diff --git a/.claude/agents/code-quality.md b/.claude/agents/code-quality.md index 0f46f2f..f609ada 100644 --- a/.claude/agents/code-quality.md +++ b/.claude/agents/code-quality.md @@ -26,11 +26,11 @@ When invoked, you will: 1. **Identify Changed Files**: Determine which files have been modified or created in the current work session. -2. **Run Linter**: Execute `npm run lint` (Biome) on changed files. Capture all errors and warnings. +2. **Run Linter**: Execute `pnpm run lint` (Biome) on changed files. Capture all errors and warnings. -3. **Run Formatter**: Apply `npm run format:fix` to all changed files using project-configured Biome formatter. +3. **Run Formatter**: Apply `pnpm run format:fix` to all changed files using project-configured Biome formatter. -4. **Type Check**: Run `npm run type-check` (tsc --noEmit) to catch type errors. +4. **Type Check**: Run `pnpm run type-check` (tsc --noEmit) to catch type errors. 5. **Analyze Project Patterns**: Review code against established FlashForgeWebUI patterns (see checklist below). @@ -144,7 +144,7 @@ Verify Biome configuration is respected: - **Semicolons**: As configured - **Line width**: Respect configured limit -Run with `npm run lint` and `npm run format` to apply project settings. +Run with `pnpm run lint` and `pnpm run format` to apply project settings. ## Behavioral Boundaries diff --git a/.claude/agents/documenter.md b/.claude/agents/documenter.md index 1d1f297..a6b379e 100644 --- a/.claude/agents/documenter.md +++ b/.claude/agents/documenter.md @@ -145,7 +145,7 @@ When processing multiple files: Always use this command to find files needing documentation: ```bash -npm run docs:check +pnpm run docs:check ``` This checks all .ts, .tsx, .js, .jsx files in src/ for @fileoverview in the first 20 lines. diff --git a/.claude/agents/primary-engineer.md b/.claude/agents/primary-engineer.md index d6d5568..043c961 100644 --- a/.claude/agents/primary-engineer.md +++ b/.claude/agents/primary-engineer.md @@ -39,7 +39,7 @@ When invoked, you will: - Use EventEmitter with typed event maps - Follow existing backend structure for printer integrations -5. **Verify and Refine**: Review your implementation for type safety, correctness, and adherence to patterns. Run `npm run type-check` and `npm run lint` to verify. +5. **Verify and Refine**: Review your implementation for type safety, correctness, and adherence to patterns. Run `pnpm run type-check` and `pnpm run lint` to verify. ## Decision Framework diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a56e050 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,134 @@ +name: CI + +on: + push: + branches: [main, alpha] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' + - '.gitattributes' + - '.editorconfig' + pull_request: + branches: [main, alpha] + paths-ignore: + - '**.md' + - 'LICENSE' + - '.gitignore' + - '.gitattributes' + - '.editorconfig' + workflow_dispatch: + inputs: + emulator-ref: + description: 'flashforge-emulator-v2 git ref to run the E2E track against' + required: false + default: 'v0.3.0' + type: string + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Type-check, lint, build, test + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout FlashForgeWebUI + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm run type-check + + - name: Lint + run: pnpm run lint + + - name: Build (backend + webui) + run: pnpm run build + + - name: Unit tests + run: pnpm test + + e2e-emulator: + name: E2E (emulator + browser) + runs-on: ubuntu-latest + needs: verify + timeout-minutes: 40 + steps: + - name: Checkout FlashForgeWebUI + uses: actions/checkout@v4 + + # The emulator lives in a separate public repo and provides the printers this + # track drives, so no real hardware is needed. Pinned to a release tag so an + # emulator change cannot silently break or silently change this CI; bump the + # tag (or dispatch with emulator-ref) to pick up a new emulator release. + - name: Checkout flashforge-emulator-v2 + uses: actions/checkout@v4 + with: + repository: GhostTypes/flashforge-emulator-v2 + ref: ${{ inputs.emulator-ref || 'v0.4.0' }} + path: emulator + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Install emulator dependencies + working-directory: emulator + run: npm ci + env: + # Only the emulator's headless runtime is used here; skip the large + # Electron binary download this repo's e2e-tests track never launches. + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + + # The browser track needs the chromium binary itself, not just its system libraries. + - name: Install Playwright chromium + run: pnpm exec playwright install --with-deps chromium + + # Unlike FlashForgeUI-Electron's CI there is no xvfb-run and no chrome-sandbox + # fixup here: this track drives the plain Node server (dist/index.js) with + # headless Chromium, never a real Electron window. + - name: Run browser E2E against the emulator + run: pnpm run test:e2e + env: + FF_EMULATOR_ROOT: ${{ github.workspace }}/emulator + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-e2e-artifacts + path: | + test-results/ + playwright-report/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b28f669..ebc475b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -63,11 +63,16 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + - name: Setup Node.js 20 uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' + cache: 'pnpm' - name: Cache pkg fetch uses: actions/cache@v4 @@ -77,19 +82,8 @@ jobs: restore-keys: | ${{ runner.os }}-pkg- - - name: Configure GitHub Packages - shell: bash - run: | - echo "@ghosttypes:registry=https://npm.pkg.github.com" >> .npmrc - echo "@parallel-7:registry=https://npm.pkg.github.com" >> .npmrc - echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Install dependencies - run: npm ci - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: pnpm install --frozen-lockfile - name: Pre-download ARMv7 Node.js Binary if: matrix.arch == 'armv7' @@ -102,15 +96,15 @@ jobs: - name: Build application shell: bash run: | - npm run build + pnpm run build if [[ "${{ matrix.platform }}" == "win" ]]; then - npx @yao-pkg/pkg . --targets node20-win-${{ matrix.arch }} --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-win-${{ matrix.arch }} --output dist/${{ matrix.output }} elif [[ "${{ matrix.platform }}" == "mac" ]]; then - npx @yao-pkg/pkg . --targets node20-macos-${{ matrix.arch }} --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-macos-${{ matrix.arch }} --output dist/${{ matrix.output }} elif [[ "${{ matrix.arch }}" == "armv7" ]]; then - npx @yao-pkg/pkg . --targets node20-linuxstatic-armv7 --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-linuxstatic-armv7 --output dist/${{ matrix.output }} else - npx @yao-pkg/pkg . --targets node20-linux-${{ matrix.arch }} --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-linux-${{ matrix.arch }} --output dist/${{ matrix.output }} fi - name: Verify binary size @@ -130,6 +124,25 @@ jobs: fi echo "✓ Binary size: $size bytes" + # Write a deterministic config so the E2E auth assertions do not depend on the + # current DEFAULT_CONFIG values. Auth is explicitly enabled with the password + # the login step posts below ("changeme"). ConfigManager merges any omitted + # keys from DEFAULT_CONFIG at load time, so only auth-relevant keys are pinned. + - name: Write deterministic test config + if: matrix.can_execute == true + shell: bash + run: | + mkdir -p data + cat > data/config.json <<'EOF' + { + "WebUIEnabled": true, + "WebUIPort": 3000, + "WebUIPassword": "changeme", + "WebUIPasswordRequired": true + } + EOF + echo "✓ Wrote deterministic data/config.json" + # Windows: use cmd wrapper to redirect output while keeping process detached - name: Start binary (Windows) if: matrix.can_execute == true && runner.os == 'Windows' @@ -204,7 +217,7 @@ jobs: fi # Test index.html contains expected content - if ! curl -sf http://127.0.0.1:3000/ | grep -q "FlashForge Web UI"; then + if ! curl -sf http://127.0.0.1:3000/ | grep "FlashForge Web UI" > /dev/null; then echo "::error::GET / did not contain 'FlashForge Web UI'" curl -s http://127.0.0.1:3000/ | head -20 exit 1 @@ -296,7 +309,7 @@ jobs: shell: bash run: | if [[ "${{ runner.os }}" == "Windows" ]]; then - if tasklist /FI "IMAGENAME eq ${{ matrix.output }}" 2>/dev/null | grep -q "${{ matrix.output }}"; then + if tasklist /FI "IMAGENAME eq ${{ matrix.output }}" 2>/dev/null | grep "${{ matrix.output }}" > /dev/null; then echo "::error::Binary left zombie processes" exit 1 fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b2a14b..45899d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,28 +59,21 @@ jobs: - name: Checkout FlashForgeWebUI uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + - name: Setup Node.js 20 uses: actions/setup-node@v4 with: node-version: 20 - cache: 'npm' + cache: 'pnpm' - - name: Configure GitHub Packages Authentication + - name: Install dependencies shell: bash run: | - echo "Setting up GitHub Packages authentication" - echo "@ghosttypes:registry=https://npm.pkg.github.com" >> .npmrc - echo "@parallel-7:registry=https://npm.pkg.github.com" >> .npmrc - echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Run npm ci - shell: bash - run: | - npm ci - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + pnpm install --frozen-lockfile - name: Pre-download ARMv7 Node.js Binary (if needed) if: matrix.arch == 'armv7' @@ -113,25 +106,25 @@ jobs: VERSION="${VERSION}.0.0" fi echo "Setting version to: $VERSION" - npm version "$VERSION" --no-git-tag-version --allow-same-version + pnpm version "$VERSION" --no-git-tag-version --allow-same-version - name: Build application shell: bash run: | # Build the TypeScript source - npm run build + pnpm run build # Package with yao-pkg based on platform/arch if [[ "${{ matrix.platform }}" == "win" ]]; then - npx @yao-pkg/pkg . --targets node20-win-${{ matrix.arch }} --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-win-${{ matrix.arch }} --output dist/${{ matrix.output }} elif [[ "${{ matrix.platform }}" == "mac" ]]; then - npx @yao-pkg/pkg . --targets node20-macos-${{ matrix.arch }} --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-macos-${{ matrix.arch }} --output dist/${{ matrix.output }} elif [[ "${{ matrix.platform }}" == "linux" ]]; then # Special case: ARMv7 needs the linuxstatic target to pick the supported Node 20 build if [[ "${{ matrix.arch }}" == "armv7" ]]; then - npx @yao-pkg/pkg . --targets node20-linuxstatic-armv7 --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-linuxstatic-armv7 --output dist/${{ matrix.output }} else - npx @yao-pkg/pkg . --targets node20-linux-${{ matrix.arch }} --output dist/${{ matrix.output }} + pnpm exec pkg . --targets node20-linux-${{ matrix.arch }} --output dist/${{ matrix.output }} fi fi @@ -206,8 +199,21 @@ jobs: RELEASE_TYPE_BADGE=" (Pre-release)" fi + PRERELEASE_NOTICE="" + if [[ "$IS_PRERELEASE" == "true" ]]; then + PRERELEASE_NOTICE="This is an alpha pre-release build intended for testing before the next stable WebUI release." + fi + VERSION_TAG="v${VERSION}" - PREVIOUS_TAG=$(git tag -l 'v*' --sort=-version:refname | grep -vx "${VERSION_TAG}" | head -n 1) + # versionsort.suffix=- ranks a prerelease below its release (v1.2.0-alpha.8 < v1.2.0); + # without it git sorts the longer alpha tag higher. A stable release compares + # against the previous stable release, so its notes span the whole alpha cycle. + # `|| true` because the step runs under pipefail and grep exits 1 on no match. + PREVIOUS_TAG=$(git -c versionsort.suffix=- tag -l 'v*' --sort=-version:refname | grep -vx "${VERSION_TAG}" || true) + if [[ "$IS_PRERELEASE" != "true" ]]; then + PREVIOUS_TAG=$(printf '%s\n' "$PREVIOUS_TAG" | grep -v -- '-' || true) + fi + PREVIOUS_TAG=$(printf '%s\n' "$PREVIOUS_TAG" | head -n 1) CHANGELOG_URL="https://github.com/${{ github.repository }}/blob/${VERSION_TAG}/CHANGELOG.md" COMPARE_URL="" @@ -215,11 +221,35 @@ jobs: COMPARE_URL="https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${VERSION_TAG}" fi + CHANGELOG_SECTION=$(VERSION="$VERSION" python3 <<'PY' + from pathlib import Path + import os + import re + + version = os.environ["VERSION"] + text = Path("CHANGELOG.md").read_text(encoding="utf-8") + pattern = rf"^## \[{re.escape(version)}\] - [^\n]*\n(.*?)(?=^## \[|\Z)" + match = re.search(pattern, text, re.MULTILINE | re.DOTALL) + if match: + print(match.group(1).strip()) + PY + ) + + if [[ -z "$(printf '%s' "$CHANGELOG_SECTION" | tr -d '[:space:]')" ]]; then + CHANGELOG_SECTION="- See the [CHANGELOG](${CHANGELOG_URL}) for the detailed release notes." + fi + cat > RELEASE_NOTES.md << EOF # FlashForgeWebUI v${VERSION}${RELEASE_TYPE_BADGE} Standalone web-based interface for FlashForge 3D printers. + ${PRERELEASE_NOTICE} + + ## Changes + + ${CHANGELOG_SECTION} + ## Downloads Choose the appropriate binary for your platform: @@ -283,6 +313,11 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.VERSION_TAG }} + # Anchor the tag to the dispatched commit. Without this, GitHub creates a + # missing tag at the repository DEFAULT branch (main) rather than the ref + # the workflow ran on, so releases cut from `alpha` were tagged against + # main and did not contain the code that was actually built and shipped. + target_commitish: ${{ github.sha }} name: ${{ env.RELEASE_NAME }} body_path: ${{ env.RELEASE_NOTES_PATH }} draft: false diff --git a/.gitignore b/.gitignore index 299fe2d..9c96c01 100644 --- a/.gitignore +++ b/.gitignore @@ -42,9 +42,15 @@ Thumbs.db # Testing coverage/ +test-results/ +playwright-report/ # Temporary files *.tmp *.temp .cache/ -data/runtime/ + +# Runtime state (generated by the app; see data/*.example.json for the schema) +data/* +!data/*.example.json + diff --git a/CHANGELOG.md b/CHANGELOG.md index 3002b8f..3de619b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,175 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-10-10 + +The first stable release since 1.1.0. It contains everything from 1.2.0-alpha.1 through 1.2.0-alpha.8, plus the changes listed under Added and Fixed below. Each alpha section further down this file has the full detail. + +### Highlights since 1.1.0 + +- **Creator 5 and Creator 5 Pro support.** Connect, monitor, and control both models, with per-tool, bed, and chamber temperatures and a Material Station card with a slot editor. (alpha.1) +- **Upload a print job from the browser.** Send a `.gcode`, `.gx`, or `.3mf` file from any device, see its thumbnail and slicer details, map tools to material-station slots, and start it. (alpha.7) +- **SSH tools for the Adventurer 5M, 5M Pro, and AD5X.** A file manager, a calibration assistant, and remote reboot, for printers set up with [FlashForge-EasySSH](https://github.com/Parallel-7/FlashForge-EasySSH). (alpha.3) +- **Better Spoolman tracking.** Material-station printers now charge filament per job, per tool. An AD5X without a material station now gets single-spool tracking. (alpha.8) +- **Camera video runs through the WebUI port.** Video goes through the authenticated WebUI server, so the camera works remotely with only the WebUI port forwarded. (alpha.1) +- **Faster connects.** Modern printers are identified from the network discovery broadcast, without an extra probe. (alpha.3) +- **Thumbnail previews** in the Recent and Local file dialogs. (alpha.2) +- **Pick the printer by URL.** `?ip=` or `?serial=` selects the active printer on load, for embedding in tools like OrcaSlicer's Device tab. (alpha.1) +- **Fixes:** the ETA no longer moves forward while a print is paused, a paused Creator 5 Pro no longer shows `Unknown` (alpha.6), and logging out now revokes the session token (alpha.7). + +### Upgrade notes + +- **Running from source:** `data/config.json` and `data/printer_details.json` are no longer tracked in git. Copy both files somewhere safe before you `git pull`, then copy them back. Otherwise git refuses the pull, or removes the files. +- **Building from source:** dependencies now install from public npm with pnpm (`pnpm install`). No GitHub Packages token or `.npmrc` is needed, which fixes the `401 Unauthorized` install error in [#18](https://github.com/Parallel-7/FlashForgeWebUI/issues/18). +- **Remote camera access:** forward only the WebUI port (default `3000`). If you forwarded the go2rtc port (`1984`) for an older version, remove that rule. That port has no authentication. +- **Adding a modern printer by hand** now requires its serial number as well as its check code. +- **`--printers` on the command line:** a Creator 5 or Creator 5 Pro needs the `creator-5` or `creator-5-pro` type and its serial number, for example `--printers="192.168.1.50:creator-5:12345678:SERIAL"`. + +### Added + +- **An About window shows the running version.** The new info button in the top bar opens it. It also links to the releases page, so you can check whether a newer version is out. Requested in [#22](https://github.com/Parallel-7/FlashForgeWebUI/issues/22). + +### Security + +- **Updated the server's HTTP and WebSocket dependencies to clear known advisories:** `axios` 1.13.6 -> 1.20.0, `express` 5.1.0 -> 5.3.0 (with its `proxy-addr`, `path-to-regexp`, `qs`, and `body-parser` dependencies), `ws` 8.18.3 -> 8.22.0, and `form-data` 4.0.5 -> 4.0.6. These packages are built into every release binary. Several of the advisories need a configuration this app does not use. For example, the critical `proxy-addr` advisory only applies when Express's `trust proxy` setting is on, and this app never turns it on. They are updated anyway. + +### Fixed + +- **The WebUI server can no longer start twice at the same moment.** A settings change that arrived during startup could start a second server while the first was still starting. The second start failed because the port was already taken, and its cleanup closed the first server's connections. Both now share one start. A stop that arrives while the server is still starting now waits for the start to finish, so the server cannot come back up after it was stopped. Thanks to [@jsalmon00](https://github.com/jsalmon00) for finding this in [FlashForgeUI-Electron#80](https://github.com/Parallel-7/FlashForgeUI-Electron/pull/80). +- **go2rtc no longer keeps running after the app exits on an error.** A failed startup and the shutdown time limit both end the app without its normal shutdown, so the go2rtc camera process kept running and holding its ports. The app now stops go2rtc whenever it exits. +- **A stable release's notes now compare against the previous stable release.** The release workflow linked "Compare changes since" to the newest older tag, which for 1.2.0 would have been `v1.2.0-alpha.8`, a one-commit diff. Git also ranked `v1.2.0-alpha.8` above `v1.2.0`, so the next alpha would have compared against alpha.8 too. Stable releases now compare against the previous stable tag, and a prerelease ranks below its release. +- **Shutdown no longer waits for a timer after the server has closed.** Stopping the WebUI server started a 3-second time limit and did not clear it when the server closed in time, so the process stayed open until the timer ran out. + +## [1.2.0-alpha.8] - 2026-09-26 + +### Added + +- **Per-job Spoolman tracking for material-station printers (Creator 5, Creator 5 Pro, AD5X with a material station).** These printers do not report filament use per tool, so the app estimates it. When you match materials for an upload, the matching dialog now also asks for the Spoolman spool in each slot, or "Do not track". The choice applies to that one print only. When the print ends, the slicer's estimate for each tool is charged to its spool, and the choice is deleted. The app never remembers a spool per slot or per printer, because the spools on the printer can change at any time. + - A completed print charges each spool its full estimate. + - A cancelled or failed print charges each tool for what it printed up to the last progress value. The app reads the gcode inside the 3MF when you upload it and finds how much of each tool's filament is used at each point of the file. The printer's progress is the position in that same file, so a tool that only prints the top half of a model is charged nothing if the print stops at 40%. + - Pause and resume charge nothing. + - If the app was not running when the print ended, it charges the last saved progress on the next status update and marks the result "approximate". A completed print is charged in full. + - Only prints the app starts are tracked. A file sent without **Start Now**, a print started on the printer, and a reprint of the same file from the printer's screen are not tracked. + - On the AD5X, a multi-material file already on the printer is tracked when you start it through the matching dialog. The estimate comes from the printer's file list, and a cancel is charged by progress, because the app has no gcode for that file. + - The Spoolman panel shows the tracked print, its spool per tool, and the result of the last charge. + - Creator 5 and Creator 5 Pro tracking is not yet tested on a real printer. If you own one, please tell us how it works in [issue #21](https://github.com/Parallel-7/FlashForgeWebUI/issues/21). +- **AD5X without a material station now gets single-spool Spoolman tracking.** Previously the AD5X was excluded from Spoolman integration outright; without a station it reports one extruder, so it now uses the same active-spool flow as the Adventurer 5M series. +- **GitHub Actions CI (`ci.yml`).** Pushes and pull requests on `main` and `alpha` now run two jobs: `verify` (type-check, lint, build, unit tests on Node 20) and `e2e-emulator` (the browser E2E suite against the `flashforge-emulator-v2` printer emulator, so no real printer is needed). The emulator is pinned to `v0.4.0`. A manual-run input can point the E2E job at a different emulator release. A failed E2E run uploads the Playwright report as an artifact. + +### Fixed + +- **Multi-material uploads now send the correct tool numbers.** A 3MF lists only the filaments a plate uses, each with its slicer number. A plate that uses filaments 1 and 3 prints with tools T0 and T2, but the upload dialog numbered the tools by their position in the list (T0 and T1). The printer then fed the second tool from the wrong slot. The dialog now takes the tool number from the filament number, and shows "Tool 1" and "Tool 3" for that plate. +- **The matching dialog waits for the material station.** Right after a connect or a printer switch, the station status can take several seconds to arrive. The dialog read it once and showed "Material station not connected" with no slots. It now keeps asking for about 15 seconds. +- **Bundled go2rtc camera-streaming binaries are now executable.** The five non-Windows binaries shipped with file mode `100644` instead of `100755`, so a fresh Linux or macOS checkout could not start the server — it exited with `EACCES` when it tried to launch go2rtc. + +### Testing + +- **Browser E2E suite expanded from 14 to 38 tests**, mirroring the desktop app's per-model emulator track: + - A shared four-model emulator matrix (Adventurer 5M Pro, AD5X, Creator 5, Creator 5 Pro) with per-model capability flags; every functional spec now loops all four models + - New connect-flow specs: auto-connect, manual connect for each model, rejection of a wrong check code, and UDP discovery + - New per-model specs for LED control and job upload + - Upload checks mirror the desktop app: the material-matching dialog must appear for a `.3mf` on material-station printers (AD5X, Creator 5, Creator 5 Pro) and never on the 5M series; the uploaded file must land on the printer; with **Start Now** unchecked, the upload must not start a print + - Gating and heater specs refactored onto the shared matrix — gating now covers the AD5X, and heaters cover all four models, including per-tool temperature readings on the Creator 5 series + - Specs open remembered-token pages, which keeps every suite inside the login rate limit (5 logins per 15 minutes per IP) + +## [1.2.0-alpha.7] - 2026-08-21 + +### Added + +- **Upload Job, ported from the desktop app.** The WebUI can now upload a `.gcode`, `.gx`, or `.3mf` file from the browser and start it. Before this, only the desktop app could do that, so a phone or a remote browser could only start files already on the printer. The dialog is a 1:1 port: file picker, **Start Now** and **Auto Level**, the plate thumbnail, and the full metadata grid — printer model, filament type and use, slicer name and version, slice date, print time, first layer time, layer height, infill, layer count, support, and slice warnings. The **Upload Job** button sits with the other job controls and turns on whenever the printer is ready for a new job. + + Material matching runs on the AD5X and the Creator 5 series. A 3MF for a material-station printer opens the existing matching dialog, which maps each tool to a slot and refuses a slot whose loaded material differs. The Creator 5 series is deliberately **not** gated here. It cannot start a job that already sits on the printer, because that firmware reports no per-file tool data — but a fresh 3MF carries its own, so upload is the one path that works on those models. + + A browser knows no local file path, so the upload runs in three steps. The file goes to the WebUI server first. The server parses it and returns the metadata. The start request then hands the printer backend a real path, which is the same input the desktop app gives it. The staged file lives in a temporary directory, is limited to 512 MB, and is deleted after the job starts or the dialog closes. Stale files expire after 30 minutes and on server shutdown. The server accepts only a bare `.gcode`, `.gx`, or `.3mf` file name, and rejects a path or any other extension. The progress bar shows the real browser-to-server transfer, which is the slow part over Wi-Fi. + +### Changed + +- `@parallel-7/slicer-meta` 1.1.0-20251121155836 -> 1.5.0. The old pin could not report layer height, infill, layer count, first layer time, or slice warnings, so the upload dialog would have shown fewer fields than the desktop one. The package was an unused dependency here until now, so nothing else changed. + +- **Material matching and the upload dialog were reworked for clarity.** The matching dialog's confirm button now always reads **Confirm** — it confirms the tool-to-slot mappings, and the flow that opened the dialog decides what happens next. It previously read **Start Print**, which promised an action the dialog itself does not perform. The desktop app's dialog shows the same label in every flow now, so the two stay in step. + + Color mismatches now have a card of their own, above the two lists. It carries one line per mismatched mapping, rebuilt on every change, so it always reflects the whole set — previously only the most recent mismatch produced a warning, so mapping a second tool hid the first problem. Each mapping row also gained a color swatch on both sides, so the expected color and the loaded color sit beside the text that compares them. + + After matching is confirmed, the upload dialog shows the mappings as chips — tool, slot, and material name, with a color swatch for each side — so the set can be checked at a glance before the job is sent. The dialog's primary button is now named after what it does: **Start Print** with **Start Now** ticked, **Send File** otherwise (it read **OK** before). Slice warnings moved out of the metadata grid into their own card, the print-time highlight became a full-width banner across the top of the grid, and on a phone the preview stacks above the metadata while the **Start Now** / **Auto Level** checkboxes stay on one line. + +### Fixed + +- **Local and recent job start is now disabled for the Creator 5 series.** Creator 5 / Creator 5 Pro firmware neither sends nor accepts material mappings over the HTTP API, so starting a previously-uploaded local or recent job dead-ended at material selection — only a fresh `.3mf` upload and start works. The dashboard's Start Recent and Start Local buttons are now disabled with the message "Local job management is not available on this printer." (the Recent/Local file dialogs show the same message as a toast instead of loading a file list). Fresh uploads are unaffected. Home Axes is likewise disabled for the series — it needs the TCP/G-code passthrough these printers lack — and a second fix was required for that gate to show at all: the shared backend base class hardcoded G-code availability in `BasePrinterBackend.buildFeatureSet()` and silently discarded the Creator 5 backend's "unavailable" declaration; the builder now honors each backend's declaration, keeping the old defaults only as fallback. + +- **Security: logging out now actually revokes the session token.** The logout route runs without the auth middleware (a logged-out caller must still reach it), so the code that read the token from the request never saw one and the revocation branch was dead code — a token from a logged-out session kept full API access until it expired. The route now extracts the token from the `Authorization` header directly, using the same helper the middleware uses. Found by the new browser test suite on its first run. + +### Testing + +- **New browser end-to-end suite drives the real built server against emulated printers — no stubs.** Coverage: login/token lifecycle (bad password, websocket auth gate, session restore, anonymous and forged tokens rejected, logout revocation), cache-busted asset and stale-build checks, printer context switching verified against the server's own state, and the Creator 5 gating above pinned across three printer models. The suite found both fixed bugs above. Note for future tests: the login rate limiter allows 5 logins per 15 minutes per IP and counts successes — every spec file documents its exact budget. + +## [1.2.0-alpha.6] - 2026-08-10 + +### Fixed + +- **A paused Creator 5 Pro no longer shows as `Unknown`.** The printer reports the raw status `"pause"`, while the API library only knew the documented `"pausing"` and `"paused"` — so every pause fell through to `Unknown`, and it did so at the worst possible moment: the printer pauses itself when it detects a clog, so the status went blank precisely when you needed to know why the print had stopped. Fixed upstream in `@ghosttypes/ff-api` 2.0.0, which this release bumps to; `"downloading"` now reads as `Busy` for the same reason. Observed on a Creator 5 Pro running firmware 1.9.4. + +- **The ETA no longer walks forward while a print is paused.** The displayed completion time was computed as `now + remaining` on every poll, which only holds still while the firmware is counting `estimatedTime` down. It freezes that field the moment the print stops advancing, so with one term fixed and the clock still moving the ETA stepped forward a minute every minute — pause on a clog for an hour and the dashboard claimed the print would finish an hour later than when the pause started, receding indefinitely. The dashboard panel and the Discord notification embed both kept recomputing with nothing to suppress them. Each now shows `--:--` (and the Discord embed omits the ETA field) unless the print is actually advancing, via the new `isPrintAdvancing` predicate in `types/polling.ts`, mirrored browser-side in `webui/static/shared/formatting.ts` — which covers `Printing` only, since the pre-print `Heating` warmup does not advance the job either and drifts identically, just for minutes rather than hours. The *duration* readouts were never wrong and are unchanged. The same bug exists in FlashForgeUI-Electron and in both API libraries, fixed in each; it originates in the C# `ff-5mp-api` (`MachineInfo.cs:210`) that every port inherited the line from. + +### Changed + +- `@ghosttypes/ff-api` 1.7.1 -> 2.0.0. The major bump is the library making `FFMachineInfo.CompletionTime` nullable for the same paused-print reason described above; this app never read that field (it derives its own ETA from the remaining duration), so nothing here changed but the pin. + +## [1.2.0-alpha.5] - 2026-07-26 + +### Fixed + +- Unknown `/api/*` paths returned 401 instead of 404. The auth middleware was mounted as a blanket `app.use('/api', ...)` layer, so it ran for every request under `/api` — including paths with no matching route — and rejected them before they could reach the splat 404 handler. Auth is now applied per-route inside `createAPIRoutes`, so every registered route stays authenticated exactly as before while unmatched paths fall through to 404 +- The E2E workflow now writes a deterministic `data/config.json` before starting the binary, so its auth assertions no longer depend on the current `DEFAULT_CONFIG` values + +## [1.2.0-alpha.4] - 2026-07-26 + +### Fixed + +- The dashboard was unusable on a fresh load: a full-screen "Rebooting printer…" overlay rendered whenever no printer was connected (including `--no-printers` startup) and could not be dismissed. The `.reboot-overlay` `display: flex` rule had equal CSS specificity to `.hidden` and came later in source order, so the overlay was always visible. Added `.reboot-overlay.hidden { display: none; }`, mirroring the existing `.cal-overlay.hidden` override. Regression from the SSH toolset port in 1.2.0-alpha.3 +- Same latent CSS specificity bug on the file-manager overlay (`.fm-overlay`); added the matching `.fm-overlay.hidden { display: none; }` override. It only surfaced when the file-manager modal was open, not on a fresh load +- Lucide "icon name was not found" warnings in the browser console. The vendored lucide bundle (v0.552.0) dropped the legacy kebab-case aliases the boot-time icon scan relied on (wifi, trash-2, alert-triangle, refresh-cw, and others). The boot-time pre-hydration set was expanded to cover every icon used in the static HTML, resolved to its PascalCase key via the existing kebab→PascalCase helper + +## [1.2.0-alpha.3] - 2026-07-26 + +### Added + +- **SSH toolset ported from the desktop app** — file manager, calibration assistant, per-printer SSH settings, and remote reboot, all reachable from the topbar. + - **File manager** — browse, rename, and multi-select delete gcode/3MF files on internal storage and USB over SFTP, with thumbnails resolved from the printer-side cache and ranged `.3mf` reads rather than full-file downloads. + - **Calibration assistant** — the full desktop workflow in the browser, including the bed-mesh visualizer, input-shaper plots, screw-adjustment solver, and animated recommendations. + - **SSH settings** — per-printer credentials keyed by serial and encrypted at rest. Passwords can be changed but are never read back to the browser. + - **Remote reboot** — one-click printer reboot with live progress. + - Requires SSH provisioned the [FlashForge-EasySSH](https://github.com/Parallel-7/FlashForge-EasySSH) way and works only on the Adventurer 5M / 5M Pro / AD5X; the buttons stay hidden on every other model, including the Creator 5 series. + +### Changed + +- Modern printers are now identified from the USB product ID carried in the UDP discovery broadcast instead of an unauthenticated TCP `M115` probe. The probe was previously skipped only for the HTTP-only Creator 5 series; it is now skipped for every new-API model (5M, 5M Pro, AD5X, Creator 5, Creator 5 Pro), removing a redundant round trip from every modern connect. The same broadcast supplies the serial and name, and `FiveMClient.initialize()` supplies the authoritative capability flags and reachability, so the probe contributed nothing +- The TCP probe remains the fallback for anything without a product ID — genuine legacy printers, and manual connects that selected "Legacy" +- Manual connections now send a product ID hint for every modern model (previously Creator 5 / Creator 5 Pro only), so those connects skip the probe as well +- The manual-connect endpoint now requires a serial number for **all** modern printers, not just the Creator 5 series, since a named model is no longer probed for it. The Creator 5 series keeps its model-specific error message +- The material-station slot-config request schema (`/spoolman/slot-config`) was tightened to the correct rules — a required material name and a strict 6-digit hex color — matching the AD5X / Creator 5 fixed material and color palettes. The previously permissive 3/6/8-digit hex and nullable material (with the `currentMaterial` fallback) are gone, since the slot editor always sends a material resolved from the model's fixed palette +- Printer discovery now delegates to the library's `PrinterDiscovery` (`@ghosttypes/ff-api` 1.7.1) instead of a local fork in `PrinterDiscoveryService.ts`, removing ~200 lines of duplicated logic and gaining proper loopback-address discovery. The discovery route was updated to match. +- Bumped `@ghosttypes/ff-api` to `^1.7.1`, which fixes `FFMachineInfo.HasMatlStation` — a raw copy of an AD5X-only `/detail` field the Creator 5 series never sends, so it arrived `undefined` on a Creator 5 Pro with four loaded slots. This UI gates the material station on `MatlStationInfo` rather than that flag, so nothing here was affected, but 1.7.0 should not ship in a new build. +- The `data/` directory is no longer tracked in git. `data/config.json` and `data/printer_details.json` were previously committed, so a clone carried the maintainer's local settings and saved printers — including per-printer check codes — and every local run showed up as a dirty working tree. Both are now generated on first run (`ConfigManager` falls back to `DEFAULT_CONFIG`, `PrinterDetailsManager` starts with no printers), and `data/config.example.json` / `data/printer_details.example.json` are tracked in their place to document the schema + +### Fixed + +- Removed the untyped manual-IP connection path (`offerManualIPEntry` / `connectDirectlyToIP`). It built a printer record from an IP address alone with no product ID, which left `createTemporaryConnection` unable to tell that the printer was HTTP-only — so it fell through to the legacy client and probed TCP 8899, which the Creator 5 series refuses outright. That is the `ECONNREFUSED` reported against this project in [ff-5mp-hass#18](https://github.com/GhostTypes/ff-5mp-hass/issues/18). The path was already unreachable in practice (its input-dialog handler is never registered), but it remained a working re-entry point for the bug. Those entry points now direct the user to the "Add Printer" form, which requires a printer type and so identifies an HTTP-only model before any socket is opened. + +- Raw G-code availability is now reported per printer (`features.gcodeCommands`) and the Home Axes button is disabled on printers that don't support it. HTTP-only printers (Creator 5 series) have no TCP channel for raw G-code, so `~G28` could never have worked there +- `DualAPIBackend` no longer assumes a legacy TCP client exists: `executeGCodeCommand` returns a clear error instead of throwing, and the pause/resume/cancel legacy fallbacks are skipped on HTTP-only backends. Those fallbacks also now report the fallback's own error rather than masking it with the original + +## [1.2.0-alpha.2] - 2026-07-05 + +### Added + +- Thumbnail previews in the Recent/Local file dialog: each file now shows a lazily loaded preview image (with a "No Preview" fallback), backed by a persistent per-printer on-disk cache (`data/Thumbnails/{serial}/`) so re-opening the dialog is instant + +### Fixed + +- TVOC air-quality reading now displays on the filtration card for every printer that reports it (e.g. the Adventurer 5M Pro), instead of only the Creator 5 Pro. The Creator 5 Pro now shows the filtration card with its controls disabled rather than hiding it entirely + +## [1.2.0-alpha.1] - 2026-07-04 + ### Added - Creator 5 / Creator 5 Pro printer support via the new `Creator5Backend`, including model detection, an HTTP-only connection flow, and material station support @@ -200,7 +369,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional password authentication - Configuration persistence in `data/config.json` -[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.1.0...v1.2.0 +[1.2.0-alpha.8]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.7...v1.2.0-alpha.8 +[1.2.0-alpha.7]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.6...v1.2.0-alpha.7 +[1.2.0-alpha.6]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.5...v1.2.0-alpha.6 +[1.2.0-alpha.5]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.2...v1.2.0-alpha.5 +[1.2.0-alpha.4]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.3...v1.2.0-alpha.4 +[1.2.0-alpha.3]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.2...v1.2.0-alpha.3 +[1.2.0-alpha.2]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.2.0-alpha.1...v1.2.0-alpha.2 +[1.2.0-alpha.1]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.1.0...v1.2.0-alpha.1 [1.1.0]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.2...v1.1.0 [1.0.2]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/Parallel-7/FlashForgeWebUI/compare/v1.0.0...v1.0.1 diff --git a/CLAUDE.md b/CLAUDE.md index 8886c76..7ba2558 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,70 +12,70 @@ FlashForgeWebUI is a standalone web-based interface for controlling and monitori ### Development ```bash -npm run dev # Build and watch with hot reload (concurrent backend + webui + server) -npm run build # Full production build (backend + webui) -npm run build:watch # Watch backend and frontend builds without starting the server -npm run start # Run the built application -npm run start:dev # Run with nodemon (watches for changes) +pnpm run dev # Build and watch with hot reload (concurrent backend + webui + server) +pnpm run build # Full production build (backend + webui) +pnpm run build:watch # Watch backend and frontend builds without starting the server +pnpm run start # Run the built application +pnpm run start:dev # Run with nodemon (watches for changes) ``` ### Build Components ```bash -npm run build:backend # Bundle backend with esbuild (scripts/build-backend.ts) -npm run build:backend:watch # Watch backend files -npm run build:webui # Compile frontend TS + copy static assets -npm run build:webui:watch # Watch frontend files -npm run build:webui:copy # Copy HTML/CSS and vendor libraries to dist +pnpm run build:backend # Bundle backend with esbuild (scripts/build-backend.ts) +pnpm run build:backend:watch # Watch backend files +pnpm run build:webui # Compile frontend TS + copy static assets +pnpm run build:webui:watch # Watch frontend files +pnpm run build:webui:copy # Copy HTML/CSS and vendor libraries to dist ``` ### Platform-Specific Builds ```bash -npm run build:linux # Linux x64 executable (using pkg) -npm run build:linux-arm # Linux ARM64 executable -npm run build:linux-armv7 # Linux ARMv7 executable -npm run build:win # Windows x64 executable -npm run build:mac # macOS x64 executable -npm run build:mac-arm # macOS ARM64 executable -npm run build:all # Build for all platforms -npm run build:wrapper # Run the platform build wrapper directly -npm run build:win:wrapped # Windows x64 build via wrapper -npm run build:linux:wrapped # Linux x64 build via wrapper -npm run build:linux-arm:wrapped # Linux ARM64 build via wrapper -npm run build:linux-armv7:wrapped # Linux ARMv7 build via wrapper -npm run build:mac:wrapped # macOS x64 build via wrapper -npm run build:mac-arm:wrapped # macOS ARM64 build via wrapper -npm run build:all:wrapped # All wrapped platform builds +pnpm run build:linux # Linux x64 executable (using pkg) +pnpm run build:linux-arm # Linux ARM64 executable +pnpm run build:linux-armv7 # Linux ARMv7 executable +pnpm run build:win # Windows x64 executable +pnpm run build:mac # macOS x64 executable +pnpm run build:mac-arm # macOS ARM64 executable +pnpm run build:all # Build for all platforms +pnpm run build:wrapper # Run the platform build wrapper directly +pnpm run build:win:wrapped # Windows x64 build via wrapper +pnpm run build:linux:wrapped # Linux x64 build via wrapper +pnpm run build:linux-arm:wrapped # Linux ARM64 build via wrapper +pnpm run build:linux-armv7:wrapped # Linux ARMv7 build via wrapper +pnpm run build:mac:wrapped # macOS x64 build via wrapper +pnpm run build:mac-arm:wrapped # macOS ARM64 build via wrapper +pnpm run build:all:wrapped # All wrapped platform builds ``` ### Code Quality ```bash -npm run lint # Run Biome lint checks -npm run lint:fix # Auto-fix Biome lint issues -npm run format # Preview Biome formatting changes -npm run format:fix # Apply Biome formatting changes -npm run check # Run Biome check (lint + format combined) -npm run check:fix # Auto-fix Biome check issues -npm run type-check # TypeScript type checking -npm run type-check:app # Type check main application -npm run docs:check # Validate @fileoverview coverage in source files -npm run docs:check:debug # Debug fileoverview validation output -npm run clean # Remove dist directory -npm run download:go2rtc # Manually download go2rtc binary +pnpm run lint # Run Biome lint checks +pnpm run lint:fix # Auto-fix Biome lint issues +pnpm run format # Preview Biome formatting changes +pnpm run format:fix # Apply Biome formatting changes +pnpm run check # Run Biome check (lint + format combined) +pnpm run check:fix # Auto-fix Biome check issues +pnpm run type-check # TypeScript type checking +pnpm run type-check:app # Type check main application +pnpm run docs:check # Validate @fileoverview coverage in source files +pnpm run docs:check:debug # Debug fileoverview validation output +pnpm run clean # Remove dist directory +pnpm run download:go2rtc # Manually download go2rtc binary ``` ### Testing ```bash # Jest unit/integration tests -npm test # Run all Jest tests -npm run test:watch # Jest watch mode -npm run test:coverage # Jest with coverage -npm run test:verbose # Jest verbose output +pnpm test # Run all Jest tests +pnpm run test:watch # Jest watch mode +pnpm run test:coverage # Jest with coverage +pnpm run test:verbose # Jest verbose output # TypeScript checks -npm run type-check # Type check the application (tsc --noEmit) +pnpm run type-check # Type check the application (tsc --noEmit) # Passthrough: append extra args after -- -# npm test -- --testPathPattern=Config +# pnpm test -- --testPathPattern=Config ``` ## Runtime Modes @@ -99,7 +99,7 @@ node dist/index.js --no-printers node dist/index.js --last-used --webui-port=3001 --webui-password=mypassword ``` -Printer spec format: `IP:TYPE:CHECKCODE` where TYPE is `new` or `legacy`. +Printer spec format: `IP:TYPE[:CHECKCODE[:SERIAL]]` where TYPE is `new`, `legacy`, `creator-5`, or `creator-5-pro`. The Creator tokens are required for the Creator 5 series because those printers are HTTP-only — the generic `new` token triggers a legacy TCP probe they cannot answer. SERIAL is required for the Creator series (their serial cannot be recovered by probing) and optional for dual-API models, which fall back to the serial reported by the TCP probe. ## Architecture @@ -146,6 +146,7 @@ src/index.ts # Entry point - initializes all singletons, conn - `AD5XBackend` - Adventurer 5M X series (uses AD5X API) - `Adventurer5MBackend` - Adventurer 5M (FiveMClient) - `Adventurer5MProBackend` - Adventurer 5M Pro (FiveMClient) +- `Creator5Backend` - Creator 5 / Creator 5 Pro (HTTP-only connection flow; IFS Material Station support) - `DualAPIBackend` - Base for printers supporting both FiveMClient and FlashForgeClient - `GenericLegacyBackend` - Fallback for older printers (FlashForgeClient only) @@ -173,25 +174,31 @@ src/index.ts # Entry point - initializes all singletons, conn - `WebUIManager` - Express HTTP server and static file serving - `WebSocketManager` - Real-time bidirectional communication with the frontend - `AuthManager` - Optional password authentication +- `CameraStreamProxy` - Authenticated bridge between WebUI clients and the local go2rtc API at `/api/camera/ws`; browsers never reach the go2rtc port directly. `WebUIManager` owns a single HTTP `upgrade` router that dispatches by pathname: `/ws` → `WebSocketManager.handleUpgrade`, `/api/camera/ws` → `CameraStreamProxy.handleUpgrade`, else `socket.destroy()`. - API routes organized by feature (context, printer-control, printer-status, printer-detection, printer-management, job, camera, temperature, filtration, spoolman, theme, discovery) - Frontend uses GridStack for dashboard layout and the bundled `video-rtc` player for go2rtc-backed camera streaming ### Data Directory -The application stores runtime state in `data/` under the project working directory. In the current repo, only `data/runtime/` is ignored by `.gitignore`, so local edits to top-level files like `data/config.json` can appear in `git status`. +The application stores runtime state in `data/` under the project working directory. The whole directory is ignored by `.gitignore` except `data/*.example.json` — nothing the app writes at runtime is tracked, so local printer credentials never reach a commit. -Key files: +Key files (all generated; none are tracked): - `data/config.json` - User settings (WebUI, Discord, Spoolman, theme, debug settings) - `data/printer_details.json` - Saved printer details and printer-specific overrides used for reconnects and camera resolution +- `data/Thumbnails/{serial}/` - Thumbnail cache written by `ThumbnailCacheService` -Default config values live in `src/types/config.ts` and are loaded through `ConfigManager`. +Neither file needs to exist at startup: `ConfigManager` falls back to `DEFAULT_CONFIG` and `PrinterDetailsManager` starts with an empty printer map, and both write themselves out on first save. The tracked `data/config.example.json` and `data/printer_details.example.json` document the schema for anyone hand-editing a headless deployment; they are reference only and are never read by the app. + +Default config values live in `src/types/config.ts` and are loaded through `ConfigManager`. When you add a config key, update `DEFAULT_CONFIG` **and** `data/config.example.json`. ### Build System +**Package Manager**: pnpm (pinned to `pnpm@10.23.0` via the `packageManager` field in `package.json`). The lockfile is `pnpm-lock.yaml`; use `pnpm install --frozen-lockfile` in CI. Dependencies install into pnpm's default isolated `node_modules` layout (no hoisting is configured) — declare anything you import as a direct dependency rather than relying on transitive resolution. + **Backend Bundling** (`scripts/build-backend.ts`): - Bundles `src/index.ts` to `dist/index.js` as a CommonJS entrypoint - Uses `packages: 'external'` to keep `node_modules` separate for pkg compatibility -- `tsc` is still used for type checking via `npm run type-check` +- `tsc` is still used for type checking via `pnpm run type-check` **Frontend Compilation** (`src/webui/static/tsconfig.json`): - Compiles frontend TypeScript to `dist/webui/static/` as browser ES modules @@ -201,14 +208,14 @@ Default config values live in `src/types/config.ts` and are loaded through `Conf - Copies vendor libraries from `node_modules/` including GridStack, Lucide, and `video-rtc` **Documentation Validation** (`scripts/check-fileoverview.go`): -- Powers `npm run docs:check` and `npm run docs:check:debug` +- Powers `pnpm run docs:check` and `pnpm run docs:check:debug` - Verifies `@fileoverview` coverage across the source tree **Wrapped Platform Builds** (`scripts/platform-build-wrapper.ts`): - Provides wrapper entrypoints for the `build:*:wrapped` scripts **go2rtc Binary** (`scripts/download-go2rtc.cjs`): -- Runs at `npm install` time via the `postinstall` hook +- Runs at `pnpm install` time via the `postinstall` hook - Downloads the platform-specific go2rtc binary to `resources/bin/` **pkg Bundling**: @@ -228,8 +235,8 @@ Default config values live in `src/types/config.ts` and are loaded through `Conf ## Dependencies **Core**: -- `@ghosttypes/ff-api` - FlashForge printer API clients (`FiveMClient`, `FlashForgeClient`) -- `@parallel-7/slicer-meta` - Printer metadata and model detection +- `@ghosttypes/ff-api` - FlashForge printer API clients (`FiveMClient`, `FlashForgeClient`). Pinned at `^2.0.3` (the 2.x line since 1.2.0-alpha.6). The 2.0.0 major was one breaking change — `FFMachineInfo.CompletionTime` became `Date | null` — which this app is immune to because it never reads that field; it derives its own ETA from the remaining duration. 2.0.0 is also what brings the Creator 5 Pro `"pause"` status mapping, so a paused C5 Pro no longer reads as `Unknown`. Resolved from **public npm** since 1.2.0-alpha.8 (as is `@parallel-7/slicer-meta`) — no `.npmrc`, token, or GitHub Packages access is needed, and CI no longer writes one. +- `@parallel-7/slicer-meta` - Printer metadata and model detection. Pinned exactly at `1.5.0`. It pulls in `adm-zip` 0.5.x, which has open advisories fixed only in 0.6; the fix has to come from a `slicer-meta` release that widens its `adm-zip` range. - `express` - HTTP server - `ws` - WebSocket server - `zod` - Schema validation @@ -264,16 +271,21 @@ Some printers support **both** (dual API). The backend system abstracts these di - Adventurer 5M X -> `AD5XBackend` - Adventurer 5M -> `Adventurer5MBackend` - Adventurer 5M Pro -> `Adventurer5MProBackend` +- Creator 5 / Creator 5 Pro -> `Creator5Backend` - Other new-API printers -> backend selected from discovery metadata and `clientType` - Legacy printers -> `GenericLegacyBackend` **Feature Detection**: Each backend declares supported features via `getBaseFeatures()`. The UI shows or hides controls based on those features, including LEDs, power toggle, material station support, and camera support. -**Model Detection (TCP-First Bootstrap, PID-Aware)**: -- The HTTP `/detail` endpoint on modern printers requires authentication (`serialNumber` + `checkCode`), so during the very first connection — before the user has supplied a check code — we cannot read the firmware-set `pid` from `/detail`. `ConnectionEstablishmentService.ts` therefore opens an unauthenticated TCP `M115` first via `tcpClient.getPrinterInfo()` and feeds the resulting `TypeName` (firmware-controlled, e.g. `"FlashForge Adventurer 5M Pro"`) into `detectPrinterModelType` / `detectPrinterFamily` in `src/utils/PrinterUtils.ts` for backend selection. This is correct and intentional — `TypeName` is firmware-set and is NOT the same as the user-mutable `Name` field on `/detail`. +**Model Detection (Discovery-PID-First, TCP Fallback)**: +- **Modern printers are typed from the discovery broadcast, not a TCP probe.** The UDP discovery response (276-byte modern packet) carries the USB product ID at `0x88`, the serial number at `0x92` and the name at `0x00`. `ConnectionEstablishmentService.createTemporaryConnection()` short-circuits on any product ID in `NEW_API_PRODUCT_IDS` (35 = 5M, 36 = 5M Pro, 38 = AD5X, 40 = Creator 5, 41 = Creator 5 Pro) and synthesizes the type info from that packet — **no TCP probe runs for any modern model**. This is safe because `establishDualAPIConnection` consumes none of the probe's output, capability flags come from `FiveMClient.initialize()` post-init, and reachability is covered by `initialize()` hitting authenticated `/detail`. (Verified against real hardware: a 5M Pro and an AD5X both return their serial at `0x92` and a correct PID at `0x88`.) +- **The TCP probe remains the fallback for anything with no product ID.** Genuine legacy printers, and manual/headless connects that named no model, still open an unauthenticated TCP `M115` via `tcpClient.getPrinterInfo()`; the resulting `TypeName` (firmware-controlled, e.g. `"FlashForge Adventurer 5M Pro"`) feeds `detectPrinterModelType` / `detectPrinterFamily` in `src/utils/PrinterUtils.ts`. `TypeName` is firmware-set and is NOT the same as the user-mutable `Name` field on `/detail`. +- **Manual connects supply what the broadcast would have.** Because a named model skips the probe, the manual connect form requires the serial number and check code, and sends a product ID hint (`MANUAL_PRODUCT_ID_HINTS` in `src/webui/static/features/printer-discovery.ts`, keys matching the type dropdown in `index.html`). Selecting `legacy` sends no product ID and is probed as before. - **Once paired, trust the library.** After the check code is supplied and `FiveMClient.initialize()` succeeds, `client.isPro` / `client.isAD5X` / `info.Pid` (from `@ghosttypes/ff-api>=1.3.1`) are derived from the firmware `pid` (35 = 5M, 36 = 5M Pro, 38 = AD5X). Read those flags for capability gating; do not re-substring-match `info.Name` — that field is user-set via the LCD or cloud and changing it broke detection in pre-fix builds (`ff-5mp-hass#13`). - **Don't manually overwrite `client.isAD5X`.** If you find yourself re-deriving capability flags that the library already sets, prefer fixing the library or the backend-selection input over mutating the FiveMClient instance from app code. +**The wall-clock ETA is gated on `isPrintAdvancing`**: `isPrintAdvancing` (`src/types/polling.ts`, mirrored browser-side in `src/webui/static/shared/formatting.ts`) covers `Printing` and nothing else, and every surface that converts a remaining duration into a clock time must gate on it — the dashboard panel (`src/webui/static/ui/panels.ts`) and the Discord embed. The reason: `now + remaining` only holds still while the firmware counts `estimatedTime` down, and it freezes that field the moment the print stops advancing — so the ETA stepped forward a minute every minute and receded for as long as a pause lasted. **`Heating` is excluded along with the paused states**: the pre-print warmup does not advance the job either and drifts identically, just for minutes rather than hours. Duration readouts are unaffected and need no gate. The predicate is duplicated server-side and browser-side because the two run in different bundles; change both, and note the same rule exists in FlashForgeUI-Electron and in both API libraries. + ## Event Flow 1. **Startup** (`src/index.ts`) @@ -349,20 +361,21 @@ class Service extends EventEmitter { ## Gotchas 1. **Dual Build System**: Backend uses esbuild bundling, frontend uses a separate `tsconfig` for browser modules. -2. **Data Directory Tracking**: Runtime state lives in `/data/`, but only `data/runtime/` is ignored by the current `.gitignore`. -3. **Camera Streams**: go2rtc manages camera streams per context; there is no user-facing global `CameraProxyPort` setting. -4. **go2rtc Binary**: The binary is downloaded at `npm install` time and stored under `resources/bin/`. If download or packaging fails, camera streaming will not work. +2. **Data Directory Tracking**: Runtime state lives in `/data/`, which is fully gitignored except `data/*.example.json`. Do not re-add generated state to git — `printer_details.json` holds per-printer check codes, which are the printers' LAN auth credentials. +3. **Camera Streams**: go2rtc manages camera streams per context, but browsers never reach the go2rtc port directly — video flows through an authenticated WebUI proxy at `/api/camera/ws` (see `CameraStreamProxy`). Only the WebUI port should be exposed/forwarded; the unauthenticated go2rtc port (default `1984`) must NOT be forwarded. There is no user-facing global `CameraProxyPort` setting. +4. **go2rtc Binary**: The binary is downloaded at `pnpm install` time and stored under `resources/bin/`. If download or packaging fails, camera streaming will not work. 5. **Polling Frequency**: All contexts poll every 3 seconds to avoid inactive-context TCP keep-alive failures. 6. **Context IDs**: IDs are UUID-based and generated at connection time; they are not tied to IP or serial number. 7. **Backend Lifecycle**: Backends are created per context and maintain their own TCP connections. 8. **Graceful Shutdown**: Shutdown stops polling, Discord, printer connections, go2rtc, and the WebUI with layered timeouts. 9. **Windows Compatibility**: Ctrl+C handling includes a readline bridge on Windows. 10. **ARMv7 Builds**: Raspberry Pi 32-bit builds target `node20-linuxstatic-armv7`, which depends on `@yao-pkg/pkg`. +11. **CRLF vs Biome LF**: The repo is checked out CRLF repo-wide, but `biome.json` sets `lineEnding: "lf"`. As a result `pnpm run check` (and `lint`/`format`) reports ~30+ format errors across nearly every source file regardless of what changed — this is pre-existing, not a regression from your edit. Only fix lint/format issues you actually introduced. ## Testing Notes **Jest unit/integration tests** (`src/`): -- ConfigManager, EnvironmentService, DiscordNotificationService, error utilities, WebUIManager integration +- ConfigManager, EnvironmentService, DiscordNotificationService, error utilities, WebUIManager integration, `CameraStreamProxy`, and camera routes (via the `test-server.ts` Express fixture helper) **Verified and tested**: - Multi-printer context switching diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..450cbc9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,154 @@ +# Contributing to FlashForge WebUI + +Thanks for helping out. This guide covers how the branches work, how to get a working dev setup, what to check before you open a pull request, and how releases are cut. + +## Branches: always work from `alpha` + +| Branch | What it is | +| --- | --- | +| `alpha` | Where all development happens. Every alpha pre-release is cut from here. | +| `main` | The stable branch. It only changes when a finished alpha cycle is merged in for a stable release. Its README is the one most users see. | + +**Branch from `alpha` and open your pull request against `alpha`.** A pull request against `main` will be asked to retarget, because `main` only receives the release merge described below. + +```bash +git clone https://github.com//FlashForgeWebUI.git +cd FlashForgeWebUI +git checkout alpha +git checkout -b fix/short-description +``` + +Before you start, and again before you open the pull request, bring your branch up to date with `alpha`: + +```bash +git remote add upstream https://github.com/Parallel-7/FlashForgeWebUI.git # once +git fetch upstream +git rebase upstream/alpha +``` + +## How a release happens + +1. Work lands on `alpha` through pull requests. +2. When a set of changes is ready for testers, an **alpha pre-release** (`vX.Y.Z-alpha.N`) is cut from `alpha`. +3. When the alpha cycle is stable, `alpha` is merged into `main` through a pull request. +4. The **stable release** (`vX.Y.Z`) is cut from `main`. +5. `alpha` is fast-forwarded to `main`, and the next alpha number starts there. + +Contributors only take part in step 1. The rest is done by maintainers and is written down at the end of this file. + +## Setting up + +You need: + +- **Node.js 20 or newer** +- **pnpm 10.23.0.** The version is pinned in `package.json`. The easiest way to get the right one is `corepack enable`. Do not use npm or yarn, and do not commit a `package-lock.json` or `yarn.lock`. +- **Go** (optional). Only `pnpm run docs:check` needs it. + +Every dependency is on public npm. You do not need a GitHub token or an `.npmrc`. + +```bash +pnpm install # also downloads the go2rtc camera binary into resources/bin/ +pnpm run build +pnpm start -- --no-printers +``` + +Open `http://localhost:3000`. The default password is `changeme`. + +For day-to-day work, `pnpm run dev` rebuilds the backend and frontend on change and restarts the server. + +Runtime state (config, saved printers, the thumbnail cache) is written to `data/`, which git ignores. Never commit anything from it: `data/printer_details.json` holds printer check codes, which are the printers' LAN passwords. + +### No printer? Use the emulator + +[flashforge-emulator-v2](https://github.com/GhostTypes/flashforge-emulator-v2) emulates the Adventurer 5M Pro, AD5X, Creator 5, and Creator 5 Pro on your machine. The E2E suite uses it, and you can point the WebUI at it by hand to try a change without real hardware. + +## Project layout + +```text +src/index.ts Entry point: loads config, connects printers, starts the WebUI +src/managers/ Singleton coordinators (config, printer contexts, backends, connections) +src/printer-backends/ One backend per printer family, all extending BasePrinterBackend +src/services/ Polling, camera (go2rtc), Spoolman, Discord, discovery, monitoring +src/webui/server/ Express server, WebSocket, auth, and the API routes +src/webui/static/ Browser code (separate tsconfig, compiled to ES modules) +src/types/, src/utils/ Shared types and helpers +tests/e2e/ Playwright browser suite run against the emulator +``` + +`CLAUDE.md` at the repo root has a deeper architecture tour, covering the multi-printer context model, how backends are selected, and the event flow. + +## Before you open a pull request + +Run these. CI runs the same checks on every pull request to `alpha` and `main`: + +```bash +pnpm run type-check +pnpm run lint +pnpm test +pnpm run build +``` + +Also worth running: + +- `pnpm run docs:check` if you added a source file. Every file needs a `@fileoverview` header comment. +- `FF_EMULATOR_ROOT=/path/to/flashforge-emulator-v2 pnpm run test:e2e` if you touched the connection flow, the WebUI server, or anything a printer model gates. CI runs this suite too, so you can also let CI do it. + +A note on formatting: the repo is checked out with CRLF line endings, but Biome expects LF, so `pnpm run check` and `pnpm run format` report format errors in files you never touched. Only fix the issues your change introduced. `pnpm run lint` is the check CI enforces. + +## Conventions + +- **Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/): `feat(scope): ...`, `fix(scope): ...`, `docs: ...`, `test: ...`, `ci: ...`, `build: ...`, `chore: ...`. Keep the subject short and describe the change, not the process. +- **Changelog.** If users would notice your change, add an entry under `## [Unreleased]` in `CHANGELOG.md`. Write it for users: what was wrong or missing, and what happens now. +- **New config keys** go in both `DEFAULT_CONFIG` (`src/types/config.ts`) and `data/config.example.json`. +- **Printer capabilities.** Gate features on what the backend declares, and on the API library's model flags (`isPro`, `isAD5X`, the product ID). Do not gate on the printer's name, because users can change it. +- **Printer API bugs** usually belong in the API library, [ff-5mp-api-ts](https://github.com/GhostTypes/ff-5mp-api-ts), not in a workaround here. Open an issue there, or mention it in your pull request. +- **The desktop app.** Much of the WebUI is shared with [FlashForgeUI-Electron](https://github.com/Parallel-7/FlashForgeUI-Electron). If you fix a bug in shared WebUI code, say so in the pull request so the fix can land in both. + +## Pull requests + +- Keep each pull request to one change. Small pull requests are reviewed faster. +- Say what you changed and why, and link the issue if there is one. +- Say how you tested it, including which printer models (real or emulated) you tried. Changes that talk to printers should be tried on real hardware or the emulator before review. +- Add or update tests for behavior you change, where it is practical. + +## Reporting bugs + +Open an [issue](https://github.com/Parallel-7/FlashForgeWebUI/issues) and include: + +- The WebUI version. Click the info button in the top bar to see it. +- How you run it: release binary (which platform), from source, or Docker. +- The printer model and firmware version. +- What you expected, what happened, and the server's console output around the problem. + +## For maintainers: cutting releases + +Releases are built and published by the **Build and Release** workflow (`.github/workflows/release.yml`), which you start by hand from the Actions tab. It builds all six binaries, creates the `vX.Y.Z` tag at the commit it ran on, and publishes the GitHub release. The release body is the matching `## [X.Y.Z]` section of `CHANGELOG.md`, so that section must already be pushed. **Never create release tags by hand.** + +### Alpha pre-release (from `alpha`) + +1. Move the `[Unreleased]` entries into a new `## [X.Y.Z-alpha.N] - YYYY-MM-DD` section, and update the compare links at the bottom of `CHANGELOG.md`. +2. `pnpm version X.Y.Z-alpha.N --no-git-tag-version` +3. Commit (`chore(release): prepare X.Y.Z-alpha.N`) and push to `alpha`. +4. Run the workflow on `alpha` with the version and **pre-release** checked. + +### Stable release (from `main`) + +1. On `alpha`, write the `## [X.Y.Z]` changelog section. It becomes the release page for people upgrading from the last stable version, so lead with a summary of the whole alpha cycle and any upgrade notes. Then run `pnpm version X.Y.Z --no-git-tag-version`, commit, and push. +2. Open a pull request from `alpha` to `main`. Wait for CI to pass, then merge it with a **merge commit**. A squash or rebase merge would leave `main` and `alpha` with different histories. +3. Run the workflow on `main` with the version and **pre-release** unchecked. +4. Fast-forward `alpha` to `main`, then start the next cycle: + + ```bash + git checkout alpha + git pull --ff-only origin main + git push origin alpha + ``` + +### After any release + +Check that the tag points at the commit you released: + +```bash +git fetch --tags +git rev-list -n1 vX.Y.Z # must match the branch head you released from +``` diff --git a/PORTING_TODO.md b/PORTING_TODO.md new file mode 100644 index 0000000..3915bb0 --- /dev/null +++ b/PORTING_TODO.md @@ -0,0 +1,69 @@ +# Porting TODO — drift from FlashForgeUI-Electron + +Last sync: 2026-07-10 — the **SSH feature set** was ported from FlashForgeUI-Electron +(`alpha` branch) in one pass. This file tracks what was ported and what has NOT +yet been brought over, so future syncs know where the two codebases still diverge. + +## Ported in the 2026-07-10 SSH sync + +- **Types**: `src/types/{calibration,ssh-settings,file-manager,printer-power}.ts` +- **Calibration engine**: `src/services/calibration/**` (engine, parsers, shaper, + report, ssh) + its 8 Jest suites, `src/managers/CalibrationManager.ts` +- **SSH stack**: `SSHSettingsService` (per-serial credential store, + `ssh-settings.json` in the data dir), `SSHConnectionManager`/`SCPFileTransfer` + (under `services/calibration/ssh/`), `FileManagerService` (SFTP listing / + delete / rename / thumbnails), `PrinterRebootService` (reboot + reconnect + monitor, REBOOT_STATUS WebSocket broadcasts) +- **Routes** (registered in `api-routes.ts`): `calibration-routes`, + `file-manager-routes`, `ssh-settings-routes`, `printer-power-routes` +- **Static client**: `features/{file-manager,calibration,reboot,ssh}.ts` + + `features/calibration/` (canvas visualizers + local types), topbar buttons + (folder / gauge / power, hidden for unsupported models), the calibration and + file-manager modals, the reboot confirm modal + progress overlay, the + Settings → SSH section, `REBOOT_STATUS` handling in `core/Transport.ts` +- **New deps**: `ssh2`, `pdf-lib`, `pngjs` (+ `@types/ssh2`, `@types/pngjs`) +- **Utils**: `SecureStorage` (see caveat below), `isRebootSupportedModel` in + `PrinterUtils` + +### Port-specific caveats + +- **`SecureStorage` is base64-only here.** The desktop app encrypts SSH + passwords with Electron `safeStorage` (`enc:` prefix); this Node/pkg build has + no OS keychain, so passwords are stored `plain:`-prefixed base64. An + `ssh-settings.json` copied from the desktop app with `enc:` passwords resolves + those to the easy-SSH default. +- **Passwords are write-only over the REST surface** (`GET /api/ssh-settings` + returns only a `passwordIsCustom` flag). Keep this invariant when touching the + routes. +- SSH features target **Adventurer 5M / 5M Pro / AD5X only** (flashforge-easyssh + provisioning, default `root`/`flashforge`). Creator 5 / 5 Pro are intentionally + unsupported for now. + +## NOT yet ported (known drift from FlashForgeUI-Electron) + +1. **`ifs-station` → `material-station` rename.** FFUI renamed the grid + component (with saved-layout auto-migration) and made the filament palette + per-printer-family (AD5X vs Creator 5) with CIEDE2000 color snapping in a + shared `material-station` card. This repo still uses + `features/ifs-station.ts`, `shared/ifs-palette.ts`, and the + `ifs-station` component id. +2. **Newer FFUI static client.** FFUI's WebUI has grown: theme *profiles* + (save/load named themes) in settings, updated component registry entries + (`creator5-temperature` behaviors), camera bootstrap fixes, icon-hydration + fixes covered by its browser Playwright suite. Diff + `src/main/webui/static/**` against `src/webui/static/**` when syncing. +3. **Server route drift.** FFUI has additional/updated route modules (material + station slot config, debug routes, updated spoolman/camera routes). Only the + four SSH-feature route files were synced. +4. **Shared-type drift.** FFUI's `@shared/types` have evolved (web-api types, + material station, polling payloads). This repo duplicates them under + `src/types/` and they are NOT auto-synced. +5. **slicer-meta / ff-api versions.** Check dependency versions against FFUI + when syncing job-upload/discovery behavior. +6. **Pre-existing `npm audit` findings.** 16 vulnerabilities (3 critical) + reported at install time, unrelated to the SSH port — needs its own pass. + +## Validation commands + +`pnpm run type-check` → `pnpm run build` → `pnpm run lint` → `pnpm test` → +`pnpm run docs:check` (all passing as of the 2026-07-10 sync). diff --git a/README.md b/README.md index b2be1d1..c83f4cb 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,15 @@ FlashForge WebUI is a lightweight, standalone web interface for monitoring and c | Feature | Description | | --- | --- | -| **Multi-Printer Support** | Connect to and manage multiple printers simultaneously with isolated contexts | -| **Live Monitoring** | Real-time tracking of temperatures, print progress, and printer status | -| **Camera Streaming** | Low-latency video monitoring with RTSP and MJPEG support | -| **Printer Control** | Full control over print jobs (pause, resume, stop) and printer settings | -| **Spoolman Integration** | Native integration for filament inventory and usage tracking | +| **Multi-Printer Support** | Connect to and manage multiple printers simultaneously with isolated contexts • Select the active printer by URL (`?ip=` / `?serial=`) for embedding in tools like OrcaSlicer's Device tab | +| **Live Monitoring** | Real-time tracking of temperatures, print progress, ETA, and printer status • Per-tool, bed, and chamber temperatures on the Creator 5 series | +| **Camera Streaming** | Low-latency video through go2rtc (WebRTC, MSE, MJPEG) • Video is tunneled through the authenticated WebUI port, so remote access needs only one forwarded port | +| **Printer Control** | Pause, resume, and cancel jobs • Temperatures, LEDs, filtration, and homing where the printer supports them • Start recent and local jobs with thumbnail previews | +| **Upload Job** | Upload a `.gcode`, `.gx`, or `.3mf` from any browser, review its thumbnail and slicer metadata, and start it • Tool-to-slot material matching for material-station printers | +| **Material Station** | Live IFS slot view (material and color) with a manual slot editor for the AD5X and Creator 5 series | +| **SSH Tools** | File manager, calibration assistant, and remote reboot for the Adventurer 5M, 5M Pro, and AD5X • Requires SSH set up with [FlashForge-EasySSH](https://github.com/Parallel-7/FlashForge-EasySSH) | +| **Spoolman Integration** | Filament usage tracking: the active spool on single-extruder printers, and per-job, per-tool tracking on material-station printers | +| **Discord Notifications** | Webhook notifications for print events, plus periodic status updates with optional camera snapshots | | **Responsive Dashboard** | Customizable grid-based UI that works on desktop, tablet, and mobile | | **Cross-Platform** | Native binaries for Windows, macOS, and Linux (x64, ARM64, ARMv7) | @@ -66,6 +70,7 @@ FlashForge WebUI supports a wide range of FlashForge printers through its adapta | Requirement | Details | | --- | --- | | **Node.js** | Version 20.0.0 or higher (for source installation) | +| **pnpm** | Pinned at 10.23.0 via Corepack (`corepack enable`) or installed globally | | **Network** | Connection to your printer(s) | @@ -81,53 +86,55 @@ Download the appropriate binary for your platform from the [Releases](https://gi | Platform | Binary | Notes | | --- | --- | --- | | **Windows x64** | `flashforge-webui-win-x64.exe` | Most Windows PCs | -| **macOS x64** | `flashforge-webui-macos-x64` | Intel Macs | -| **macOS ARM** | `flashforge-webui-macos-arm64` | Apple Silicon (M1/M2/M3) | -| **Linux x64** | `flashforge-webui-linux-x64` | Most Linux PCs | -| **Linux ARM64** | `flashforge-webui-linux-arm64` | Raspberry Pi 4/5 (64-bit OS) | -| **Linux ARMv7** | `flashforge-webui-linux-armv7` | Raspberry Pi 3/4 (32-bit OS) | +| **macOS x64** | `flashforge-webui-macos-x64.bin` | Intel Macs | +| **macOS ARM** | `flashforge-webui-macos-arm64.bin` | Apple Silicon (M1/M2/M3) | +| **Linux x64** | `flashforge-webui-linux-x64.bin` | Most Linux PCs | +| **Linux ARM64** | `flashforge-webui-linux-arm64.bin` | Raspberry Pi 4/5 (64-bit OS) | +| **Linux ARMv7** | `flashforge-webui-linux-armv7.bin` | Raspberry Pi 3/4 (32-bit OS) | -**Raspberry Pi Users:** Use `flashforge-webui-linux-arm64` for 64-bit Raspberry Pi OS, or `flashforge-webui-linux-armv7` for 32-bit. +**Raspberry Pi Users:** Use `flashforge-webui-linux-arm64.bin` for 64-bit Raspberry Pi OS, or `flashforge-webui-linux-armv7.bin` for 32-bit. ```bash # Make the binary executable (Linux/macOS) -chmod +x flashforge-webui-linux-arm64 +chmod +x flashforge-webui-linux-arm64.bin # Run with auto-connect to last used printer -./flashforge-webui-linux-arm64 --last-used +./flashforge-webui-linux-arm64.bin --last-used # Run without auto-connect -./flashforge-webui-linux-arm64 --no-printers +./flashforge-webui-linux-arm64.bin --no-printers ```

Running from Source

+All dependencies — including the `@ghosttypes` and `@parallel-7` scoped packages — are public on npm: no `.npmrc`, token, or GitHub Packages access is needed to build from source. + ```bash # Clone the repository git clone https://github.com/Parallel-7/FlashForgeWebUI.git cd FlashForgeWebUI # Install dependencies -npm install +pnpm install # Build the application (required before first run) -npm run build +pnpm run build # Start the server -npm start +pnpm start # Or start with auto-connect to last used printer -npm start -- --last-used +pnpm start -- --last-used ``` **Development Mode:** ```bash # Build and watch for changes with hot reload -npm run dev +pnpm run dev ```
@@ -146,6 +153,8 @@ Or if accessing from another device on your network: http://:3000 ``` +**Checking Your Version:** Click the info button in the top bar to open the About window. It shows the running version and links to the [Releases](https://github.com/Parallel-7/FlashForgeWebUI/releases) page, so you can see whether an update is available. + **Default Login:** The default password is `changeme`. You should change this in the active config file (by default `data/config.json`, or the directory pointed to by `DATA_DIR`) or via the `--webui-password` flag. **Remote Access / Port Forwarding:** Everything the WebUI needs - including live camera streams - is served over the single WebUI port (default `3000`). If you expose the WebUI outside your LAN, forward **only** that port. Camera video is tunneled through the authenticated WebUI server, so do **not** forward the internal go2rtc streaming port (`1984`); it has no authentication of its own and should never be reachable from outside your machine's network. @@ -160,7 +169,7 @@ http://:3000 | --- | --- | | **--last-used** | Connect to the last used printer on startup | | **--all-saved-printers** | Connect to all saved printers on startup | -| **--printers="IP:TYPE:CODE,..."** | Connect to specific printers (TYPE: "new" or "legacy") | +| **--printers="IP:TYPE[:CODE[:SERIAL]],..."** | Connect to specific printers (TYPE: "new", "legacy", "creator-5", or "creator-5-pro"). SERIAL is required for the Creator 5 series | | **--no-printers** | Start WebUI only, without connecting to any printer | | **--webui-port=PORT** | Override the WebUI port (default: 3000) | | **--webui-password=PASS** | Override the WebUI password | @@ -177,11 +186,11 @@ By default, runtime data is stored in `./data` under the working directory. Set ```bash # Linux/macOS -DATA_DIR=/path/to/flashforge-data npm start +DATA_DIR=/path/to/flashforge-data pnpm start # PowerShell $env:DATA_DIR = 'C:\FlashForgeWebUI\data' -npm start +pnpm start ```
@@ -194,27 +203,38 @@ npm start | **WebUIPasswordRequired** | `true` | Require password to access | | **SpoolmanEnabled** | `false` | Enable Spoolman integration | | **SpoolmanServerUrl** | `""` | Your Spoolman server URL (e.g., `http://192.168.1.100:7912`) | +| **SpoolmanUpdateMode** | `"weight"` | Report filament usage to Spoolman by `"weight"` or `"length"` | +| **DiscordSync** | `false` | Enable Discord webhook notifications | +| **WebhookUrl** | `""` | Your Discord webhook URL | +| **DiscordUpdateIntervalMinutes** | `5` | Minutes between periodic status updates | +| **DiscordIncludeCameraSnapshots** | `false` | Attach camera snapshots to Discord notifications | +| **DebugMode** | `false` | Enable verbose debug logging |
+A full example lives in [`data/config.example.json`](data/config.example.json). It is reference only and is never read by the app. +

Testing

```bash # Jest unit/integration tests -npm test -npm run test:watch # watch mode -npm run test:coverage # coverage report +pnpm test +pnpm run test:watch # watch mode +pnpm run test:coverage # coverage report # TypeScript checks -npm run type-check +pnpm run type-check # Lint/format -npm run check +pnpm run check + +# Browser E2E suite against emulated printers (no real printer needed) +FF_EMULATOR_ROOT=/path/to/flashforge-emulator-v2 pnpm run test:e2e ``` -Tests live alongside the source under `src/` (`*.test.ts`) and run through Jest. +Unit tests live alongside the source under `src/` (`*.test.ts`) and run through Jest. The browser E2E suite under `tests/e2e/` builds the app and drives it with Playwright against [flashforge-emulator-v2](https://github.com/GhostTypes/flashforge-emulator-v2). CI runs both on every push and pull request to `main` and `alpha`.

Building from Source

@@ -222,12 +242,12 @@ Tests live alongside the source under `src/` (`*.test.ts`) and run through Jest. ```bash # Build for specific platform -npm run build:linux # Linux x64 -npm run build:linux-arm # Linux ARM64 (Raspberry Pi 4/5) -npm run build:linux-armv7 # Linux ARMv7 (Raspberry Pi 3) -npm run build:win # Windows x64 -npm run build:mac # macOS x64 -npm run build:mac-arm # macOS ARM (Apple Silicon) +pnpm run build:linux # Linux x64 +pnpm run build:linux-arm # Linux ARM64 (Raspberry Pi 4/5) +pnpm run build:linux-armv7 # Linux ARMv7 (Raspberry Pi 3) +pnpm run build:win # Windows x64 +pnpm run build:mac # macOS x64 +pnpm run build:mac-arm # macOS ARM (Apple Silicon) ```
@@ -238,11 +258,23 @@ npm run build:mac-arm # macOS ARM (Apple Silicon) | Issue | Solution | | --- | --- | -| **"Cannot GET /" or blank page when accessing WebUI** | If running from source: Make sure you ran `npm run build` before `npm start`
If using a pre-1.0.2 binary: Update to version 1.0.2 or later (fixes static file serving bug) | -| **"Permission denied" when running binary** | Run `chmod +x flashforge-webui-linux-*` to make executable | +| **"Cannot GET /" or blank page when accessing WebUI** | If running from source: Make sure you ran `pnpm run build` before `pnpm start`
If using a pre-1.0.2 binary: Update to version 1.0.2 or later (fixes static file serving bug) | +| **"Permission denied" when running binary** | Run `chmod +x flashforge-webui-linux-*.bin` to make executable | | **Port already in use** | Change the port in the active config file (default `data/config.json`, or the directory pointed to by `DATA_DIR`) or use `--webui-port=3001` | -| **Cannot connect to printer** | Ensure your printer is on the same network as the device running WebUI
Check that the printer's IP address is correct
For legacy printers, ensure TCP port 8899 is accessible | -| **Selecting the correct binary for your platform** | Windows: `flashforge-webui-win-x64.exe`
macOS Intel: `flashforge-webui-macos-x64`
macOS Apple Silicon: `flashforge-webui-macos-arm64`
Linux x64: `flashforge-webui-linux-x64`
Raspberry Pi (64-bit OS): `flashforge-webui-linux-arm64`
Raspberry Pi (32-bit OS): `flashforge-webui-linux-armv7`
Check your architecture with `uname -m` (x86_64 = x64, aarch64 = ARM64, armv7l = ARMv7) | +| **Cannot connect to printer** | Ensure your printer is on the same network as the device running WebUI
Check that the printer's IP address is correct
For legacy printers, ensure TCP port 8899 is accessible
When adding a modern printer by hand, enter both its serial number and check code | +| **Camera works locally but not remotely** | Forward only the WebUI port (default `3000`). Video is tunneled through it, so the go2rtc port (`1984`) should never be forwarded | +| **`401 Unauthorized` from `npm.pkg.github.com` when installing** | Older versions pulled some packages from GitHub Packages through `package-lock.json`. Since 1.2.0 every dependency is on public npm and the project uses pnpm: update your checkout and run `pnpm install`, not `npm install` | +| **Selecting the correct binary for your platform** | Windows: `flashforge-webui-win-x64.exe`
macOS Intel: `flashforge-webui-macos-x64.bin`
macOS Apple Silicon: `flashforge-webui-macos-arm64.bin`
Linux x64: `flashforge-webui-linux-x64.bin`
Raspberry Pi (64-bit OS): `flashforge-webui-linux-arm64.bin`
Raspberry Pi (32-bit OS): `flashforge-webui-linux-armv7.bin`
Check your architecture with `uname -m` (x86_64 = x64, aarch64 = ARM64, armv7l = ARMv7) | + +
+ +
+

Contributing

+
+ +
+ +All development happens on the `alpha` branch. Fork the repo, branch from `alpha`, and open your pull request against `alpha`. See [CONTRIBUTING.md](CONTRIBUTING.md) for the setup, checks, and release flow.
@@ -264,7 +296,8 @@ npm run build:mac-arm # macOS ARM (Apple Silicon) | --- | --- | | **[ff-5mp-api-ts](https://github.com/GhostTypes/ff-5mp-api-ts)** | FlashForge API Client Library | | **[slicer-meta](https://github.com/Parallel-7/slicer-meta)** | Printer Metadata & Model Utilities | -| **FlashForgeUI-Electron** | Original Desktop Application | -| **Spoolman** | Filament Management | +| **[FlashForgeUI-Electron](https://github.com/Parallel-7/FlashForgeUI-Electron)** | Original Desktop Application | +| **[Spoolman](https://github.com/Donkie/Spoolman)** | Filament Management | +| **[go2rtc](https://github.com/AlexxIT/go2rtc)** | Camera Streaming |
diff --git a/data/config.json b/data/config.example.json similarity index 85% rename from data/config.json rename to data/config.example.json index e435a5a..bfd4c81 100644 --- a/data/config.json +++ b/data/config.example.json @@ -2,8 +2,9 @@ "WebUIEnabled": true, "WebUIPort": 3000, "WebUIPassword": "changeme", - "WebUIPasswordRequired": false, + "WebUIPasswordRequired": true, "DiscordSync": false, + "DiscordIncludeCameraSnapshots": false, "WebhookUrl": "", "DiscordUpdateIntervalMinutes": 5, "SpoolmanEnabled": false, diff --git a/data/printer_details.example.json b/data/printer_details.example.json new file mode 100644 index 0000000..77bb47b --- /dev/null +++ b/data/printer_details.example.json @@ -0,0 +1,22 @@ +{ + "lastUsedPrinterSerial": "SNXXXXXXXXXXXX", + "printers": { + "SNXXXXXXXXXXXX": { + "Name": "Adventurer 5M Pro", + "IPAddress": "192.168.1.100", + "SerialNumber": "SNXXXXXXXXXXXX", + "CheckCode": "00000000", + "ClientType": "new", + "printerModel": "Flashforge Adventurer 5M Pro", + "modelType": "adventurer-5m-pro", + "commandPort": 8899, + "httpPort": 8898, + "customCameraEnabled": false, + "customCameraUrl": "", + "customLedsEnabled": false, + "forceLegacyMode": false, + "webUIEnabled": true, + "lastConnected": "2026-01-01T00:00:00.000Z" + } + } +} diff --git a/data/printer_details.json b/data/printer_details.json deleted file mode 100644 index 8cea163..0000000 --- a/data/printer_details.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "lastUsedPrinterSerial": "SNMQRE9400951", - "printers": { - "SNMQRE9400951": { - "Name": "AD5X", - "IPAddress": "192.168.1.110", - "SerialNumber": "SNMQRE9400951", - "CheckCode": "0e35a229", - "ClientType": "new", - "printerModel": "Flashforge AD5X", - "modelType": "ad5x", - "customCameraEnabled": false, - "customCameraUrl": "", - "customLedsEnabled": false, - "forceLegacyMode": false, - "lastConnected": "2025-11-23T02:52:58.265Z" - } - } -} diff --git a/docs/research-tcp-probe-discovery.md b/docs/research-tcp-probe-discovery.md new file mode 100644 index 0000000..b13fd0f --- /dev/null +++ b/docs/research-tcp-probe-discovery.md @@ -0,0 +1,153 @@ +# Research: TCP-probe usage in the automatic discovery/connection path + +**Scope:** Standalone FlashForgeWebUI (web UI server + headless CLI). Research only — no code changed. +**Question:** Can the TCP M115 probe be removed from the *automatic UDP-discovery → connect* path for modern printers, keeping it only for genuine legacy printers? + +**Short answer:** **Yes, for modern printers.** The UDP broadcast already carries the two things the TCP probe is actually used for on modern printers — the USB product ID (authoritative model identity) and the serial number. The TCP probe is only *required* for genuine legacy printers (140-byte broadcast, no productId/serial), where the probe socket is also reused as the runtime control channel. + +--- + +## 1. UDP broadcast vs TCP probe — what each provides + +### UDP broadcast (`src/services/PrinterDiscoveryService.ts`, `parsePrinterResponse`) +- **Modern packet (≥276 bytes)** — `PrinterDiscoveryService.ts:257-273`: + - `name` → `readNullTerminatedAscii(0x00, 132)` (L258) + - `serialNumber` → `readNullTerminatedAscii(0x92, 130)` (L259) + - `commandPort` → `readUInt16BE(0x84)` (L265) + - `eventPort` → `readUInt16BE(0x8e)` (L266) + - **`productId` → `readUInt16BE(0x88)`** (L269) — the USB product ID, authoritative for model identity +- **Legacy packet (140 ≤ len < 276)** — `PrinterDiscoveryService.ts:275-284`: + - `name` → `readNullTerminatedAscii(0x00, 128)` (L275) + - `commandPort` → `readUInt16BE(0x84)` (L281) + - `serialNumber: ''` (hardcoded empty, L280) — **no serial, no productId, no eventPort** + +### TCP M115 probe (`FlashForgeClient.getPrinterInfo()` → `PrinterInfo`) +Source of truth: `node_modules/@ghosttypes/ff-api/dist/tcpapi/replays/PrinterInfo.d.ts` and `FlashForgeClient.d.ts:195`. +Fields: `TypeName`, `Name`, `FirmwareVersion`, `SerialNumber`, `Dimensions`, `MacAddress`, `ToolCount`. + +**Which of those the app actually consumes** (`createTemporaryConnection`, `ConnectionEstablishmentService.ts:129-308`): only `TypeName` (L228), `Name` (L233), `SerialNumber` (L234). `FirmwareVersion` is dead — it appears **only** in the type defs (`src/types/printer.ts:85,95`) and is never read for any branching decision (confirmed by grep). `Dimensions`/`MacAddress`/`ToolCount` are unused. The probe also returns a `_reuseableClient` (L248) — but **only in the legacy branch** (`!familyInfo.is5MFamily`, L241). + +--- + +## 2. Table — TCP-probe outputs, consumers, alternatives, verdict + +| TCP-probe output | Used by (file:line) | Non-TCP alternative | Verdict | +|---|---|---|---| +| **`TypeName`** (model identity) | detection route `detectPrinterFamily` (`printer-detection-routes.ts:88`); headless `detectPrinterModelType` (`ConnectionFlowManager.ts:597,1262`); backend selection via `printerModel` (`PrinterBackendManager.ts:185,269`) | UDP `productId`@0x88 → `NEW_API_PRODUCT_IDS` (`PrinterUtils.ts:132-138`); post-pairing `client.isPro`/`isAD5X`/`info.Pid` (per CLAUDE.md) | **Removable for modern printers** (productId covers 5M/5M Pro/AD5X/Creator 5/5 Pro). **Required for legacy** (no productId in legacy packet). | +| **`SerialNumber`** | FiveMClient auth `serial+checkCode` (`ConnectionEstablishmentService.ts:383-386`); saved-printer keying (`ConnectionFlowManager.ts:600-604,1260`); `/detect`→`/connect` handoff (`printer-detection-routes.ts:77-81`) | UDP `serialNumber`@0x92 (modern 276-byte packet only) | **Removable for modern** (broadcast carries it). **Required fallback** when broadcast serial is empty. | +| **`Name`** (user-assigned) | display name (`ConnectionFlowManager.ts:590-593`) | UDP `name`@0x00 | **Removable** (broadcast carries it). | +| **`FirmwareVersion`** | nothing | HTTP `/detail` after pairing | **Already removable** (dead code — unused). | +| `Dimensions` / `MacAddress` / `ToolCount` | nothing | n/a | **Already removable** (unused). | +| **`_reuseableClient`** (reusable TCP control socket) | `establishLegacyConnection` (`ConnectionEstablishmentService.ts:474-499`) | none — TCP **is** the legacy transport | **REQUIRED for legacy printers only.** | + +--- + +## 3. The HTTP-only short-circuit (quoted verbatim) + +`src/services/ConnectionEstablishmentService.ts:136-153`: + +```ts + // HTTP-only models (Creator 5 / 5 Pro) run no legacy TCP server, so the usual + // TCP probe can't work. When discovery's USB product ID identifies such a model, + // synthesize the type info from the discovery packet and skip the TCP probe. + const idModelType = detectPrinterModelTypeFromId(printer.productId, ''); + if (isHttpOnlyModel(idModelType)) { + const typeName = getModelDisplayName(idModelType); + console.log(`[Connection] HTTP-only model detected via product ID: ${typeName}`); + this.emit('printer-type-detected', { typeName, familyInfo: detectPrinterFamily(typeName) }); + return { + success: true, + typeName, + printerInfo: { + Name: printer.name, + SerialNumber: printer.serialNumber, + TypeName: typeName, + } as unknown as ExtendedPrinterInfo, + }; + } +``` + +**Logic:** +1. Compute `idModelType = detectPrinterModelTypeFromId(printer.productId, '')` — keyed **only** on productId (typeName arg is `''`). +2. If `isHttpOnlyModel(idModelType)` (i.e. `creator-5`/`creator-5-pro`, `PrinterUtils.ts:98-102`), skip the TCP probe. +3. Return synthesized result: `typeName` = display name (e.g. `"Creator 5"`); `Name` and `SerialNumber` copied straight from the discovery packet (`printer.name` / `printer.serialNumber` — the latter can be `''`). + +The dual-API product IDs (5M/5M Pro/AD5X = 35/36/38) deliberately do **not** short-circuit — proven by `src/services/ConnectionEstablishmentService.test.ts:38-64` ("does not short-circuit for dual-API product IDs", productId 35). **This test is the exact line that a removal change would flip.** + +--- + +## 4. Caller graph of the TCP probe (`createTemporaryConnection`) + +Grep-confirmed consumers: +- `/api/printers/detect` — `printer-detection-routes.ts:68` (frontend detect step). +- `connectHeadlessDirect` — `ConnectionFlowManager.ts:1241` (the `/connect` route + `--printers=` CLI). +- `connectToPrinter` — `ConnectionFlowManager.ts:573` (interactive/Electron-era flow; **not invoked by any HTTP route or `src/index.ts`** — vestigial in the standalone WebUI). +- `establishLegacyConnection` — `ConnectionEstablishmentService.ts:478` (reached only for genuine legacy printers; reuses the probe socket). + +**Startup path (`src/index.ts:124,159,177`) uses only `connectHeadlessFromSaved` / `connectHeadlessDirect`.** `connectHeadlessFromSaved → connectWithSavedDetails` calls `establishFinalConnection` directly (L944) — **no probe** for saved-printer reconnects. So the probe is *not* run at startup for already-saved printers; it only runs for the live discover/manual-direct path. + +--- + +## 5. Creator 5 serial flow, end-to-end + +### Case A — discovered Creator 5 **with** a serial in the broadcast (276-byte packet, serial@0x92 populated) +1. Discovery returns `DiscoveredPrinter{ productId:40|41, serialNumber:"", name, ports }`. +2. Frontend `connectToDiscoveredPrinter(ip, serial, …)` — `printer-discovery.ts:336`. + - `POST /api/printers/detect` body `{ ipAddress, commandPort, httpPort, productId }` — **serial is NOT forwarded** (`printer-discovery.ts:361-366`). + - detect route builds `mockPrinter.serialNumber = ''` (body has no serial, `printer-detection-routes.ts:60`); `createTemporaryConnection` short-circuits on productId; returns `SerialNumber: ''`. + - detect route returns `serialNumber: ''` (`printer-detection-routes.ts:77-81,99`). + - Frontend: `detectedSerial = serialNumber || serial` = `'' || ""` = `""` (`printer-discovery.ts:375`). ✓ + - `POST /api/printers/connect` body includes `serialNumber:""`, `productId:40` (`printer-discovery.ts:403-421`). +3. connect route: `serialNumber` is truthy → the http-only serial guard at `printer-management-routes.ts:66-76` passes → `connectHeadlessDirect([spec])`. +4. Headless: `createTemporaryConnection` (mock has productId+serial) short-circuits; `probedSerial = spec.serialNumber` (`ConnectionFlowManager.ts:1254-1260`); model refined from `primaryClient.model` (`ConnectionFlowManager.ts:1303-1310`); `establishDualAPIConnection(httpOnly=true)` creates the FiveMClient with serial+checkCode (`ConnectionEstablishmentService.ts:389,394`). + - **→ One-click connect works today when the broadcast carries the serial.** ✓ + +### Case B — discovered Creator 5 **without** a serial (broadcast serial@0x92 empty/missing) +1. Discovery returns `serialNumber:''` (modern packet) or the legacy packet (no serial field at all). +2. `connectToDiscoveredPrinter(ip, serial='', …)`: + - `/detect` returns `serialNumber:''`; frontend `detectedSerial = '' || '' = ''`. + - `/connect` body: `serialNumber:''` → in the route: + ```ts + // printer-management-routes.ts:58-76 + const serialNumber = + typeof body.serialNumber === 'string' && body.serialNumber.trim() !== '' + ? body.serialNumber.trim() + : undefined; + if ( + typeof productId === 'number' && + isHttpOnlyModel(detectPrinterModelTypeFromId(productId, '')) && + !serialNumber + ) { + return sendErrorResponse(res, 400, 'Serial number is required for Creator 5 series printers'); + } + ``` + - **→ 400 "Serial number is required for Creator 5 series printers".** ✗ + +The manual-connect path can never hit this 400, because `connectManually` requires a serial for any modern type (`printer-discovery.ts:480-489`) and supplies the productId hint (`MANUAL_PRODUCT_ID_HINTS`, `printer-discovery.ts:27-30,476`). + +--- + +## 6. Recommendation + +**TCP probing can be removed from the automatic discovery path for modern printers.** The minimal, low-risk change is to **generalize the existing HTTP-only short-circuit to all new-API product IDs**, keeping the TCP probe as the fallback for genuine-legacy/unknown cases: + +1. **`src/services/ConnectionEstablishmentService.ts:139-153`** — widen the guard from `isHttpOnlyModel(idModelType)` to "productId present and resolves to a known new-API model", i.e. roughly: + `if (printer.productId !== undefined && printer.productId in NEW_API_PRODUCT_IDS) { …synthesize from productId + broadcast name + broadcast serial… }` + This single change removes the TCP probe from `/detect` and `connectHeadlessDirect` for 5M/5M Pro/AD5X, exactly as it already does for Creator 5/5 Pro. +2. **`src/services/ConnectionEstablishmentService.test.ts:38-64`** — the "does not short-circuit for dual-API product IDs" test encodes the *current* (probe-runs) behavior and must be rewritten to assert synthesis for productId 35/36/38. +3. **Optional polish (not required for correctness):** have the frontend discover path forward the discovered serial to `/detect` (`printer-discovery.ts:361-366`) so the detect response is self-contained. Not strictly needed — `printer-discovery.ts:375` (`serialNumber || serial`) already falls back to the discovered serial. +4. **Keep the TCP probe as the fallback** in `createTemporaryConnection` for: `productId === undefined`, `productId === 0`, or any value not in `NEW_API_PRODUCT_IDS` (genuine legacy + unknown). Legacy printers *must* keep it — it is their runtime control channel (`_reuseableClient`, `ConnectionEstablishmentService.ts:241-250,474-499`). + +**Nothing else forces TCP to stay** for modern printers. The runtime dual-API TCP socket (secondary `FlashForgeClient` for G-code, `ConnectionEstablishmentService.ts:427-452`) is unrelated to the *probe* and would remain; only the redundant *type-detection* TCP connection goes away. + +### CLAUDE.md rationale reconciliation +CLAUDE.md states the TCP-first M115 bootstrap is "correct and intentional" because `/detail` requires auth before pairing. That was true when the only pre-auth sources of model identity were `/detail` (auth-gated) and the TCP M115. The codebase has since added the **UDP `productId`@0x88** as a pre-auth, authoritative model-identity source (`NEW_API_PRODUCT_IDS`, `PrinterUtils.ts:132-138`) — the HTTP-only short-circuit already relies on it. So for the **UDP-discovery** path specifically, broadcast `productId` + broadcast serial make the probe redundant. The CLAUDE.md note's general point still holds for the auth-gated `/detail` and for manual-IP connects where no productId is supplied. + +--- + +## 7. Unknowns needing firmware/hardware confirmation + +1. **productId@0x88 reliability for dual-API printers** — is it always populated and non-zero on 5M / 5M Pro / AD5X across firmware versions? (Already trusted for Creator 5.) Safe by fallback: a `0`/absent value falls through to the TCP probe, so a misconfigured field degrades gracefully rather than breaking. +2. **broadcast serial@0x92 reliability** — is it reliably populated for modern dual-API printers? If empty, the synthesized path returns `serial:''`; the discover path then degrades to the `Unknown-${Date.now()}` fallback (`ConnectionFlowManager.ts:613-616,1260`), which breaks saved-printer keying. Creator 5 already depends on this field being usable (the 400 in Case B). +3. **Whether the synthesized display-name TypeName (e.g. `"Adventurer 5M Pro"`) stored as `printerModel` is acceptable** vs. the firmware string `"FlashForge Adventurer 5M Pro"`. The headless path refines it from `primaryClient.model` (`ConnectionFlowManager.ts:1303-1310`); the `/detect`-driven path does not. Cosmetic/consistency only — `detectPrinterFamily`/`detectPrinterModelType` use substring matching and accept either. +4. **Vestigial interactive flow** — `startConnectionFlow`/`tryAutoConnect`/`connectToPrinter`/`connectDirectlyToIP` are not wired to any WebUI route or `index.ts`. Confirm they are truly dead before relying on that claim; if kept, widening the guard removes TCP there too (harmless). diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 7bbd54d..0000000 --- a/package-lock.json +++ /dev/null @@ -1,8281 +0,0 @@ -{ - "name": "flashforge-webui", - "version": "1.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "flashforge-webui", - "version": "1.1.0", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@ghosttypes/ff-api": "^1.6.1", - "@parallel-7/slicer-meta": "1.1.0-20251121155836", - "axios": "^1.8.4", - "express": "^5.1.0", - "form-data": "^4.0.0", - "gridstack": "^12.3.3", - "lucide": "^0.552.0", - "ws": "^8.18.3", - "zod": "^4.0.5" - }, - "bin": { - "flashforge-webui": "dist/index.js" - }, - "devDependencies": { - "@biomejs/biome": "2.4.5", - "@jest/globals": "^30.2.0", - "@types/express": "^4.17.21", - "@types/jest": "^30.0.0", - "@types/node": "^20.17.9", - "@types/supertest": "^6.0.3", - "@types/ws": "^8.5.13", - "@yao-pkg/pkg": "^5.15.0", - "concurrently": "^9.1.2", - "esbuild": "^0.27.3", - "jest": "^30.2.0", - "nodemon": "^3.1.11", - "rimraf": "^6.0.1", - "supertest": "^7.2.2", - "ts-jest": "^29.4.6", - "tsx": "^4.19.2", - "typescript": "^5.7.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "../ff-5mp-api-ts": { - "name": "@ghosttypes/ff-api", - "version": "1.3.2", - "extraneous": true, - "license": "ISC", - "dependencies": { - "axios": "^1.8.4", - "form-data": "^4.0.0" - }, - "devDependencies": { - "@biomejs/biome": "2.3.14", - "@types/axios": "^0.9.36", - "@types/node": "^22.14.0", - "@vitest/coverage-v8": "^4.0.18", - "tsx": "^4.20.3", - "typescript": "^5.8.3", - "vitest": "^4.0.18" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", - "@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/core/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/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", - "@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-compilation-targets/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/@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.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.6" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", - "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/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@biomejs/biome": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.5.tgz", - "integrity": "sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ==", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.5", - "@biomejs/cli-darwin-x64": "2.4.5", - "@biomejs/cli-linux-arm64": "2.4.5", - "@biomejs/cli-linux-arm64-musl": "2.4.5", - "@biomejs/cli-linux-x64": "2.4.5", - "@biomejs/cli-linux-x64-musl": "2.4.5", - "@biomejs/cli-win32-arm64": "2.4.5", - "@biomejs/cli-win32-x64": "2.4.5" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.5.tgz", - "integrity": "sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.5.tgz", - "integrity": "sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.5.tgz", - "integrity": "sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.5.tgz", - "integrity": "sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.5.tgz", - "integrity": "sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.5.tgz", - "integrity": "sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.5.tgz", - "integrity": "sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.5.tgz", - "integrity": "sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@ghosttypes/ff-api": { - "version": "1.6.1", - "resolved": "https://npm.pkg.github.com/download/@ghosttypes/ff-api/1.6.1/95687f4d27782db0a171c35a569aff8b93d1a916", - "integrity": "sha512-/WopgepJL0hMojkDkIxpUyd2SiPTaXo5wt6aZJFV7BzkXSnMEAqZmHIaI/DumfAfyvXkcQ0o3Q4iRhBKbULGAA==", - "license": "ISC", - "dependencies": { - "axios": "^1.8.4", - "form-data": "^4.0.0" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", - "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "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/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@parallel-7/slicer-meta": { - "version": "1.1.0-20251121155836", - "resolved": "https://npm.pkg.github.com/download/@parallel-7/slicer-meta/1.1.0-20251121155836/9338b2ad8e107d4deabeb4ddec5746df51990d66", - "integrity": "sha512-yLwF0qUCkrTy8RkyXJUM80F2ElYscL2GW7vA1LmGNPpdH1McV/XXbA/wi6ZFLJfVBVoSPGE8sNfxsLfW3YRqfg==", - "dependencies": { - "adm-zip": "^0.5.14", - "date-fns": "^4.1.0", - "fast-xml-parser": "^5.2.1" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "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/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cookiejar": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", - "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", - "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", - "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^30.0.0", - "pretty-format": "^30.0.0" - } - }, - "node_modules/@types/methods": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", - "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/superagent": { - "version": "8.1.9", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", - "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cookiejar": "^2.1.5", - "@types/methods": "^1.1.4", - "@types/node": "*", - "form-data": "^4.0.0" - } - }, - "node_modules/@types/supertest": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", - "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/methods": "^1.1.4", - "@types/superagent": "^8.1.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@yao-pkg/pkg": { - "version": "5.16.1", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.16.1.tgz", - "integrity": "sha512-crUlnNFSReFNFuXDc4f3X2ignkFlc9kmEG7Bp/mJMA1jYyqR0lqjZGLgrSDYTYiNsYud8AzgA3RY1DrMdcUZWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.23.0", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "@yao-pkg/pkg-fetch": "3.5.16", - "into-stream": "^6.0.0", - "minimist": "^1.2.6", - "multistream": "^4.1.0", - "picocolors": "^1.1.0", - "picomatch": "^4.0.2", - "prebuild-install": "^7.1.1", - "resolve": "^1.22.0", - "stream-meter": "^1.0.4", - "tinyglobby": "^0.2.9" - }, - "bin": { - "pkg": "lib-es5/bin.js" - } - }, - "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.5.16", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.16.tgz", - "integrity": "sha512-mCnZvZz0/Ylpk4TGyt34pqWJyBGYJM8c3dPoMRV8Knodv2QhcYS4iXb5kB/JNWkrRtCKukGZIKkMLXZ3TQlzPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.6", - "picocolors": "^1.1.0", - "progress": "^2.0.3", - "semver": "^7.3.5", - "tar-fs": "^2.1.1", - "yargs": "^16.2.0" - }, - "bin": { - "pkg-fetch": "lib-es5/bin.js" - } - }, - "node_modules/@yao-pkg/pkg-fetch/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/@yao-pkg/pkg-fetch/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@yao-pkg/pkg-fetch/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/@yao-pkg/pkg/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.2.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", - "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "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/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "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/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "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.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "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": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", - "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/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "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/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC" - }, - "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.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/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dedent": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", - "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.279", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz", - "integrity": "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "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==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "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/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/exit-x": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.0", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "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/finalhandler": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", - "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "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==", - "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/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/glob/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/gridstack": { - "version": "12.3.3", - "resolved": "https://registry.npmjs.org/gridstack/-/gridstack-12.3.3.tgz", - "integrity": "sha512-Bboi4gj7HXGnx1VFXQNde4Nwi5srdUSuCCnOSszKhFjBs8EtMEWhsKX02BjIKkErq/FjQUkNUbXUYeQaVMQ0jQ==", - "funding": [ - { - "type": "paypal", - "url": "https://www.paypal.me/alaind831" - }, - { - "type": "venmo", - "url": "https://www.venmo.com/adumesny" - } - ], - "license": "MIT" - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "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": "BSD-3-Clause" - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC" - }, - "node_modules/into-stream": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", - "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "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-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-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", - "import-local": "^3.2.0", - "jest-cli": "30.2.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.2.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "p-limit": "^3.1.0", - "pretty-format": "30.2.0", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", - "parse-json": "^5.2.0", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "jest-util": "30.2.0", - "string-length": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "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==", - "dev": true, - "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/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "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/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, - "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/lucide": { - "version": "0.552.0", - "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.552.0.tgz", - "integrity": "sha512-f9PSKLsd4TtGRnRnbqZ2IMKQ2tfCA/dwHaZHysmB3LAF8uRi2GB35iy6S/kjcdvglDrueUdpu50ZDBoB21WT2g==", - "license": "ISC" - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "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/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT" - }, - "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==", - "license": "MIT" - }, - "node_modules/multistream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", - "integrity": "sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==", - "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": { - "once": "^1.4.0", - "readable-stream": "^3.6.0" - } - }, - "node_modules/multistream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", - "dev": true, - "license": "MIT" - }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.85.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", - "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nodemon": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", - "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/nodemon/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nodemon/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/nodemon/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "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/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "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/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "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/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/resolve/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/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "glob": "^13.0.0", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "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/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "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": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stream-meter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", - "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^2.1.4" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/superagent": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", - "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.1", - "cookiejar": "^2.1.4", - "debug": "^4.3.7", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.5", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.14.1" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supertest": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", - "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cookie-signature": "^1.2.2", - "methods": "^1.1.2", - "superagent": "^10.3.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "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/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "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.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "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/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.3", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.20.6", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", - "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "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/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, - "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/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "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" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/package.json b/package.json index b93cf21..6937f13 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "flashforge-webui", - "version": "1.1.0", + "version": "1.2.0", "description": "Standalone WebUI for FlashForge 3D Printers", "main": "dist/index.js", "bin": "dist/index.js", "type": "commonjs", + "packageManager": "pnpm@10.23.0", "scripts": { "postinstall": "node scripts/download-go2rtc.cjs", "dev": "npm run build && concurrently --names \"BACKEND,WEBUI,SERVER\" \"npm run build:backend:watch\" \"npm run build:webui:watch\" \"npm run start:dev\"", @@ -40,14 +41,16 @@ "format:fix": "biome format --write .", "check": "biome check .", "check:fix": "biome check --write .", - "type-check": "npm run type-check:app", + "type-check": "npm run type-check:app && npm run type-check:e2e", "type-check:app": "tsc --noEmit", "docs:check": "go run scripts/check-fileoverview.go", "docs:check:debug": "go run scripts/check-fileoverview.go -debug", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "test:verbose": "jest --verbose" + "test:verbose": "jest --verbose", + "test:e2e": "npm run build && playwright test", + "type-check:e2e": "tsc --noEmit -p tsconfig.e2e.json" }, "keywords": [ "flashforge", @@ -59,22 +62,28 @@ "author": "Parallel-7", "license": "MIT", "dependencies": { - "@ghosttypes/ff-api": "^1.6.1", - "@parallel-7/slicer-meta": "1.1.0-20251121155836", - "axios": "^1.8.4", - "express": "^5.1.0", - "form-data": "^4.0.0", + "@ghosttypes/ff-api": "^2.0.3", + "@parallel-7/slicer-meta": "1.5.0", + "axios": "^1.20.0", + "express": "^5.3.0", + "form-data": "^4.0.6", "gridstack": "^12.3.3", "lucide": "^0.552.0", - "ws": "^8.18.3", + "pdf-lib": "^1.17.1", + "pngjs": "^7.0.0", + "ssh2": "^1.17.0", + "ws": "^8.22.0", "zod": "^4.0.5" }, "devDependencies": { "@biomejs/biome": "2.4.5", "@jest/globals": "^30.2.0", + "@playwright/test": "^1.62.1", "@types/express": "^4.17.21", "@types/jest": "^30.0.0", "@types/node": "^20.17.9", + "@types/pngjs": "^6.0.5", + "@types/ssh2": "^1.15.5", "@types/supertest": "^6.0.3", "@types/ws": "^8.5.13", "@yao-pkg/pkg": "^5.15.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..34dbaf6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Browser E2E for the standalone FlashForgeWebUI server. + * + * workers is pinned to 1 because each spec file boots its own emulator set on + * FIXED ports (8899/8898 + shifts). Parallel workers would collide with + * EADDRINUSE, exactly like the FlashForgeUI-Electron browser suite this was + * ported from. + */ +export default defineConfig({ + testDir: './tests/e2e/browser', + timeout: 120_000, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + headless: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..1f9e021 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5437 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@ghosttypes/ff-api': + specifier: ^2.0.3 + version: 2.0.3 + '@parallel-7/slicer-meta': + specifier: 1.5.0 + version: 1.5.0 + axios: + specifier: ^1.20.0 + version: 1.20.0 + express: + specifier: ^5.3.0 + version: 5.3.0 + form-data: + specifier: ^4.0.6 + version: 4.0.6 + gridstack: + specifier: ^12.3.3 + version: 12.3.3 + lucide: + specifier: ^0.552.0 + version: 0.552.0 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 + ssh2: + specifier: ^1.17.0 + version: 1.17.0 + ws: + specifier: ^8.22.0 + version: 8.22.0 + zod: + specifier: ^4.0.5 + version: 4.1.12 + devDependencies: + '@biomejs/biome': + specifier: 2.4.5 + version: 2.4.5 + '@jest/globals': + specifier: ^30.2.0 + version: 30.2.0 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^20.17.9 + version: 20.19.25 + '@types/pngjs': + specifier: ^6.0.5 + version: 6.0.5 + '@types/ssh2': + specifier: ^1.15.5 + version: 1.15.5 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 + '@yao-pkg/pkg': + specifier: ^5.15.0 + version: 5.16.1 + concurrently: + specifier: ^9.1.2 + version: 9.2.1 + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@20.19.25) + nodemon: + specifier: ^3.1.11 + version: 3.1.11 + rimraf: + specifier: ^6.0.1 + version: 6.1.2 + supertest: + specifier: ^7.2.2 + version: 7.2.2 + ts-jest: + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.3)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.25))(typescript@5.9.3) + tsx: + specifier: ^4.19.2 + version: 4.20.6 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@biomejs/biome@2.4.5': + resolution: {integrity: sha512-OWNCyMS0Q011R6YifXNOg6qsOg64IVc7XX6SqGsrGszPbkVCoaO7Sr/lISFnXZ9hjQhDewwZ40789QmrG0GYgQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.5': + resolution: {integrity: sha512-lGS4Nd5O3KQJ6TeWv10mElnx1phERhBxqGP/IKq0SvZl78kcWDFMaTtVK+w3v3lusRFxJY78n07PbKplirsU5g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.5': + resolution: {integrity: sha512-6MoH4tyISIBNkZ2Q5T1R7dLd5BsITb2yhhhrU9jHZxnNSNMWl+s2Mxu7NBF8Y3a7JJcqq9nsk8i637z4gqkJxQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.5': + resolution: {integrity: sha512-iqLDgpzobG7gpBF0fwEVS/LT8kmN7+S0E2YKFDtqliJfzNLnAiV2Nnyb+ehCDCJgAZBASkYHR2o60VQWikpqIg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.4.5': + resolution: {integrity: sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.4.5': + resolution: {integrity: sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.4.5': + resolution: {integrity: sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.4.5': + resolution: {integrity: sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.5': + resolution: {integrity: sha512-Pmhv9zT95YzECfjEHNl3mN9Vhusw9VA5KHY0ZvlGsxsjwS5cb7vpRnHzJIv0vG7jB0JI7xEaMH9ddfZm/RozBw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@ghosttypes/ff-api@2.0.3': + resolution: {integrity: sha512-IFUU/89DZjcWUFFvj+AkSL+xHMRAno7E4knGXbuWz8Vvwudwg0rlBmHxKqEoJw3NMAZzM5GUzf4m1evwEazalw==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + + '@parallel-7/slicer-meta@1.5.0': + resolution: {integrity: sha512-2+vkKyGpJpzh8T9etXi5XYGVPNmcWnUZZ95fM2j4VQ2m1vXChCJ8uInk3QbAPXPBuIE/y7IE5avhBP/UULm7Hg==} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@13.0.5': + resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/express-serve-static-core@4.19.7': + resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + + '@types/node@20.19.25': + resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@yao-pkg/pkg-fetch@3.5.16': + resolution: {integrity: sha512-mCnZvZz0/Ylpk4TGyt34pqWJyBGYJM8c3dPoMRV8Knodv2QhcYS4iXb5kB/JNWkrRtCKukGZIKkMLXZ3TQlzPg==} + hasBin: true + + '@yao-pkg/pkg@5.16.1': + resolution: {integrity: sha512-crUlnNFSReFNFuXDc4f3X2ignkFlc9kmEG7Bp/mJMA1jYyqR0lqjZGLgrSDYTYiNsYud8AzgA3RY1DrMdcUZWg==} + hasBin: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + + babel-jest@30.2.0: + resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.9.18: + resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} + hasBin: true + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001766: + resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.279: + resolution: {integrity: sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-xml-builder@1.3.1: + resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} + + fast-xml-parser@5.11.0: + resolution: {integrity: sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw==} + hasBin: true + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + from2@2.3.0: + resolution: {integrity: sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gridstack@12.3.3: + resolution: {integrity: sha512-Bboi4gj7HXGnx1VFXQNde4Nwi5srdUSuCCnOSszKhFjBs8EtMEWhsKX02BjIKkErq/FjQUkNUbXUYeQaVMQ0jQ==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + into-stream@6.0.0: + resolution: {integrity: sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==} + engines: {node: '>=10'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide@0.552.0: + resolution: {integrity: sha512-f9PSKLsd4TtGRnRnbqZ2IMKQ2tfCA/dwHaZHysmB3LAF8uRi2GB35iy6S/kjcdvglDrueUdpu50ZDBoB21WT2g==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multistream@4.1.0: + resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} + + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + p-is-promise@3.0.0: + resolution: {integrity: sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + prebuild-install@7.1.1: + resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + rimraf@6.1.2: + resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} + engines: {node: 20 || >=22} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stream-meter@1.0.4: + resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@2.4.2: + resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-jest@29.4.6: + resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + +snapshots: + + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.6': {} + + '@babel/core@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/traverse@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@biomejs/biome@2.4.5': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.5 + '@biomejs/cli-darwin-x64': 2.4.5 + '@biomejs/cli-linux-arm64': 2.4.5 + '@biomejs/cli-linux-arm64-musl': 2.4.5 + '@biomejs/cli-linux-x64': 2.4.5 + '@biomejs/cli-linux-x64-musl': 2.4.5 + '@biomejs/cli-win32-arm64': 2.4.5 + '@biomejs/cli-win32-x64': 2.4.5 + + '@biomejs/cli-darwin-arm64@2.4.5': + optional: true + + '@biomejs/cli-darwin-x64@2.4.5': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.5': + optional: true + + '@biomejs/cli-linux-arm64@2.4.5': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.5': + optional: true + + '@biomejs/cli-linux-x64@2.4.5': + optional: true + + '@biomejs/cli-win32-arm64@2.4.5': + optional: true + + '@biomejs/cli-win32-x64@2.4.5': + optional: true + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@ghosttypes/ff-api@2.0.3': + dependencies: + axios: 1.20.0 + form-data: 4.0.6 + transitivePeerDependencies: + - debug + - supports-color + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + + '@jest/core@30.2.0': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@20.19.25) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + '@jest/diff-sequences@30.0.1': {} + + '@jest/environment@30.2.0': + dependencies: + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + jest-mock: 30.2.0 + + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect@30.2.0': + dependencies: + expect: 30.2.0 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@sinonjs/fake-timers': 13.0.5 + '@types/node': 20.19.25 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.2.0': + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 20.19.25 + jest-regex-util: 30.0.1 + + '@jest/reporters@30.2.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.25 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.48 + + '@jest/snapshot-utils@30.2.0': + dependencies: + '@jest/types': 30.2.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.2.0': + dependencies: + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.2.0': + dependencies: + '@jest/test-result': 30.2.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + slash: 3.0.0 + + '@jest/transform@30.2.0': + dependencies: + '@babel/core': 7.28.6 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.25 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@noble/hashes@1.8.0': {} + + '@nodable/entities@3.0.0': {} + + '@parallel-7/slicer-meta@1.5.0': + dependencies: + adm-zip: 0.5.16 + date-fns: 4.1.0 + fast-xml-parser: 5.11.0 + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + + '@sinclair/typebox@0.34.48': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@13.0.5': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.6 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.6 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.19.25 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.19.25 + + '@types/cookiejar@2.1.5': {} + + '@types/express-serve-static-core@4.19.7': + dependencies: + '@types/node': 20.19.25 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.7 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@30.0.0': + dependencies: + expect: 30.2.0 + pretty-format: 30.2.0 + + '@types/methods@1.1.4': {} + + '@types/mime@1.3.5': {} + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + + '@types/node@20.19.25': + dependencies: + undici-types: 6.21.0 + + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 20.19.25 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.19.25 + + '@types/send@1.2.1': + dependencies: + '@types/node': 20.19.25 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 20.19.25 + '@types/send': 0.17.6 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + + '@types/stack-utils@2.0.3': {} + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.19.25 + form-data: 4.0.6 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.25 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@ungap/structured-clone@1.3.0': {} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + '@yao-pkg/pkg-fetch@3.5.16': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + picocolors: 1.1.1 + progress: 2.0.3 + semver: 7.7.3 + tar-fs: 2.1.4 + yargs: 16.2.0 + transitivePeerDependencies: + - encoding + - supports-color + + '@yao-pkg/pkg@5.16.1': + dependencies: + '@babel/generator': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@yao-pkg/pkg-fetch': 3.5.16 + into-stream: 6.0.0 + minimist: 1.2.8 + multistream: 4.1.0 + picocolors: 1.1.1 + picomatch: 4.0.3 + prebuild-install: 7.1.1 + resolve: 1.22.11 + stream-meter: 1.0.4 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - encoding + - supports-color + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + adm-zip@0.5.16: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + anynum@1.0.1: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + asap@2.0.6: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + asynckit@0.4.0: {} + + axios@1.20.0: + dependencies: + follow-redirects: 1.16.1 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + babel-jest@30.2.0(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 + '@jest/transform': 30.2.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.28.6) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.2.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.6) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.6) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.6) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.6) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.6) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.6) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.6) + + babel-preset-jest@30.2.0(@babel/core@7.28.6): + dependencies: + '@babel/core': 7.28.6 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.9.18: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.18 + caniuse-lite: 1.0.30001766 + electron-to-chromium: 1.5.279 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001766: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@1.1.4: {} + + ci-info@4.3.1: {} + + cjs-module-lexer@2.2.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + concurrently@9.2.1: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.8.3 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + content-disposition@2.0.1: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + core-util-is@1.0.3: {} + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.28.0 + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + date-fns@4.1.0: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + dedent@1.7.1: {} + + deep-extend@0.6.0: {} + + deepmerge@4.3.1: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.279: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@2.0.0: {} + + esprima@4.0.1: {} + + etag@1.8.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + expand-template@2.0.3: {} + + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-json-stable-stringify@2.1.0: {} + + fast-safe-stringify@2.1.1: {} + + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.0: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + follow-redirects@1.16.1: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + from2@2.3.0: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + + fs-constants@1.0.0: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.0: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.2 + path-scurry: 2.0.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + gridstack@12.3.3: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + into-stream@6.0.0: + dependencies: + from2: 2.3.0 + p-is-promise: 3.0.0 + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-unsafe@2.0.0: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@30.2.0: + dependencies: + execa: 5.1.1 + jest-util: 30.2.0 + p-limit: 3.1.0 + + jest-circus@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.1 + is-generator-fn: 2.1.0 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + p-limit: 3.1.0 + pretty-format: 30.2.0 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.2.0(@types/node@20.19.25): + dependencies: + '@jest/core': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@20.19.25) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.2.0(@types/node@20.19.25): + dependencies: + '@babel/core': 7.28.6 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.6) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.25 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + + jest-docblock@30.2.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + jest-util: 30.2.0 + pretty-format: 30.2.0 + + jest-environment-node@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + + jest-haste-map@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + jest-worker: 30.2.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.2.0 + + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.28.6 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + jest-util: 30.2.0 + + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): + optionalDependencies: + jest-resolve: 30.2.0 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.2.0: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.2.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.2.0: + dependencies: + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.2.0: + dependencies: + '@babel/core': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.6) + '@babel/types': 7.28.6 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.6) + chalk: 4.1.2 + expect: 30.2.0 + graceful-fs: 4.2.11 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.3 + synckit: 0.11.12 + transitivePeerDependencies: + - supports-color + + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + chalk: 4.1.2 + ci-info: 4.3.1 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + jest-validate@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.2.0 + + jest-watcher@30.2.0: + dependencies: + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.2.0 + string-length: 4.0.2 + + jest-worker@30.2.0: + dependencies: + '@types/node': 20.19.25 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.2.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.2.0(@types/node@20.19.25): + dependencies: + '@jest/core': 30.2.0 + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@20.19.25) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + leven@3.1.0: {} + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.memoize@4.1.2: {} + + lru-cache@10.4.3: {} + + lru-cache@11.2.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide@0.552.0: {} + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + mkdirp-classic@0.5.3: {} + + ms@2.1.3: {} + + multistream@4.1.0: + dependencies: + once: 1.4.0 + readable-stream: 3.6.2 + + nan@2.28.0: + optional: true + + napi-build-utils@1.0.2: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + neo-async@2.6.2: {} + + node-abi@3.85.0: + dependencies: + semver: 7.7.3 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-int64@0.4.0: {} + + node-releases@2.0.27: {} + + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.5 + pstree.remy: 1.1.8 + semver: 7.7.3 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + p-is-promise@3.0.0: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + pako@1.0.11: {} + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.28.6 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-expression-matcher@1.6.2: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.2 + minipass: 7.1.2 + + path-to-regexp@8.4.2: {} + + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + + prebuild-install@7.1.1: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + pstree.remy@1.1.8: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pure-rand@7.0.1: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-is@18.3.1: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rimraf@6.1.2: + dependencies: + glob: 13.0.0 + package-json-from-dist: 1.0.1 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safer-buffer@2.1.2: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + send@1.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.3 + + slash@3.0.0: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.28.0 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + statuses@2.0.2: {} + + stream-meter@1.0.4: + dependencies: + readable-stream: 2.3.8 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.5 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + tr46@0.0.3: {} + + tree-kill@1.2.2: {} + + ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.3)(jest-util@30.2.0)(jest@30.2.0(@types/node@20.19.25))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@20.19.25) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.6 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.6) + esbuild: 0.27.3 + jest-util: 30.2.0 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.1.2 + + tweetnacl@0.14.5: {} + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + undefsafe@2.0.5: {} + + undici-types@5.26.5: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + vary@1.1.2: {} + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + ws@8.22.0: {} + + xml-naming@0.3.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@4.1.12: {} diff --git a/resources/bin/darwin-arm64/go2rtc b/resources/bin/darwin-arm64/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/darwin-x64/go2rtc b/resources/bin/darwin-x64/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/linux-arm/go2rtc b/resources/bin/linux-arm/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/linux-arm64/go2rtc b/resources/bin/linux-arm64/go2rtc old mode 100644 new mode 100755 diff --git a/resources/bin/linux-x64/go2rtc b/resources/bin/linux-x64/go2rtc old mode 100644 new mode 100755 diff --git a/scripts/copy-webui-assets.js b/scripts/copy-webui-assets.js index b5ed8b8..5271155 100644 --- a/scripts/copy-webui-assets.js +++ b/scripts/copy-webui-assets.js @@ -60,9 +60,11 @@ function logError(message) { console.error(` ${RED_CROSS} ${message}`); } +const APP_VERSION = packageJson.version || 'dev'; +const APP_VERSION_PLACEHOLDER = /__APP_VERSION__/g; + function createBuildId() { - const version = packageJson.version || 'dev'; - return `${version}-${Date.now().toString(36)}`; + return `${APP_VERSION}-${Date.now().toString(36)}`; } function isLocalAssetPath(assetPath) { @@ -162,7 +164,9 @@ function applyBuildStamp(rootDir) { const indexPath = path.join(rootDir, 'index.html'); if (fs.existsSync(indexPath)) { - const indexContent = fs.readFileSync(indexPath, 'utf8'); + const indexContent = fs + .readFileSync(indexPath, 'utf8') + .replace(APP_VERSION_PLACEHOLDER, APP_VERSION); fs.writeFileSync(indexPath, rewriteHtmlAssetUrls(indexContent, buildId), 'utf8'); } diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 39a8b1b..108943e 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -18,7 +18,6 @@ global.console = { debug: jest.fn(), }; -// Clean up mocks after each test afterEach(() => { jest.clearAllMocks(); }); diff --git a/src/index.ts b/src/index.ts index 924a221..f4b135b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { getMultiContextSpoolmanTracker } from './services/MultiContextSpoolmanT import { getMultiContextTemperatureMonitor } from './services/MultiContextTemperatureMonitor'; import { getSavedPrinterService } from './services/SavedPrinterService'; import { initializeSpoolmanIntegrationService } from './services/SpoolmanIntegrationService'; +import { getThumbnailCacheService } from './services/ThumbnailCacheService'; import type { PollingData } from './types/polling'; import type { PrinterClientType, PrinterDetails } from './types/printer'; import { getCameraUserConfig } from './utils/camera-utils'; @@ -38,7 +39,6 @@ import { createHardDeadline } from './utils/ShutdownTimeout'; import { initializeDataDirectory } from './utils/setup'; import { getWebUIManager } from './webui/server/WebUIManager'; -// Initialize global singleton services const configManager = getConfigManager(); const connectionManager = getConnectionFlowManager(); const contextManager = getPrinterContextManager(); @@ -105,7 +105,6 @@ async function connectLastUsed(): Promise { `[Connection] Found last used printer: ${lastUsedPrinter.Name} (${lastUsedPrinter.IPAddress})` ); - // Convert StoredPrinterDetails to PrinterDetails const printerDetails: PrinterDetails = applyPerPrinterDefaults({ Name: lastUsedPrinter.Name, IPAddress: lastUsedPrinter.IPAddress, @@ -138,7 +137,6 @@ async function connectAllSaved(): Promise { console.log(`[Connection] Connecting to ${savedPrinters.length} saved printer(s)...`); - // Convert StoredPrinterDetails to PrinterDetails const printerDetailsList: PrinterDetails[] = savedPrinters.map((saved) => applyPerPrinterDefaults({ Name: saved.Name, @@ -209,7 +207,6 @@ async function startWebUI(): Promise { try { console.log('[WebUI] Starting WebUI server...'); - // Enable auto-start after all services are initialized webUIManager.enableAutoStart(); const success = await webUIManager.start(); @@ -237,7 +234,6 @@ async function startWebUI(): Promise { * Setup event forwarding from polling coordinator to WebUI */ function setupEventForwarding(): void { - // Forward polling data to WebUI for real-time updates // For WebUI (single-printer or multi-printer), forward all context data // The WebUI/WebSocket layer will handle filtering if needed pollingCoordinator.on('polling-data', (contextId: string, data: PollingData) => { @@ -320,7 +316,6 @@ function setupSignalHandlers(): void { }); }); - // Handle termination signal (Linux/Mac) process.on('SIGTERM', () => { console.log('\n[Shutdown] Received SIGTERM signal'); void shutdown() @@ -391,7 +386,6 @@ async function shutdown(): Promise { console.log(`[Shutdown] Disconnect: ${succeeded} succeeded, ${failed} failed`); - // Log individual failures for debugging results.forEach((result, index) => { if (result.status === 'rejected') { console.warn(`[Shutdown] Context ${connectedContexts[index]} failed:`, result.reason); @@ -433,6 +427,10 @@ async function main(): Promise { console.log('[Init] Initializing data directory...'); initializeDataDirectory(); + // 1a. Initialize the persistent per-printer thumbnail cache used by the file dialog + await getThumbnailCacheService().initialize(); + console.log('[Init] Thumbnail cache initialized'); + // 2. Parse CLI arguments const config = parseHeadlessArguments(); console.log(`[Init] Mode: ${config.mode}`); @@ -510,7 +508,7 @@ async function main(): Promise { pollingCoordinator.startPollingForContext(contextId); console.log(`[Polling] Started for context: ${contextId}`); - // STEP 2: Get context and polling service (now available after step 1) + // STEP 2: Get context and polling service (created by step 1) const context = contextManager.getContext(contextId); const pollingService = context?.pollingService; @@ -545,7 +543,7 @@ async function main(): Promise { // STEP 5: Create SpoolmanTracker for this context (depends on PrintStateMonitor) const spoolmanTracker = getMultiContextSpoolmanTracker(); - spoolmanTracker.createTrackerForContext(contextId, stateMonitor); + spoolmanTracker.createTrackerForContext(contextId, stateMonitor, pollingService); console.log(`[Events] Created SpoolmanTracker for context ${contextId}`); discordService.registerContext(contextId); @@ -592,7 +590,7 @@ async function main(): Promise { }); console.log('[Events] Context-removed hook configured'); - // 11. Connect to printers (handlers are now ready to receive backend-initialized events) + // 11. Connect to printers (backend-initialized handlers are already registered) console.log('[Init] Connecting to printers...'); connectedContexts = await connectPrinters(config); @@ -601,7 +599,6 @@ async function main(): Promise { } else if (connectedContexts.length > 0) { console.log(`[Init] Connected to ${connectedContexts.length} printer(s)`); - // Log connection summary for (const contextId of connectedContexts) { const context = contextManager.getContext(contextId); if (context) { @@ -641,5 +638,4 @@ async function main(): Promise { } } -// Start the application void main(); diff --git a/src/managers/CalibrationManager.ts b/src/managers/CalibrationManager.ts new file mode 100644 index 0000000..757859e --- /dev/null +++ b/src/managers/CalibrationManager.ts @@ -0,0 +1,659 @@ +/** + * @fileoverview Central manager for calibration operations. + * Coordinates between parsers, analysis engines, and UI components. + * Provides a unified interface for all calibration functionality. + * + * @module managers/CalibrationManager + */ + +import { getEnvironmentService } from '../services/EnvironmentService'; +import fs from 'fs/promises'; +import path from 'path'; +import type { + AnalysisResult, + BedConfig, + BedWorkspace, + CalibrationHistoryEntry, + CalibrationSettings, + MeshData, + PrinterCalibrationData, + ShaperResult, + StageResult, + WorkflowData, + WorkflowStage, +} from '../types/calibration'; +import { DEFAULT_BED_CONFIG, DEFAULT_CALIBRATION_SETTINGS } from '../types/calibration'; +import { Bed, DeviationAnalyzer, ScrewSolver, TapeCalculator, WorkflowEngine } from '../services/calibration/engine'; +import { KlipperConfigParser } from '../services/calibration/parsers/KlipperConfigParser'; +import { renderHeatmapPNG, renderReportPDF } from '../services/calibration/report/ReportRenderer'; + +type SerializedWorkflowData = Omit & { + stages: Array<[WorkflowStage, StageResult]>; +}; + +/** + * Manager for all calibration operations. + */ +export class CalibrationManager { + /** Singleton instance */ + private static instance: CalibrationManager | null = null; + + /** Parser for Klipper config files */ + private readonly configParser: KlipperConfigParser; + + /** Per-printer workspaces */ + private readonly workspaces: Map; + + /** Global calibration settings */ + private settings: CalibrationSettings; + + /** Per-printer calibration data */ + private readonly printerData: Map; + + /** Base path for calibration data storage */ + private readonly dataPath: string; + + /** Whether manager has been initialized */ + private initialized = false; + + /** + * Private constructor for singleton pattern. + */ + private constructor() { + this.configParser = new KlipperConfigParser(); + this.workspaces = new Map(); + this.printerData = new Map(); + this.settings = { ...DEFAULT_CALIBRATION_SETTINGS }; + this.dataPath = path.join(getEnvironmentService().getDataPath(), 'calibration'); + } + + /** + * Get the singleton instance. + */ + static getInstance(): CalibrationManager { + if (!CalibrationManager.instance) { + CalibrationManager.instance = new CalibrationManager(); + } + return CalibrationManager.instance; + } + + /** + * Initialize the calibration manager. + */ + async initialize(): Promise { + if (this.initialized) { + return; + } + + await fs.mkdir(this.dataPath, { recursive: true }); + await fs.mkdir(path.join(this.dataPath, 'printers'), { recursive: true }); + + await this.loadSettings(); + + this.initialized = true; + } + + /** + * Load global calibration settings from disk. + */ + private async loadSettings(): Promise { + const settingsPath = path.join(this.dataPath, 'settings.json'); + + try { + const data = await fs.readFile(settingsPath, 'utf-8'); + const loaded = JSON.parse(data) as Partial; + this.settings = { ...DEFAULT_CALIBRATION_SETTINGS, ...loaded }; + } catch { + this.settings = { ...DEFAULT_CALIBRATION_SETTINGS }; + } + } + + /** + * Save global calibration settings to disk. + */ + private async saveSettings(): Promise { + const settingsPath = path.join(this.dataPath, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(this.settings, null, 2)); + } + + /** + * Get per-printer storage paths. + */ + private getPrinterPaths(contextId: string): { + baseDir: string; + historyPath: string; + lastMeshPath: string; + shaperXPath: string; + shaperYPath: string; + sshPath: string; + exportsDir: string; + legacyPath: string; + } { + const baseDir = path.join(this.dataPath, 'printers', contextId); + return { + baseDir, + historyPath: path.join(baseDir, 'history.json'), + lastMeshPath: path.join(baseDir, 'last-mesh.json'), + shaperXPath: path.join(baseDir, 'shaper-x.json'), + shaperYPath: path.join(baseDir, 'shaper-y.json'), + sshPath: path.join(baseDir, 'ssh.json'), + exportsDir: path.join(baseDir, 'exports'), + legacyPath: path.join(baseDir, 'calibration-data.json'), + }; + } + + /** + * Ensure printer directories exist. + */ + private async ensurePrinterDirs(contextId: string): Promise { + const paths = this.getPrinterPaths(contextId); + await fs.mkdir(paths.baseDir, { recursive: true }); + await fs.mkdir(paths.exportsDir, { recursive: true }); + } + + private async readJSONFile(filePath: string): Promise { + try { + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data) as T; + } catch { + return null; + } + } + + private async writeJSONFile(filePath: string, data: unknown): Promise { + await fs.writeFile(filePath, JSON.stringify(data, null, 2)); + } + + private serializeWorkflowData(data: WorkflowData): SerializedWorkflowData { + return { + ...data, + stages: Array.from(data.stages.entries()), + }; + } + + private deserializeWorkflowData(data: WorkflowData | SerializedWorkflowData): WorkflowData { + if (data.stages instanceof Map) { + return data as WorkflowData; + } + const stages = new Map(data.stages); + return { + ...(data as WorkflowData), + stages, + }; + } + + private serializeHistoryEntries(entries: CalibrationHistoryEntry[]): CalibrationHistoryEntry[] { + return entries.map((entry) => { + const workflow = entry.data as WorkflowData | undefined; + if (workflow?.stages instanceof Map) { + const serialized = this.serializeWorkflowData(workflow); + return { + ...entry, + data: serialized as unknown as WorkflowData, + }; + } + return entry; + }); + } + + private deserializeHistoryEntries(entries: CalibrationHistoryEntry[]): CalibrationHistoryEntry[] { + return entries.map((entry) => { + const data = entry.data as WorkflowData | SerializedWorkflowData | undefined; + if (data && (data as SerializedWorkflowData).stages && Array.isArray((data as SerializedWorkflowData).stages)) { + return { + ...entry, + data: this.deserializeWorkflowData(data) as WorkflowData, + }; + } + return entry; + }); + } + + /** + * Get current calibration settings. + */ + getSettings(): CalibrationSettings { + return { ...this.settings }; + } + + /** + * Update calibration settings. + */ + async updateSettings(settings: Partial): Promise { + this.settings = { ...this.settings, ...settings }; + await this.saveSettings(); + } + + /** + * Get or create a workspace for a printer context. + */ + getWorkspace(contextId: string): BedWorkspace | undefined { + return this.workspaces.get(contextId); + } + + /** + * Create a new workspace for a printer context. + */ + createWorkspace(contextId: string, meshData?: MeshData): BedWorkspace { + const bedConfig: BedConfig = meshData + ? { + sizeX: meshData.maxX - meshData.minX, + sizeY: meshData.maxY - meshData.minY, + meshPointsX: meshData.pointsX, + meshPointsY: meshData.pointsY, + } + : { ...DEFAULT_BED_CONFIG }; + + const workspace: BedWorkspace = { + contextId, + meshData: meshData || null, + bedConfig, + analysis: null, + workflow: null, + isDirty: false, + }; + + this.workspaces.set(contextId, workspace); + return workspace; + } + + /** + * Clear a workspace. + */ + clearWorkspace(contextId: string): void { + this.workspaces.delete(contextId); + } + + /** + * Parse a Klipper config file and extract mesh data. + */ + parseConfigFile(content: string, profileName = 'default'): MeshData | null { + const result = this.configParser.parseConfigFile(content, profileName); + if (!result.success || !result.data) { + return null; + } + return result.data; + } + + /** + * Get available mesh profiles from a config file. + */ + getAvailableProfiles(content: string): string[] { + return this.configParser.getAvailableProfiles(content); + } + + /** + * Load mesh data into a workspace from config content. + */ + loadMeshFromConfig(contextId: string, configContent: string, profileName = 'default'): BedWorkspace | null { + const meshData = this.parseConfigFile(configContent, profileName); + if (!meshData) { + return null; + } + + const validation = this.configParser.validateMeshData(meshData); + if (!validation.isValid) { + console.error('Invalid mesh data:', validation.error); + return null; + } + + let workspace = this.workspaces.get(contextId); + if (!workspace) { + workspace = this.createWorkspace(contextId, meshData); + } else { + workspace.meshData = meshData; + workspace.bedConfig = this.configParser.createBedConfigFromMesh(meshData); + workspace.isDirty = true; + } + + return workspace; + } + + /** + * Analyze the current mesh in a workspace. + */ + analyzeMesh(contextId: string): AnalysisResult | null { + const workspace = this.workspaces.get(contextId); + if (!workspace?.meshData) { + return null; + } + + const bed = new Bed(workspace.bedConfig); + bed.setMeshData(workspace.meshData.matrix); + + const analyzer = new DeviationAnalyzer(bed, { + cornerAveragingSize: this.settings.hardware.cornerAveraging, + screwThreshold: this.settings.thresholds.screwThreshold, + tapeThreshold: this.settings.thresholds.tapeThreshold, + beltThreshold: this.settings.thresholds.beltThreshold, + screwConfig: { + pitch: this.settings.hardware.screwPitch, + minAdjust: this.settings.hardware.minAdjustment, + maxAdjust: this.settings.hardware.maxAdjustment, + }, + }); + + const analysis = analyzer.analyze(); + workspace.analysis = analysis; + + return analysis; + } + + /** + * Compute the full calibration workflow for a workspace. + */ + computeWorkflow(contextId: string): WorkflowData | null { + const workspace = this.workspaces.get(contextId); + if (!workspace?.meshData) { + return null; + } + + const bed = new Bed(workspace.bedConfig); + bed.setMeshData(workspace.meshData.matrix); + + const analyzer = new DeviationAnalyzer(bed, { + cornerAveragingSize: this.settings.hardware.cornerAveraging, + screwThreshold: this.settings.thresholds.screwThreshold, + tapeThreshold: this.settings.thresholds.tapeThreshold, + beltThreshold: this.settings.thresholds.beltThreshold, + screwConfig: { + pitch: this.settings.hardware.screwPitch, + minAdjust: this.settings.hardware.minAdjustment, + maxAdjust: this.settings.hardware.maxAdjustment, + }, + }); + + const screwSolver = new ScrewSolver(bed, { + pitch: this.settings.hardware.screwPitch, + minAdjust: this.settings.hardware.minAdjustment, + maxAdjust: this.settings.hardware.maxAdjustment, + }); + + const tapeCalculator = new TapeCalculator(bed, { + tapeThickness: this.settings.hardware.tapeThickness, + minHeightDiff: this.settings.thresholds.tapeThreshold, + }); + + const workflowEngine = new WorkflowEngine(bed, screwSolver, tapeCalculator, this.settings); + + const workflow = workflowEngine.computeWorkflow(); + workspace.workflow = workflow; + workspace.analysis = analyzer.analyze(); + + return workflow; + } + + /** + * Get printer-specific calibration data. + */ + async getPrinterData(contextId: string): Promise { + const cached = this.printerData.get(contextId); + if (cached) { + return cached; + } + + await this.ensurePrinterDirs(contextId); + const paths = this.getPrinterPaths(contextId); + + // Legacy migration + const legacyData = await this.readJSONFile(paths.legacyPath); + if (legacyData) { + await this.savePrinterData(contextId, legacyData); + try { + await fs.rename(paths.legacyPath, path.join(paths.baseDir, 'calibration-data.legacy.json')); + } catch { + // Ignore migration cleanup errors + } + return this.printerData.get(contextId) || {}; + } + + const [sshData, historyData, lastMesh, shaperX, shaperY] = await Promise.all([ + this.readJSONFile(paths.sshPath), + this.readJSONFile(paths.historyPath), + this.readJSONFile(paths.lastMeshPath), + this.readJSONFile(paths.shaperXPath), + this.readJSONFile(paths.shaperYPath), + ]); + + const data: PrinterCalibrationData = { + ...(sshData || {}), + lastBedMesh: lastMesh || undefined, + inputShaperX: shaperX || undefined, + inputShaperY: shaperY || undefined, + calibrationHistory: historyData ? this.deserializeHistoryEntries(historyData) : undefined, + }; + + this.printerData.set(contextId, data); + return data; + } + + /** + * Save printer-specific calibration data. + */ + async savePrinterData(contextId: string, data: PrinterCalibrationData): Promise { + await this.ensurePrinterDirs(contextId); + const paths = this.getPrinterPaths(contextId); + + const sshPayload: PrinterCalibrationData = { + sshConfigPath: data.sshConfigPath, + }; + + const sshHasValues = Object.values(sshPayload).some((value) => value !== undefined && value !== ''); + if (sshHasValues) { + await this.writeJSONFile(paths.sshPath, sshPayload); + } else { + await fs.rm(paths.sshPath, { force: true }); + } + + if (data.calibrationHistory && data.calibrationHistory.length > 0) { + const serializedHistory = this.serializeHistoryEntries(data.calibrationHistory); + await this.writeJSONFile(paths.historyPath, serializedHistory); + } else { + await fs.rm(paths.historyPath, { force: true }); + } + + if (data.lastBedMesh) { + await this.writeJSONFile(paths.lastMeshPath, data.lastBedMesh); + } else { + await fs.rm(paths.lastMeshPath, { force: true }); + } + + if (data.inputShaperX) { + await this.writeJSONFile(paths.shaperXPath, data.inputShaperX); + } else { + await fs.rm(paths.shaperXPath, { force: true }); + } + + if (data.inputShaperY) { + await this.writeJSONFile(paths.shaperYPath, data.inputShaperY); + } else { + await fs.rm(paths.shaperYPath, { force: true }); + } + + this.printerData.set(contextId, data); + } + + /** + * Add a calibration history entry for a printer. + */ + async addHistoryEntry( + contextId: string, + type: 'bed_level' | 'input_shaper', + summary: string, + data: WorkflowData | unknown + ): Promise { + const printerData = await this.getPrinterData(contextId); + + const entry: CalibrationHistoryEntry = { + timestamp: Date.now(), + type, + summary, + data: data as WorkflowData, + }; + + if (!printerData.calibrationHistory) { + printerData.calibrationHistory = []; + } + + printerData.calibrationHistory.unshift(entry); + + const maxEntries = this.settings.history.maxEntries; + if (printerData.calibrationHistory.length > maxEntries) { + printerData.calibrationHistory = printerData.calibrationHistory.slice(0, maxEntries); + } + + await this.savePrinterData(contextId, printerData); + } + + /** + * Get calibration history for a printer. + */ + async getHistory(contextId: string): Promise { + const printerData = await this.getPrinterData(contextId); + return printerData.calibrationHistory || []; + } + + /** + * Clear calibration history for a printer. + */ + async clearHistory(contextId: string): Promise { + const printerData = await this.getPrinterData(contextId); + printerData.calibrationHistory = []; + await this.savePrinterData(contextId, printerData); + } + + /** + * Persist the latest bed mesh analysis for a printer. + */ + async saveLastBedMesh(contextId: string, matrix: number[][], analysis: AnalysisResult): Promise { + const printerData = await this.getPrinterData(contextId); + printerData.lastBedMesh = { + timestamp: Date.now(), + matrix, + analysis, + }; + await this.savePrinterData(contextId, printerData); + } + + /** + * Persist a recommended input shaper result for a printer. + */ + async saveShaperResult(contextId: string, axis: 'x' | 'y', result: ShaperResult): Promise { + const printerData = await this.getPrinterData(contextId); + if (axis === 'x') { + printerData.inputShaperX = result; + } else { + printerData.inputShaperY = result; + } + await this.savePrinterData(contextId, printerData); + } + + /** + * Export calibration report. + */ + async exportReport(contextId: string, format: 'json' | 'csv' | 'png' | 'pdf'): Promise { + const workspace = this.workspaces.get(contextId); + if (!workspace || !workspace.meshData) { + throw new Error('No workspace found for context'); + } + + if (!workspace.analysis) { + this.analyzeMesh(contextId); + } + + if (format === 'json') { + return JSON.stringify( + { + contextId, + timestamp: Date.now(), + meshData: workspace.meshData, + analysis: workspace.analysis, + workflow: workspace.workflow + ? { + initialRange: workspace.workflow.initialRange, + finalRange: workspace.workflow.finalRange, + improvementPercent: workspace.workflow.improvementPercent, + screwAdjustments: workspace.workflow.screwAdjustments, + tapeRecommendations: workspace.workflow.tapeRecommendations, + } + : null, + settings: this.settings, + }, + null, + 2 + ); + } + + if (format === 'csv') { + const lines: string[] = []; + lines.push('# Calibration Report'); + lines.push(`# Context: ${contextId}`); + lines.push(`# Timestamp: ${new Date().toISOString()}`); + lines.push(''); + lines.push('# Mesh Data'); + lines.push(`# X: ${workspace.meshData.minX} to ${workspace.meshData.maxX}`); + lines.push(`# Y: ${workspace.meshData.minY} to ${workspace.meshData.maxY}`); + lines.push(''); + + // Header row + const cols = workspace.meshData.pointsX; + const headerRow = Array.from({ length: cols }, (_, i) => `X${i}`).join(','); + lines.push(`Row,${headerRow}`); + + // Data rows + for (let row = 0; row < workspace.meshData.matrix.length; row++) { + const rowData = workspace.meshData.matrix[row].map((v) => v.toFixed(4)).join(','); + lines.push(`${row},${rowData}`); + } + + return lines.join('\n'); + } + + if (format === 'png') { + return renderHeatmapPNG(workspace.meshData, { + colorScheme: this.settings.visualization.colorScheme, + cellSize: 24, + padding: 16, + showGrid: true, + }); + } + + if (format === 'pdf') { + return renderReportPDF({ + contextId, + meshData: workspace.meshData, + analysis: workspace.analysis, + workflow: workspace.workflow, + settings: this.settings, + }); + } + + throw new Error(`Unsupported format: ${format}`); + } + + /** + * Get default export path for a calibration report. + */ + async getDefaultExportPath(contextId: string, format: 'json' | 'csv' | 'png' | 'pdf'): Promise { + await this.ensurePrinterDirs(contextId); + const paths = this.getPrinterPaths(contextId); + return path.join(paths.exportsDir, `report-${Date.now()}.${format}`); + } + + /** + * Shutdown and cleanup. + */ + async shutdown(): Promise { + await this.saveSettings(); + this.workspaces.clear(); + this.printerData.clear(); + this.initialized = false; + } +} + +/** + * Get the CalibrationManager singleton instance. + */ +export function getCalibrationManager(): CalibrationManager { + return CalibrationManager.getInstance(); +} diff --git a/src/managers/ConfigManager.test.ts b/src/managers/ConfigManager.test.ts index 4bc323f..16f4ec0 100644 --- a/src/managers/ConfigManager.test.ts +++ b/src/managers/ConfigManager.test.ts @@ -20,7 +20,6 @@ describe('ConfigManager', () => { let configManager: ConfigManager; beforeEach(() => { - // Setup mocks // Mock fs.existsSync to return true for config directory jest.spyOn(fs, 'existsSync').mockReturnValue(true); @@ -46,7 +45,6 @@ describe('ConfigManager', () => { // Mock fs.writeFileSync jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); - // Reset singleton resetConfigManagerSingleton(); configManager = getConfigManager(); }); diff --git a/src/managers/ConfigManager.ts b/src/managers/ConfigManager.ts index cef77b5..1f68ff6 100644 --- a/src/managers/ConfigManager.ts +++ b/src/managers/ConfigManager.ts @@ -60,13 +60,10 @@ export class ConfigManager extends EventEmitter { this.configPath = path.join(dataPath, 'config.json'); this.lockFilePath = path.join(dataPath, 'config.lock'); - // Ensure data directory exists this.ensureDataDirectory(dataPath); - // Initialize with defaults this.currentConfig = { ...DEFAULT_CONFIG }; - // Load existing configuration void this.loadFromFile().catch((error) => { console.error('Failed to load initial configuration:', error); }); @@ -145,7 +142,6 @@ export class ConfigManager extends EventEmitter { const previousConfig = { ...this.currentConfig }; const changedKeys: Array = []; - // Apply updates and track changed keys for (const [key, value] of Object.entries(updates)) { if (key in DEFAULT_CONFIG) { const configKey = key as keyof AppConfig; @@ -169,7 +165,6 @@ export class ConfigManager extends EventEmitter { const previousConfig = { ...this.currentConfig }; const sanitizedConfig = sanitizeConfig(newConfig); - // Find all changed keys const changedKeys: Array = []; for (const key of Object.keys(DEFAULT_CONFIG) as Array) { if (this.currentConfig[key] !== sanitizedConfig[key]) { @@ -276,7 +271,6 @@ export class ConfigManager extends EventEmitter { const previousConfig = { ...this.currentConfig }; this.currentConfig = { ...sanitizedConfig }; - // Emit update event for initialization const changedKeys = Object.keys(DEFAULT_CONFIG) as Array; this.emitUpdateEvent(previousConfig, changedKeys); @@ -297,13 +291,11 @@ export class ConfigManager extends EventEmitter { const changedKeys = Object.keys(DEFAULT_CONFIG) as Array; this.emitUpdateEvent(previousConfig, changedKeys); - // Save the sanitized version this.scheduleSave(); } } } catch (error) { console.error('Failed to load config file:', error); - // Keep current defaults and save them immediately void this.forceSave().catch((error) => { console.error('Failed to force save config after load error:', error); }); @@ -311,7 +303,6 @@ export class ConfigManager extends EventEmitter { this.isLoading = false; this.configLoaded = true; - // Emit config-loaded event for initialization coordination console.log('Config loading complete - emitting config-loaded event'); this.emit('config-loaded'); } @@ -345,14 +336,11 @@ export class ConfigManager extends EventEmitter { this.isSaving = true; try { - // Create lock file to prevent concurrent writes await fs.promises.writeFile(this.lockFilePath, ''); - // Ensure directory exists const configDir = path.dirname(this.configPath); await fs.promises.mkdir(configDir, { recursive: true }); - // Write configuration with pretty formatting for human readability const configData = JSON.stringify(this.currentConfig, null, 2); await fs.promises.writeFile(this.configPath, configData, 'utf8'); @@ -362,7 +350,6 @@ export class ConfigManager extends EventEmitter { this.emit('saveError', error); throw error; } finally { - // Clean up lock file try { if (fs.existsSync(this.lockFilePath)) { await fs.promises.unlink(this.lockFilePath); @@ -381,14 +368,11 @@ export class ConfigManager extends EventEmitter { */ private saveToFileSync(): void { try { - // Create lock file to prevent concurrent writes fs.writeFileSync(this.lockFilePath, ''); - // Ensure directory exists const configDir = path.dirname(this.configPath); fs.mkdirSync(configDir, { recursive: true }); - // Write configuration with pretty formatting for human readability const configData = JSON.stringify(this.currentConfig, null, 2); fs.writeFileSync(this.configPath, configData, 'utf8'); @@ -398,7 +382,6 @@ export class ConfigManager extends EventEmitter { console.error('Failed to save config file synchronously:', error); this.emit('saveError', error); } finally { - // Clean up lock file try { if (fs.existsSync(this.lockFilePath)) { fs.unlinkSync(this.lockFilePath); @@ -424,7 +407,6 @@ export class ConfigManager extends EventEmitter { this.emit('configUpdated', updateEvent); - // Emit specific events for each changed key changedKeys.forEach((key) => { this.emit(`config:${key}`, this.currentConfig[key], previousConfig[key]); }); diff --git a/src/managers/ConnectionFlowManager.ts b/src/managers/ConnectionFlowManager.ts index aa09230..4b4e4a8 100644 --- a/src/managers/ConnectionFlowManager.ts +++ b/src/managers/ConnectionFlowManager.ts @@ -52,12 +52,11 @@ import { } from '../utils/PrinterUtils'; import { applyPerPrinterDefaults } from '../utils/printerSettingsDefaults'; import { TimeoutError, withTimeout } from '../utils/ShutdownTimeout'; -import { IPAddressSchema } from '../utils/validation.utils'; import { getLoadingManager } from './LoadingManager'; import { getPrinterBackendManager } from './PrinterBackendManager'; import { getPrinterContextManager } from './PrinterContextManager'; -// Input dialog options interface (matching preload.ts) +// Options for the host-supplied input dialog handler interface InputDialogOptions { title?: string; message?: string; @@ -106,7 +105,6 @@ export class ConnectionFlowManager extends EventEmitter { /** Setup internal event handlers and service event forwarding */ private setupEventHandlers(): void { - // Forward backend manager events this.forwardEvents(this.backendManager, [ 'backend-initialized', 'backend-initialization-failed', @@ -116,28 +114,24 @@ export class ConnectionFlowManager extends EventEmitter { 'loading-state-changed', ]); - // Initialize thumbnail queue when backend is ready this.backendManager.on('backend-initialized', () => { const thumbnailQueue = getThumbnailRequestQueue(); thumbnailQueue.initialize(this.backendManager); console.log('ThumbnailRequestQueue initialized with backend manager'); }); - // Reset thumbnail queue when backend is disposed this.backendManager.on('backend-disposed', () => { const thumbnailQueue = getThumbnailRequestQueue(); thumbnailQueue.reset(); console.log('ThumbnailRequestQueue reset after backend disposal'); }); - // Forward discovery service events this.forwardEvents(this.discoveryService, [ 'discovery-started', 'discovery-completed', 'discovery-failed', ]); - // Forward connection state events this.connectionStateManager.on('state-changed', (data) => { this.emit('connection-state-changed', data); }); @@ -218,7 +212,6 @@ export class ConnectionFlowManager extends EventEmitter { /** Start the printer connection flow */ public async startConnectionFlow(options: ConnectionOptions = {}): Promise { try { - // Check if already connected and warn user if (this.isConnected() && options.checkForActiveConnection !== false) { const activeContextId = this.contextManager.getActiveContextId(); const currentDetails = activeContextId @@ -237,21 +230,17 @@ export class ConnectionFlowManager extends EventEmitter { this.emit('connection-flow-started'); - // Show loading for discovery this.loadingManager.show({ message: 'Scanning for printers on network...', canCancel: true }); // Discover printers const discoveredPrinters = await this.discoveryService.scanNetwork(); if (discoveredPrinters.length === 0) { - // Check if we have saved printers for enhanced fallback const savedPrinterCount = this.savedPrinterService.getSavedPrinterCount(); if (savedPrinterCount > 0) { - // Hide discovery loading and show enhanced choice dialog this.loadingManager.hide(); console.log('No printers discovered - showing enhanced fallback options'); - // Use the same enhanced fallback as auto-connect const lastUsedPrinter = this.savedPrinterService.getLastUsedPrinter(); const userChoice = await this.dialogService.showAutoConnectChoiceDialog( lastUsedPrinter, @@ -262,7 +251,6 @@ export class ConnectionFlowManager extends EventEmitter { return { success: false, error: 'Connection cancelled by user' }; } - // Handle user choice switch (userChoice) { case 'connect-last-used': if (lastUsedPrinter) { @@ -274,7 +262,7 @@ export class ConnectionFlowManager extends EventEmitter { return await this.showSavedPrintersForSelection(); case 'manual-ip': - return await this.offerManualIPEntry(); + return this.manualEntryUnavailable(); default: return { success: false, error: 'Unknown choice' }; @@ -283,21 +271,18 @@ export class ConnectionFlowManager extends EventEmitter { // No saved printers - go directly to manual IP entry this.loadingManager.hide(); console.log('No printers discovered and no saved printers - offering manual IP entry'); - return await this.offerManualIPEntry(); + return this.manualEntryUnavailable(); } } - // Hide loading for user interaction this.loadingManager.hide(); - // Show printer selection dialog const selectedPrinter = await this.dialogService.showPrinterSelectionDialog(discoveredPrinters); if (!selectedPrinter) { return { success: false, error: 'No printer selected' }; } - // Connect to selected printer return await this.connectToPrinter(selectedPrinter); } catch (error) { const errorMessage = getConnectionErrorMessage(error); @@ -318,7 +303,6 @@ export class ConnectionFlowManager extends EventEmitter { /** Attempt to auto-connect based on saved printer configuration */ public async tryAutoConnect(): Promise { - // Check if auto-connect should be attempted if (!this.autoConnectService.shouldAutoConnect()) { return { success: false, error: 'Auto-connect disabled' }; } @@ -335,10 +319,8 @@ export class ConnectionFlowManager extends EventEmitter { this.emit('auto-connect-discovery-started'); try { - // Run discovery to find all printers const discoveredPrinters = await this.discoveryService.scanNetwork(); - // Find matches using saved printer service const matches = this.savedPrinterService.findMatchingPrinters(discoveredPrinters); // If no matches found but we have saved printers, show auto-connect choice dialog @@ -349,7 +331,6 @@ export class ConnectionFlowManager extends EventEmitter { this.loadingManager.hide(); - // Show auto-connect choice dialog const userChoice = await this.dialogService.showAutoConnectChoiceDialog( lastUsedPrinter, savedPrinterCount @@ -359,7 +340,6 @@ export class ConnectionFlowManager extends EventEmitter { return { success: false, error: 'Auto-connect cancelled by user' }; } - // Handle user choice switch (userChoice) { case 'connect-last-used': if (lastUsedPrinter) { @@ -406,7 +386,7 @@ export class ConnectionFlowManager extends EventEmitter { } case 'manual-ip': - return await this.offerManualIPEntry(); + return this.manualEntryUnavailable(); default: return { success: false, error: 'Auto-connect cancelled by user' }; @@ -474,15 +454,12 @@ export class ConnectionFlowManager extends EventEmitter { (async () => { console.log(`Starting disconnect sequence for context ${contextId}...`); - // Stop polling first this.emit('pre-disconnect', contextId); await new Promise((resolve) => setTimeout(resolve, 100)); - // Get clients for disposal from connection state const primaryClient = this.connectionStateManager.getPrimaryClient(contextId); const secondaryClient = this.connectionStateManager.getSecondaryClient(contextId); - // Dispose backend for this context await this.backendManager.disposeContext(contextId); // Dispose clients through connection service (handles logout) @@ -492,13 +469,10 @@ export class ConnectionFlowManager extends EventEmitter { currentDetails?.ClientType ); - // Update connection state this.connectionStateManager.setDisconnected(contextId); - // Remove context from manager this.contextManager.removeContext(contextId); - // Emit disconnected event this.emit('disconnected', currentDetails?.Name); })(), { timeoutMs, operation: `disconnectContext(${contextId})` } @@ -558,7 +532,6 @@ export class ConnectionFlowManager extends EventEmitter { /** Connect to a selected printer with proper type detection and pairing */ private async connectToPrinter(discoveredPrinter: DiscoveredPrinter): Promise { - // Start tracking this connection flow const flowId = this.startFlow(); this.loadingManager.show({ @@ -596,14 +569,12 @@ export class ConnectionFlowManager extends EventEmitter { this.loadingManager.updateMessage('Processing printer details...'); const modelType = detectPrinterModelType(tempResult.typeName); - // Extract serial number from temporary connection if not already present let serialNumber = discoveredPrinter.serialNumber; if (!serialNumber && tempResult.printerInfo?.SerialNumber) { serialNumber = tempResult.printerInfo.SerialNumber as string; console.log('Extracted serial number from temporary connection:', serialNumber); } - // Use the real printer name we extracted earlier const printerName = realPrinterName; if (printerName !== discoveredPrinter.name) { console.log('Using real printer name from temporary connection:', printerName); @@ -659,7 +630,7 @@ export class ConnectionFlowManager extends EventEmitter { // Step 5: Establish final connection using updated printer information this.loadingManager.updateMessage('Establishing final connection...'); const connectionResult = await this.connectionService.establishFinalConnection( - updatedDiscoveredPrinter, // Use the updated printer info with correct serial number + updatedDiscoveredPrinter, tempResult.typeName, familyInfo.is5MFamily, checkCode, @@ -711,7 +682,6 @@ export class ConnectionFlowManager extends EventEmitter { await this.savedPrinterService.savePrinter(printerDetails); - // Update last connected timestamp await this.savedPrinterService.updateLastConnected(printerDetails.SerialNumber); // Step 7: Create printer context @@ -820,73 +790,27 @@ export class ConnectionFlowManager extends EventEmitter { } } - /** Offer manual IP entry to user */ - private async offerManualIPEntry(): Promise { - if (!this.inputDialogHandler) { - return { - success: false, - error: 'Manual IP entry not available - input dialog handler not set', - }; - } - - try { - const ipAddress = await this.inputDialogHandler({ - title: 'Manual Printer Connection', - message: 'No printers found on network. Enter printer IP address manually:', - defaultValue: '', - inputType: 'text', - placeholder: 'e.g., 192.168.1.100', - }); - - if (!ipAddress) { - return { success: false, error: 'No IP address provided' }; - } - - // Validate IP address format - const validation = IPAddressSchema.safeParse(ipAddress.trim()); - if (!validation.success) { - this.loadingManager.showError('Invalid IP address format', 3000); - return { success: false, error: 'Invalid IP address format' }; - } - - return await this.connectDirectlyToIP(validation.data); - } catch (error) { - const errorMessage = getConnectionErrorMessage(error); - this.loadingManager.showError(`Manual connection failed: ${errorMessage}`, 4000); - return { success: false, error: errorMessage }; - } - } - - /** Connect directly to an IP address */ - public async connectDirectlyToIP(ipAddress: string): Promise { - try { - this.loadingManager.show({ - message: `Connecting to printer at ${ipAddress}...`, - canCancel: false, - }); - - // Create a mock discovered printer for the connection process - // The actual name and serial will be determined during temporary connection - const mockDiscoveredPrinter: DiscoveredPrinter = { - name: `Printer at ${ipAddress}`, // Temporary name, will be updated - ipAddress: ipAddress, - serialNumber: '', // Will be determined during connection - model: undefined, // Will be determined during connection - }; - - console.log('Starting direct IP connection to:', ipAddress); - - // Use the standard connection flow which will: - // 1. Create temporary connection to get printer info - // 2. Extract proper name and serial number - // 3. Establish final connection with correct details - return await this.connectToPrinter(mockDiscoveredPrinter); - } catch (error) { - const errorMessage = getConnectionErrorMessage(error); - console.error('Direct IP connection failed:', error); - this.loadingManager.showError(`Direct connection failed: ${errorMessage}`, 4000); - return { success: false, error: errorMessage }; - } + /** + * Direct the user to the typed manual-connect form. + * + * This replaces `offerManualIPEntry()` / `connectDirectlyToIP(ip)`, which + * built a DiscoveredPrinter with NO productId from an IP address alone. With + * no product ID, `createTemporaryConnection` cannot tell that the printer is + * HTTP-only, falls through to `createLegacyClient()`, and probes TCP 8899 - + * which the Creator 5 series refuses outright (it runs no TCP server), so + * adding one by IP failed with ECONNREFUSED. Reported against FlashForgeWebUI + * in ff-5mp-hass#18. + * + * The browser's "Add Printer" form requires a printer type and sends the + * matching product ID, so it identifies an HTTP-only model before any socket + * is opened. That is the only manual path that is safe to offer. + */ + private manualEntryUnavailable(): ConnectionResult { + const message = + 'Use the "Add Printer" form to add a printer by IP - it asks for the printer type, ' + + 'which is required to connect models that have no legacy TCP service (Creator 5 / 5 Pro).'; + this.loadingManager.showError(message, 5000); + return { success: false, error: message }; } /** Show saved printers for manual selection */ @@ -915,11 +839,9 @@ export class ConnectionFlowManager extends EventEmitter { /** Connect using saved printer details */ public async connectWithSavedDetails(details: PrinterDetails): Promise { - // Start tracking this connection flow const flowId = this.startFlow(); try { - // Ensure per-printer settings have defaults if not set const detailsWithDefaults: PrinterDetails = applyPerPrinterDefaults(details); // If we added defaults, save them back to printer_details.json @@ -930,7 +852,6 @@ export class ConnectionFlowManager extends EventEmitter { const familyInfo = detectPrinterFamily(detailsWithDefaults.printerModel); - // Create a mock discovered printer for connection establishment const discoveredPrinter: DiscoveredPrinter = { name: detailsWithDefaults.Name, ipAddress: detailsWithDefaults.IPAddress, @@ -954,12 +875,10 @@ export class ConnectionFlowManager extends EventEmitter { throw new Error('Failed to establish connection'); } - // Create printer context const contextId = this.contextManager.createContext(detailsWithDefaults); this.updateFlowContext(flowId, contextId); console.log(`Created context ${contextId} for saved printer ${details.Name}`); - // Update connection state for this context this.connectionStateManager.setConnected( contextId, detailsWithDefaults, @@ -967,7 +886,6 @@ export class ConnectionFlowManager extends EventEmitter { connectionResult.secondaryClient ); - // Initialize backend for this context await this.backendManager.initializeBackend(contextId, { printerDetails: detailsWithDefaults, primaryClient: connectionResult.primaryClient, @@ -1110,7 +1028,6 @@ export class ConnectionFlowManager extends EventEmitter { `[Headless] Attempting to connect to ${savedPrinter.Name} (${savedPrinter.SerialNumber})` ); - // Find matching discovered printer by serial number const discoveredMatch = discoveredPrinters.find( (dp) => dp.serialNumber === savedPrinter.SerialNumber ); @@ -1159,7 +1076,6 @@ export class ConnectionFlowManager extends EventEmitter { const result = await this.connectWithSavedDetails(updatedPrinterDetails); if (result.success && result.printerDetails) { - // Update last connected timestamp await this.savedPrinterService.updateLastConnected(result.printerDetails.SerialNumber); // Get the active context ID (connectWithSavedDetails switches to the new context) @@ -1205,6 +1121,8 @@ export class ConnectionFlowManager extends EventEmitter { checkCode?: string; commandPort?: number; httpPort?: number; + productId?: number; + serialNumber?: string; }> ): Promise<{ contextId: string; ip: string }[]> { const connectedContexts: { contextId: string; ip: string }[] = []; @@ -1215,20 +1133,25 @@ export class ConnectionFlowManager extends EventEmitter { const flowId = this.startFlow(); - // Create mock discovered printer + // Create mock discovered printer. The productId hint (from UDP discovery + // or the manual "Creator 5" selection) lets the temporary connection skip + // the legacy TCP probe for HTTP-only models. const mockDiscoveredPrinter: DiscoveredPrinter = { name: `Printer at ${spec.ip}`, ipAddress: spec.ip, - serialNumber: '', // Will be determined during connection + // HTTP-only models can't be probed for their serial, so the caller + // must supply it — modern printers authenticate with serial + check + // code. The TCP probe refines it for dual-API printers. + serialNumber: spec.serialNumber ?? '', model: undefined, commandPort: spec.commandPort, eventPort: spec.httpPort, + productId: spec.productId, }; // Determine if this is a 5M family printer const is5MFamily = spec.type === 'new'; - // Create temporary connection to get printer info const tempResult = await this.connectionService.createTemporaryConnection(mockDiscoveredPrinter); if (!tempResult.success || !tempResult.typeName) { @@ -1237,27 +1160,26 @@ export class ConnectionFlowManager extends EventEmitter { continue; } - // Extract printer information const printerName = tempResult.printerInfo?.Name && typeof tempResult.printerInfo.Name === 'string' ? tempResult.printerInfo.Name : `Printer at ${spec.ip}`; - const serialNumber = + const probedSerial = tempResult.printerInfo?.SerialNumber && - typeof tempResult.printerInfo.SerialNumber === 'string' + typeof tempResult.printerInfo.SerialNumber === 'string' && + tempResult.printerInfo.SerialNumber.trim() !== '' ? tempResult.printerInfo.SerialNumber - : `Unknown-${Date.now()}`; + : undefined; + const serialNumber = probedSerial ?? spec.serialNumber ?? `Unknown-${Date.now()}`; const modelType = detectPrinterModelType(tempResult.typeName); // Preserve existing saved printer settings if available const existingPrinter = this.savedPrinterService.getSavedPrinter(serialNumber); - // Use explicit check code, fallback to saved value, then default const checkCode = spec.checkCode || existingPrinter?.CheckCode || getDefaultCheckCode(); - // Update discovered printer with real info const updatedDiscoveredPrinter: DiscoveredPrinter = { name: printerName, ipAddress: spec.ip, @@ -1265,6 +1187,7 @@ export class ConnectionFlowManager extends EventEmitter { model: tempResult.typeName, commandPort: spec.commandPort, eventPort: spec.httpPort, + productId: spec.productId, }; const forceLegacyMode = existingPrinter?.forceLegacyMode ?? false; @@ -1284,15 +1207,33 @@ export class ConnectionFlowManager extends EventEmitter { continue; } - // Save printer details + // When the productId hint short-circuited the TCP probe, the temp result + // only carries a synthesized generic model name (a manual "Creator 5" + // selection cannot distinguish the 5 Pro). Prefer the immutable factory + // model name reported by the authenticated HTTP client — it also corrects + // a wrong manual selection. + let resolvedTypeName = tempResult.typeName; + if (is5MFamily) { + const factoryModel = (connectionResult.primaryClient as Partial).model; + if (typeof factoryModel === 'string' && factoryModel.trim() !== '') { + resolvedTypeName = factoryModel; + } + } + const resolvedModelType = detectPrinterModelType(resolvedTypeName); + if (resolvedModelType !== modelType) { + console.log( + `[Headless] Refined model from "${tempResult.typeName}" to "${resolvedTypeName}" via HTTP client` + ); + } + const printerDetails: PrinterDetails = applyPerPrinterDefaults({ Name: formatPrinterName(printerName, serialNumber), IPAddress: spec.ip, SerialNumber: serialNumber, CheckCode: checkCode, ClientType: forceLegacyMode ? 'legacy' : spec.type, - printerModel: tempResult.typeName, - modelType, + printerModel: resolvedTypeName, + modelType: resolvedModelType, commandPort: spec.commandPort, httpPort: spec.httpPort, // Preserve previously configured per-printer overrides when present @@ -1305,11 +1246,9 @@ export class ConnectionFlowManager extends EventEmitter { await this.savedPrinterService.savePrinter(printerDetails); await this.savedPrinterService.updateLastConnected(serialNumber); - // Create printer context const contextId = this.contextManager.createContext(printerDetails); this.updateFlowContext(flowId, contextId); - // Update connection state this.connectionStateManager.setConnected( contextId, printerDetails, @@ -1317,7 +1256,6 @@ export class ConnectionFlowManager extends EventEmitter { connectionResult.secondaryClient ); - // Initialize backend await this.backendManager.initializeBackend(contextId, { printerDetails, primaryClient: connectionResult.primaryClient, @@ -1348,7 +1286,6 @@ export class ConnectionFlowManager extends EventEmitter { } } -// Export singleton instance let connectionFlowManager: ConnectionFlowManager | null = null; export const getConnectionFlowManager = (): ConnectionFlowManager => { diff --git a/src/managers/PrinterBackendManager.ts b/src/managers/PrinterBackendManager.ts index 1ca4250..00bdae0 100644 --- a/src/managers/PrinterBackendManager.ts +++ b/src/managers/PrinterBackendManager.ts @@ -123,7 +123,7 @@ export class PrinterBackendManager extends EventEmitter { * Initialize backend based on printer details * Now context-aware - requires contextId * - * @param contextId - Context ID for this backend + * @param contextId - Context ID * @param options - Backend initialization options * @returns Promise resolving to initialization result */ @@ -160,10 +160,8 @@ export class PrinterBackendManager extends EventEmitter { options: BackendInitializationOptions ): Promise { try { - // Check if we had an old backend before disposal const hadOldBackend = this.contextBackends.has(contextId); - // Dispose of existing backend for this context if any if (hadOldBackend) { await this.disposeContext(contextId); @@ -172,16 +170,14 @@ export class PrinterBackendManager extends EventEmitter { console.log( `PrinterBackendManager: Waiting for old backend cleanup to complete for context ${contextId}...` ); - await new Promise((resolve) => setTimeout(resolve, 500)); // 500ms delay + await new Promise((resolve) => setTimeout(resolve, 500)); } - // Show loading state this.loadingManager.show({ message: 'Initializing printer backend...', canCancel: false, }); - // Detect printer model from details let modelType = detectPrinterModelType(options.printerDetails.printerModel); // Force legacy mode uses the generic legacy backend regardless of printer family. @@ -196,23 +192,17 @@ export class PrinterBackendManager extends EventEmitter { `Initializing ${getModelDisplayName(modelType)} backend...` ); - // Create backend instance const backend = this.createBackend(modelType, options); - // Initialize the backend await backend.initialize(); - // Store references in context map this.contextBackends.set(contextId, backend); this.contextPrinterDetails.set(contextId, options.printerDetails); - // Update context manager with backend reference this.contextManager.updateBackend(contextId, backend); - // Setup backend event forwarding this.setupBackendEventForwarding(backend, contextId); - // Success! this.loadingManager.showSuccess( `Backend initialized for ${getModelDisplayName(modelType)}`, 3000 @@ -309,12 +299,10 @@ export class PrinterBackendManager extends EventEmitter { * Now includes contextId for multi-context support */ private setupBackendEventForwarding(backend: BasePrinterBackend, contextId: string): void { - // Forward all backend events with context ID backend.on('backend-event', (event) => { this.emit('backend-event', { ...event, contextId }); }); - // Forward specific events with context ID backend.on('feature-updated', (data) => { this.emit('feature-updated', { ...data, contextId }); }); @@ -331,7 +319,7 @@ export class PrinterBackendManager extends EventEmitter { /** * Dispose of backend for a specific context * - * @param contextId - Context ID to dispose + * @param contextId - Context ID */ public async disposeContext(contextId: string): Promise { const backend = this.contextBackends.get(contextId); @@ -342,14 +330,11 @@ export class PrinterBackendManager extends EventEmitter { console.log(`Disposing backend for context ${contextId} (${printerName})...`); - // Remove from maps first this.contextBackends.delete(contextId); this.contextPrinterDetails.delete(contextId); - // Update context manager this.contextManager.updateBackend(contextId, null); - // Dispose the backend (this calls client.dispose()) await backend.dispose(); // Additional cleanup delay to ensure ff-api client internal timers stop @@ -359,7 +344,6 @@ export class PrinterBackendManager extends EventEmitter { this.emit('backend-disposed', { contextId }); } catch (error) { console.error(`Error disposing backend for context ${contextId}:`, error); - // Clear references even if disposal fails this.contextBackends.delete(contextId); this.contextPrinterDetails.delete(contextId); } @@ -389,7 +373,7 @@ export class PrinterBackendManager extends EventEmitter { /** * Check if backend is initialized and ready for a specific context * - * @param contextId - Context ID to check + * @param contextId - Context ID * @returns True if backend is ready */ public isBackendReady(contextId: string): boolean { @@ -466,8 +450,6 @@ export class PrinterBackendManager extends EventEmitter { return backend.getCapabilities(); } - // Forward backend operations to context backend - /** * Execute G-code command * @@ -493,6 +475,90 @@ export class PrinterBackendManager extends EventEmitter { return await backend.executeGCodeCommand(command); } + /** + * Set the print bed target temperature for a context + * + * Routes through the context backend, which prefers the legacy TCP channel on + * dual-API printers and the HTTP temperature-control API on HTTP-only models + * (Creator 5 series). + * + * @param contextId - Context ID + * @param temperature - Target temperature in Celsius + * @returns Command result + */ + public async setBedTemperature(contextId: string, temperature: number): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.setBedTemperature(temperature); + } + + /** + * Cancel print bed heating for a context + * + * @param contextId - Context ID + * @returns Command result + */ + public async cancelBedTemperature(contextId: string): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.cancelBedTemperature(); + } + + /** + * Set the extruder target temperature for a context + * + * Routes through the context backend, which prefers the legacy TCP channel on + * dual-API printers and the HTTP temperature-control API on HTTP-only models + * (Creator 5 series). + * + * @param contextId - Context ID + * @param temperature - Target temperature in Celsius + * @returns Command result + */ + public async setExtruderTemperature( + contextId: string, + temperature: number + ): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.setExtruderTemperature(temperature); + } + + /** + * Cancel extruder heating for a context + * + * @param contextId - Context ID + * @returns Command result + */ + public async cancelExtruderTemperature(contextId: string): Promise { + const backend = this.contextBackends.get(contextId); + if (!backend) { + return this.noBackendTemperatureResult(); + } + + return await backend.cancelExtruderTemperature(); + } + + /** + * Shared failure result for temperature commands with no initialized backend + */ + private noBackendTemperatureResult(): CommandResult { + return { + success: false, + error: 'No backend initialized', + timestamp: new Date(), + }; + } + /** * Get current printer status * @@ -687,7 +753,6 @@ export class PrinterBackendManager extends EventEmitter { }; } - // Check if backend supports AD5X upload if (!('uploadFileAD5X' in backend)) { return { success: false, @@ -698,7 +763,6 @@ export class PrinterBackendManager extends EventEmitter { }; } - // Use interface assertion for better type safety const ad5xBackend = backend as { uploadFileAD5X: ( filePath: string, @@ -769,7 +833,7 @@ export class PrinterBackendManager extends EventEmitter { * Handle connection established event * Now requires contextId parameter * - * @param contextId - Context ID for this connection + * @param contextId - Context ID * @param printerDetails - Printer details from connection * @param primaryClient - Primary API client * @param secondaryClient - Optional secondary API client @@ -828,7 +892,7 @@ export class PrinterBackendManager extends EventEmitter { * Handle connection lost event * Now requires contextId parameter * - * @param contextId - Context ID for the lost connection + * @param contextId - Context ID */ public async onConnectionLost(contextId: string): Promise { console.log( @@ -846,21 +910,17 @@ export class PrinterBackendManager extends EventEmitter { public async cleanup(): Promise { console.log('PrinterBackendManager: Cleaning up all contexts...'); - // Dispose of all context backends const contextIds = Array.from(this.contextBackends.keys()); for (const contextId of contextIds) { await this.disposeContext(contextId); } - // Clear all maps this.contextBackends.clear(); this.contextPrinterDetails.clear(); this.contextInitPromises.clear(); - // Remove all event listeners this.removeAllListeners(); - // Clear singleton instance PrinterBackendManager.instance = null; console.log('PrinterBackendManager: Cleanup complete'); diff --git a/src/managers/PrinterContextManager.ts b/src/managers/PrinterContextManager.ts index 9690ad0..ff0ec30 100644 --- a/src/managers/PrinterContextManager.ts +++ b/src/managers/PrinterContextManager.ts @@ -151,7 +151,6 @@ export class PrinterContextManager extends EventEmitter { this.contexts.set(contextId, context); - // Emit creation event const event: ContextCreatedEvent = { contextId, contextInfo: this.contextToInfo(context), @@ -168,7 +167,7 @@ export class PrinterContextManager extends EventEmitter { /** * Remove a context and clean up its resources * - * @param contextId - ID of context to remove + * @param contextId - Context ID * * @fires context-removed * @throws Error if context doesn't exist @@ -189,7 +188,6 @@ export class PrinterContextManager extends EventEmitter { // Remove from map (cleanup of backend/services is handled externally) this.contexts.delete(contextId); - // Emit removal event const event: ContextRemovedEvent = { contextId, wasActive, @@ -202,7 +200,7 @@ export class PrinterContextManager extends EventEmitter { /** * Switch to a different context * - * @param contextId - ID of context to switch to + * @param contextId - Context ID * * @fires context-switched * @throws Error if context doesn't exist @@ -228,7 +226,6 @@ export class PrinterContextManager extends EventEmitter { context.lastActivity = new Date(); this.activeContextId = contextId; - // Emit switch event const event: ContextSwitchEvent = { contextId, previousContextId, @@ -265,7 +262,7 @@ export class PrinterContextManager extends EventEmitter { /** * Get a specific context by ID * - * @param contextId - Context ID to retrieve + * @param contextId - Context ID * @returns Context or undefined if not found */ public getContext(contextId: string): PrinterContext | undefined { @@ -293,7 +290,7 @@ export class PrinterContextManager extends EventEmitter { /** * Check if a context exists * - * @param contextId - Context ID to check + * @param contextId - Context ID * @returns True if context exists */ public hasContext(contextId: string): boolean { @@ -312,7 +309,7 @@ export class PrinterContextManager extends EventEmitter { /** * Update context connection state * - * @param contextId - Context to update + * @param contextId - Context ID * @param state - New connection state */ public updateConnectionState(contextId: string, state: ContextConnectionState): void { @@ -326,7 +323,7 @@ export class PrinterContextManager extends EventEmitter { /** * Update context backend reference * - * @param contextId - Context to update + * @param contextId - Context ID * @param backend - Backend instance or null */ public updateBackend(contextId: string, backend: BasePrinterBackend | null): void { @@ -340,7 +337,7 @@ export class PrinterContextManager extends EventEmitter { /** * Update context printer details (for settings changes) * - * @param contextId - Context to update + * @param contextId - Context ID * @param printerDetails - Updated printer details */ public updatePrinterDetails(contextId: string, printerDetails: PrinterDetails): void { @@ -358,7 +355,7 @@ export class PrinterContextManager extends EventEmitter { /** * Update context polling service reference * - * @param contextId - Context to update + * @param contextId - Context ID * @param pollingService - Polling service instance or null */ public updatePollingService( @@ -375,7 +372,7 @@ export class PrinterContextManager extends EventEmitter { /** * Update context notification coordinator reference * - * @param contextId - Context to update + * @param contextId - Context ID * @param notificationCoordinator - Notification coordinator instance or null */ public updateNotificationCoordinator( @@ -429,7 +426,7 @@ export class PrinterContextManager extends EventEmitter { /** * Set active spool for a context - * Will be implemented once SpoolmanIntegrationService is ported + * Stores the spool data and spool ID on the in-memory context * * @param contextId - Context ID (defaults to active context if not provided) * @param spoolData - Active spool data (null to clear) @@ -438,10 +435,6 @@ export class PrinterContextManager extends EventEmitter { contextId: string | undefined, spoolData: ActiveSpoolData | null ): Promise { - // TODO: Implement once SpoolmanIntegrationService is ported - console.warn( - '[PrinterContextManager] setActiveSpool not yet implemented - SpoolmanIntegrationService pending' - ); const targetContextId = contextId || this.activeContextId; if (targetContextId) { const context = this.contexts.get(targetContextId); diff --git a/src/managers/PrinterDetailsManager.ts b/src/managers/PrinterDetailsManager.ts index 00ddc6a..24b8b41 100644 --- a/src/managers/PrinterDetailsManager.ts +++ b/src/managers/PrinterDetailsManager.ts @@ -41,14 +41,11 @@ export class PrinterDetailsManager { private readonly contextLastUsed = new Map(); // contextId -> serialNumber constructor() { - // Store printer details in data directory (standalone) const dataPath = getDataPath(); this.filePath = path.join(dataPath, 'printer_details.json'); - // Ensure data directory exists this.ensureDataDirectory(dataPath); - // Initialize with empty config this.currentConfig = { lastUsedPrinterSerial: null, printers: {}, @@ -156,7 +153,6 @@ export class PrinterDetailsManager { return false; } - // Validate ClientType is one of the expected values const clientType = detailsObj.ClientType as string; if (clientType !== 'legacy' && clientType !== 'new') { return false; @@ -176,7 +172,6 @@ export class PrinterDetailsManager { return false; } - // Validate optional per-printer settings fields if present if ( 'customCameraEnabled' in detailsObj && typeof detailsObj.customCameraEnabled !== 'boolean' @@ -215,26 +210,22 @@ export class PrinterDetailsManager { const configObj = config as Record; - // Check top-level structure if (!('lastUsedPrinterSerial' in configObj) || !('printers' in configObj)) { return false; } const { lastUsedPrinterSerial, printers } = configObj; - // Validate lastUsedPrinterSerial if (lastUsedPrinterSerial !== null && typeof lastUsedPrinterSerial !== 'string') { return false; } - // Validate printers object if (!printers || typeof printers !== 'object') { return false; } const printersObj = printers as Record; - // Validate each printer entry for (const [serialNumber, printerData] of Object.entries(printersObj)) { if (!serialNumber || typeof serialNumber !== 'string') { return false; @@ -245,7 +236,6 @@ export class PrinterDetailsManager { } } - // Validate lastUsedPrinterSerial exists in printers if not null if (lastUsedPrinterSerial && !(lastUsedPrinterSerial in printersObj)) { return false; } @@ -263,12 +253,10 @@ export class PrinterDetailsManager { const detailsObj = details as unknown as Record; - // Check for lastConnected field if (!('lastConnected' in detailsObj) || typeof detailsObj.lastConnected !== 'string') { return false; } - // Validate it's a valid ISO date string const date = new Date(detailsObj.lastConnected as string); if (Number.isNaN(date.getTime())) { return false; @@ -310,7 +298,6 @@ export class PrinterDetailsManager { private migrateFromOldFormat(oldData: PrinterDetails): MultiPrinterConfig { console.log(`Migrating old printer format for: ${oldData.Name}`); - // Ensure modelType is set if missing const modelType = oldData.modelType || detectPrinterModelType(oldData.printerModel); const normalizedOldData = this.sanitizePrinterDetails(oldData); @@ -344,12 +331,10 @@ export class PrinterDetailsManager { const fileContent = fs.readFileSync(this.filePath, 'utf8'); const parsedData: unknown = JSON.parse(fileContent); - // Check if old format and migrate if (this.isOldFormat(parsedData)) { console.log('Detected old single-printer format - migrating to multi-printer format'); this.currentConfig = this.migrateFromOldFormat(parsedData); - // Save migrated config immediately this.saveConfigToFile() .then(() => { console.log('Successfully migrated and saved multi-printer configuration'); @@ -360,7 +345,6 @@ export class PrinterDetailsManager { return; } - // Validate new format if (this.validateMultiPrinterConfig(parsedData)) { let needsResave = false; const sanitizedPrinters: MultiPrinterConfig['printers'] = {}; @@ -377,7 +361,6 @@ export class PrinterDetailsManager { printers: sanitizedPrinters, }; - // Validate lastUsedPrinterSerial integrity if ( this.currentConfig.lastUsedPrinterSerial && !(this.currentConfig.lastUsedPrinterSerial in this.currentConfig.printers) @@ -403,7 +386,6 @@ export class PrinterDetailsManager { lastUsedPrinterSerial: null, printers: {}, }; - // Remove invalid file this.clearAllPrinters(); } } catch (error) { @@ -463,7 +445,7 @@ export class PrinterDetailsManager { /** * Get the last used printer (context-aware) * - * @param contextId - Optional context ID for context-specific tracking + * @param contextId - Context ID * @returns Last used printer details or null */ public getLastUsedPrinter(contextId?: string): StoredPrinterDetails | null { @@ -488,7 +470,7 @@ export class PrinterDetailsManager { * Context-aware version * * @param details - Printer details to save - * @param contextId - Optional context ID for context-specific last-used tracking + * @param contextId - Context ID */ public async savePrinter( details: PrinterDetails, @@ -630,62 +612,19 @@ export class PrinterDetailsManager { printers: {}, }; - // Clear context-specific tracking this.contextLastUsed.clear(); } /** * Clear context-specific last-used tracking * - * @param contextId - Context ID to clear tracking for + * @param contextId - Context ID */ public clearContextTracking(contextId: string): void { this.contextLastUsed.delete(contextId); console.log(`Cleared context tracking for ${contextId}`); } - // ============================================================================= - // LEGACY API METHODS (for backward compatibility during transition) - // ============================================================================= - - /** - * Get current printer details (backward compatibility) - * Returns the last used printer or null - */ - public getPrinterDetails(): PrinterDetails | null { - const lastUsed = this.getLastUsedPrinter(); - if (!lastUsed) { - return null; - } - - // Convert StoredPrinterDetails back to PrinterDetails (remove lastConnected) - const { lastConnected: _lastConnected, ...printerDetails } = lastUsed; - return printerDetails; - } - - /** - * Save new printer details (backward compatibility) - * Saves printer and sets as last used - */ - public async saveNewPrinterDetails(details: PrinterDetails): Promise { - await this.savePrinter(details); - } - - /** - * Check if printer details exist (backward compatibility) - */ - public hasPrinterDetails(): boolean { - return this.getLastUsedPrinter() !== null; - } - - /** - * Clear stored printer details (backward compatibility) - * Clears all printers - */ - public clearPrinterDetails(): void { - this.clearAllPrinters(); - } - // ============================================================================= // UTILITY METHODS // ============================================================================= @@ -717,7 +656,6 @@ export class PrinterDetailsManager { } } -// Export singleton instance let printerDetailsManager: PrinterDetailsManager | null = null; export const getPrinterDetailsManager = (): PrinterDetailsManager => { diff --git a/src/printer-backends/AD5XBackend.ts b/src/printer-backends/AD5XBackend.ts index 371812b..2a0b82a 100644 --- a/src/printer-backends/AD5XBackend.ts +++ b/src/printer-backends/AD5XBackend.ts @@ -77,7 +77,7 @@ export class AD5XBackend extends DualAPIBackend { localJobs: false, // AD5X doesn't support local file listing recentJobs: true, uploadJobs: true, - startJobs: true, // AD5X now supports job starting with new ff-api + startJobs: true, // AD5X supports job starting via ff-api pauseResume: true, cancelJobs: true, usesNewAPI: true, @@ -95,13 +95,11 @@ export class AD5XBackend extends DualAPIBackend { * Perform AD5X-specific initialization */ protected async initializeBackend(): Promise { - // Call parent initialization await super.initializeBackend(); console.log('- Material station: Available with 4 slots'); console.log('- Job starting: Enabled with material station support'); - // Initialize material station monitoring this.initializeMaterialStationMonitoring(); } @@ -110,7 +108,6 @@ export class AD5XBackend extends DualAPIBackend { */ private initializeMaterialStationMonitoring(): void { try { - // Get initial material station status const status = this.getMaterialStationStatus(); if (status) { console.log(`Material station initialized with ${status.slots.length} slots`); @@ -127,7 +124,6 @@ export class AD5XBackend extends DualAPIBackend { protected async processMachineInfo(_machineInfo: unknown): Promise { await super.processMachineInfo(_machineInfo); - // Store machine info for material station data extraction with type validation if (isAD5XMachineInfo(_machineInfo)) { this.lastMachineInfo = _machineInfo; } else { @@ -205,7 +201,6 @@ export class AD5XBackend extends DualAPIBackend { */ public async startJob(params: JobOperationParams): Promise { try { - // Handle file upload case if (params.filePath) { const success = await this.fiveMClient.jobControl.uploadFile( params.filePath, @@ -225,7 +220,6 @@ export class AD5XBackend extends DualAPIBackend { }; } - // Handle local file printing case if (!params.fileName) { throw new Error('fileName or filePath is required'); } @@ -240,7 +234,6 @@ export class AD5XBackend extends DualAPIBackend { }; } - // Check if material mappings are provided for multi-color job const materialMappings = params.additionalParams?.materialMappings as | AD5XMaterialMapping[] | undefined; @@ -357,7 +350,7 @@ export class AD5XBackend extends DualAPIBackend { } protected supportsStartJobs(): boolean { - return true; // AD5X now supports job starting with new ff-api + return true; // AD5X supports job starting via ff-api } protected getMaterialStationSlotCount(): number { diff --git a/src/printer-backends/BasePrinterBackend.ts b/src/printer-backends/BasePrinterBackend.ts index e058813..02bc3d5 100644 --- a/src/printer-backends/BasePrinterBackend.ts +++ b/src/printer-backends/BasePrinterBackend.ts @@ -92,7 +92,6 @@ export abstract class BasePrinterBackend extends EventEmitter { this.serialNumber = options.printerDetails.serialNumber; this.typeName = options.printerDetails.typeName; - // Store per-printer settings from printer details this.customCameraEnabled = options.printerDetails.customCameraEnabled ?? false; this.customCameraUrl = options.printerDetails.customCameraUrl ?? ''; this.customLedsEnabled = options.printerDetails.customLedsEnabled ?? false; @@ -203,15 +202,12 @@ export abstract class BasePrinterBackend extends EventEmitter { } try { - // Validate primary client connection await this.validatePrimaryClient(); - // Initialize secondary client if available if (this.secondaryClient) { await this.validateSecondaryClient(); } - // Build feature set this.rebuildFeatureSet(); // Perform backend-specific initialization @@ -286,23 +282,27 @@ export abstract class BasePrinterBackend extends EventEmitter { reason: baseFeatures.filtration.reason, }, gcodeCommands: { - available: true, // Always available - usesLegacyAPI: true, // G-code always uses legacy API - supportedCommands: this.getSupportedGCodeCommands(), + // Honor the backend's own declaration: HTTP-only models (Creator 5 + // series) expose no G-code passthrough and set available=false in + // getChildBaseFeatures(). Default to true for backends that don't care. + available: baseFeatures.gcodeCommands?.available ?? true, + usesLegacyAPI: baseFeatures.gcodeCommands?.usesLegacyAPI ?? true, + supportedCommands: + baseFeatures.gcodeCommands?.supportedCommands ?? this.getSupportedGCodeCommands(), }, statusMonitoring: { - available: true, // Always available - usesNewAPI: this.supportsNewAPI(), - usesLegacyAPI: true, // Always available as fallback - realTimeUpdates: this.supportsNewAPI(), + available: baseFeatures.statusMonitoring?.available ?? true, + usesNewAPI: baseFeatures.statusMonitoring?.usesNewAPI ?? this.supportsNewAPI(), + usesLegacyAPI: baseFeatures.statusMonitoring?.usesLegacyAPI ?? true, + realTimeUpdates: baseFeatures.statusMonitoring?.realTimeUpdates ?? this.supportsNewAPI(), }, jobManagement: { localJobs: this.supportsLocalJobs(), recentJobs: this.supportsRecentJobs(), uploadJobs: this.supportsUploadJobs(), startJobs: this.supportsStartJobs(), - pauseResume: true, // Always available - cancelJobs: true, // Always available + pauseResume: true, + cancelJobs: true, usesNewAPI: this.supportsNewAPI(), }, materialStation: { @@ -471,7 +471,6 @@ export abstract class BasePrinterBackend extends EventEmitter { */ public async dispose(): Promise { try { - // Dispose of clients if (this.primaryClient) { await this.primaryClient.dispose(); } @@ -480,12 +479,10 @@ export abstract class BasePrinterBackend extends EventEmitter { await this.secondaryClient.dispose(); } - // Clean up state this.initialized = false; this.connected = false; this.features = null; - // Remove event listeners this.removeAllListeners(); this.emitEvent('disconnected'); @@ -572,6 +569,61 @@ export abstract class BasePrinterBackend extends EventEmitter { */ public abstract setLedEnabled(enabled: boolean): Promise; + /** + * Set the print bed target temperature + * @param temperature - Target temperature in Celsius + * @returns Command result with success/failure + */ + public abstract setBedTemperature(temperature: number): Promise; + + /** + * Cancel print bed heating (target back to 0) + * @returns Command result with success/failure + */ + public abstract cancelBedTemperature(): Promise; + + /** + * Set the extruder target temperature + * @param temperature - Target temperature in Celsius + * @returns Command result with success/failure + */ + public abstract setExtruderTemperature(temperature: number): Promise; + + /** + * Cancel extruder heating (target back to 0) + * @returns Command result with success/failure + */ + public abstract cancelExtruderTemperature(): Promise; + + /** + * Wrap a boolean temperature-control action into a CommandResult. + * + * Shared by the backend heater implementations so the only backend-specific + * logic is the client routing (legacy TCP vs HTTP temperature control). + * + * @param action - Heater action resolving to the transport's success flag + * @returns Command result with success/failure + */ + protected async runTemperatureControlCommand( + action: () => Promise + ): Promise { + try { + const success = await action(); + + return { + success, + error: success ? undefined : 'Temperature command failed', + timestamp: new Date(), + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + timestamp: new Date(), + }; + } + } + // Helper methods for feature detection protected abstract supportsNewAPI(): boolean; diff --git a/src/printer-backends/DualAPIBackend.ts b/src/printer-backends/DualAPIBackend.ts index 6e24e69..5e41bd2 100644 --- a/src/printer-backends/DualAPIBackend.ts +++ b/src/printer-backends/DualAPIBackend.ts @@ -68,12 +68,10 @@ export abstract class DualAPIBackend extends BasePrinterBackend { * Common initialization logic for all dual-API backends */ protected initializeClients(): void { - // Validate primary client is FiveMClient if (!(this.primaryClient instanceof FiveMClient)) { throw new Error(`${this.constructor.name} requires FiveMClient as primary client`); } - // Validate secondary client is FlashForgeClient if (!this.secondaryClient || !(this.secondaryClient instanceof FlashForgeClient)) { throw new Error(`${this.constructor.name} requires FlashForgeClient as secondary client`); } @@ -92,10 +90,8 @@ export abstract class DualAPIBackend extends BasePrinterBackend { } try { - // Validate primary client connection await this.validatePrimaryClient(); - // Initialize secondary client if available if (this.secondaryClient) { await this.validateSecondaryClient(); } @@ -103,7 +99,7 @@ export abstract class DualAPIBackend extends BasePrinterBackend { // Fetch product info BEFORE building feature set await this.fetchProductInfo(); - // Now call parent initialize which will build features using our product info + // Call parent initialize, which builds features using our product info await super.initialize(); } catch (error) { this.emitEvent('error', null, error instanceof Error ? error.message : String(error)); @@ -123,12 +119,10 @@ export abstract class DualAPIBackend extends BasePrinterBackend { * Override in subclasses for model-specific setup */ protected async initializeBackend(): Promise { - // Log initialization with backend name console.log(`${this.constructor.name} initialized for ${this.printerName}`); console.log('- Primary client (FiveMClient): Available'); console.log('- Secondary client (FlashForgeClient): Available'); - // Log detected features if we have product info if (this.productInfo) { console.log('Auto-detected features from product endpoint:'); console.log( @@ -158,7 +152,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { */ protected async fetchProductInfo(): Promise { try { - // Call sendProductCommand to populate productInfo const success = await this.fiveMClient.sendProductCommand(); if (!success || !this.fiveMClient.productInfo) { @@ -166,7 +159,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { return; } - // Store product info for use in getBaseFeatures this.productInfo = this.fiveMClient.productInfo; } catch (error) { console.error('Error fetching product info:', error); @@ -179,7 +171,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { * Uses product info to determine LED and filtration availability */ protected getBaseFeatures(): PrinterFeatureSet { - // Get child-specific features first const childFeatures = this.getChildBaseFeatures(); // Override LED and filtration based on product info if available @@ -193,7 +184,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { ? childFeatures.ledControl.builtin : this.productInfo.lightCtrlState !== 0; - // Return new object with overridden values return { ...childFeatures, ledControl: { @@ -226,6 +216,17 @@ export abstract class DualAPIBackend extends BasePrinterBackend { public async executeGCodeCommand(command: string): Promise { const startTime = Date.now(); + // HTTP-only backends (Creator 5 / 5 Pro) have no TCP channel for raw G-code. + if (!this.legacyClient) { + return { + success: false, + command, + error: 'Raw G-code is unavailable on this printer (HTTP-only, no TCP channel)', + executionTime: 0, + timestamp: new Date(), + }; + } + try { // G-code commands always use legacy API const response = await this.legacyClient.sendRawCmd(command); @@ -252,32 +253,85 @@ export abstract class DualAPIBackend extends BasePrinterBackend { } } + // ----- Bed / extruder heater control --------------------------------------- + + /** + * Set the bed target temperature. + * + * Dual-API printers keep preferring the legacy TCP channel (the same `~M140` + * wire command the raw G-code path sends); HTTP-only backends (Creator 5 + * series, no TCP channel) route through the FiveMClient temperature-control + * API. The branch is on client presence, so an HTTP-only printer never + * attempts a TCP connection. + */ + public async setBedTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.setBedTemp(temperature) + : this.fiveMClient.tempControl.setBedTemp(temperature) + ); + } + + /** + * Cancel bed heating. Legacy channel sends `~M140 S0`; the HTTP-only path + * uses the temperature-control cancel command. + */ + public async cancelBedTemperature(): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.cancelBedTemp() + : this.fiveMClient.tempControl.cancelBedTemp() + ); + } + + /** + * Set the extruder target temperature. Legacy channel sends `~M104`; the + * HTTP-only path (Creator 5 series) drives the primary tool via the + * temperature-control API. + */ + public async setExtruderTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.setExtruderTemp(temperature) + : this.fiveMClient.tempControl.setExtruderTemp(temperature) + ); + } + + /** + * Cancel extruder heating. Legacy channel sends `~M104 S0`; the HTTP-only + * path (Creator 5 series) turns the primary tool off via the + * temperature-control API. + */ + public async cancelExtruderTemperature(): Promise { + return this.runTemperatureControlCommand(() => + this.legacyClient + ? this.legacyClient.cancelExtruderTemp() + : this.fiveMClient.tempControl.cancelExtruderTemp() + ); + } + /** * Get current printer status using new API with legacy fallback * Common implementation with hooks for backend-specific fields */ public async getPrinterStatus(): Promise { try { - // Use new API for status monitoring const status = await this.fiveMClient.info.getStatus(); if (!status) { throw new Error('Failed to get printer status'); } - // Get detailed machine info for additional data const machineInfo = await this.fiveMClient.info.get(); - // Allow subclasses to process machine info await this.processMachineInfo(machineInfo); - // Calculate time estimates properly + // estimatedTime is already a remaining countdown. Do not subtract elapsed + // time again (that double-counts and shrinks the remaining duration). const estimatedTimeSeconds = machineInfo?.EstimatedTime || 0; const elapsedTimeSeconds = machineInfo?.PrintDuration || 0; - const remainingTimeSeconds = - estimatedTimeSeconds > elapsedTimeSeconds ? estimatedTimeSeconds - elapsedTimeSeconds : 0; + const remainingTimeSeconds = estimatedTimeSeconds; - // Extract current filament usage values const estimatedRightLen = machineInfo?.EstLength || 0; const estimatedRightWeight = machineInfo?.EstWeight || 0; const currentJob = machineInfo?.PrintFileName; @@ -301,7 +355,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { let finalEstimatedRightWeight = estimatedRightWeight; if (status === 'completed' && this.lastFilamentUsageCache) { - // Verify cache matches current job if (this.lastFilamentUsageCache.currentJob === currentJob) { finalEstimatedRightLen = this.lastFilamentUsageCache.estimatedRightLen; finalEstimatedRightWeight = this.lastFilamentUsageCache.estimatedRightWeight; @@ -338,6 +391,7 @@ export abstract class DualAPIBackend extends BasePrinterBackend { estimatedRightLen: finalEstimatedRightLen, estimatedRightWeight: finalEstimatedRightWeight, printEta: machineInfo?.PrintEta || undefined, + completionTime: machineInfo?.CompletionTime ?? null, cumulativePrintTime: machineInfo?.CumulativePrintTime || 0, cumulativeFilament: machineInfo?.CumulativeFilament || 0, nozzleSize: machineInfo?.NozzleSize || '0.4mm', @@ -347,7 +401,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { coolingFanSpeed: machineInfo?.CoolingFanSpeed || 0, chamberFanSpeed: machineInfo?.ChamberFanSpeed || 0, tvoc: machineInfo?.Tvoc || 0, - // Allow subclasses to add additional fields ...this.getAdditionalStatusFields(machineInfo), }; @@ -468,7 +521,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { printingTime: fileEntry.printingTime, })); - // Allow subclasses to transform job data const transformedJobs = this.transformJobList(jobs, 'recent'); return { @@ -495,7 +547,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { */ public async startJob(params: JobOperationParams): Promise { try { - // Handle file upload case if (params.filePath) { const success = await this.fiveMClient.jobControl.uploadFile( params.filePath, @@ -515,7 +566,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { }; } - // Handle local file printing case if (!params.fileName) { throw new Error('fileName or filePath is required'); } @@ -573,7 +623,14 @@ export abstract class DualAPIBackend extends BasePrinterBackend { timestamp: new Date(), }; } catch (error) { - // Fallback to legacy API + // Fallback to legacy API (unavailable on HTTP-only backends) + if (!this.legacyClient) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + timestamp: new Date(), + }; + } try { await this.legacyClient.sendRawCmd('M25'); return { @@ -581,10 +638,10 @@ export abstract class DualAPIBackend extends BasePrinterBackend { data: 'Job paused (via legacy API)', timestamp: new Date(), }; - } catch { + } catch (fallbackError) { return { success: false, - error: error instanceof Error ? error.message : String(error), + error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), timestamp: new Date(), }; } @@ -608,7 +665,14 @@ export abstract class DualAPIBackend extends BasePrinterBackend { timestamp: new Date(), }; } catch (error) { - // Fallback to legacy API + // Fallback to legacy API (unavailable on HTTP-only backends) + if (!this.legacyClient) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + timestamp: new Date(), + }; + } try { await this.legacyClient.sendRawCmd('M24'); return { @@ -616,10 +680,10 @@ export abstract class DualAPIBackend extends BasePrinterBackend { data: 'Job resumed (via legacy API)', timestamp: new Date(), }; - } catch { + } catch (fallbackError) { return { success: false, - error: error instanceof Error ? error.message : String(error), + error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), timestamp: new Date(), }; } @@ -643,7 +707,14 @@ export abstract class DualAPIBackend extends BasePrinterBackend { timestamp: new Date(), }; } catch (error) { - // Fallback to legacy API + // Fallback to legacy API (unavailable on HTTP-only backends) + if (!this.legacyClient) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + timestamp: new Date(), + }; + } try { await this.legacyClient.sendRawCmd('M26'); return { @@ -651,10 +722,10 @@ export abstract class DualAPIBackend extends BasePrinterBackend { data: 'Job cancelled (via legacy API)', timestamp: new Date(), }; - } catch { + } catch (fallbackError) { return { success: false, - error: error instanceof Error ? error.message : String(error), + error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), timestamp: new Date(), }; } @@ -666,7 +737,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { */ public async getModelPreview(): Promise { try { - // First check if printer is currently printing const machineInfo = await this.fiveMClient.info.get(); if (!machineInfo || !machineInfo.PrintFileName || machineInfo.PrintFileName === '') { @@ -674,7 +744,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { return null; } - // Use the general job thumbnail method for the current job return this.getJobThumbnail(machineInfo.PrintFileName); } catch (error) { console.error('Error getting model preview:', error); @@ -692,7 +761,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { return null; } - // Get the thumbnail for the specified file const thumbnailBuffer = await this.fiveMClient.files.getGCodeThumbnail(fileName); if (!thumbnailBuffer || thumbnailBuffer.length === 0) { @@ -700,7 +768,6 @@ export abstract class DualAPIBackend extends BasePrinterBackend { return null; } - // Convert buffer to base64 data URL const base64Data = thumbnailBuffer.toString('base64'); return `data:image/png;base64,${base64Data}`; } catch (error) { @@ -776,27 +843,27 @@ export abstract class DualAPIBackend extends BasePrinterBackend { // Feature detection methods - common implementations protected supportsNewAPI(): boolean { - return true; // All dual-API backends support new API + return true; } protected supportsCustomLEDControl(): boolean { - return true; // All current dual-API backends support custom LED control + return true; } protected supportsLocalJobs(): boolean { - return true; // All dual-API backends support local jobs + return true; } protected supportsRecentJobs(): boolean { - return true; // All dual-API backends support recent jobs + return true; } protected supportsUploadJobs(): boolean { - return true; // All dual-API backends support upload jobs + return true; } protected supportsStartJobs(): boolean { - return true; // All dual-API backends support starting jobs + return true; } protected getSupportedGCodeCommands(): readonly string[] { @@ -909,11 +976,9 @@ export abstract class DualAPIBackend extends BasePrinterBackend { * Override to clear filament usage cache on disconnect */ public async dispose(): Promise { - // Clear filament usage cache on disconnect this.lastFilamentUsageCache = null; this.lastFallbackCameraProbeAt = 0; - // Call parent dispose to clean up clients await super.dispose(); } } diff --git a/src/printer-backends/GenericLegacyBackend.ts b/src/printer-backends/GenericLegacyBackend.ts index 44f9643..954638e 100644 --- a/src/printer-backends/GenericLegacyBackend.ts +++ b/src/printer-backends/GenericLegacyBackend.ts @@ -148,14 +148,45 @@ export class GenericLegacyBackend extends BasePrinterBackend { } } + // ----- Bed / extruder heater control (legacy TCP G-code channel) ----------- + + /** + * Set the bed target temperature via the legacy TCP G-code channel + * (`~M140 S`), matching the raw G-code path this backend has always used. + */ + public async setBedTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.setBedTemp(temperature)); + } + + /** + * Cancel bed heating via the legacy TCP G-code channel (`~M140 S0`). + */ + public async cancelBedTemperature(): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.cancelBedTemp()); + } + + /** + * Set the extruder target temperature via the legacy TCP G-code channel + * (`~M104 S`). + */ + public async setExtruderTemperature(temperature: number): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.setExtruderTemp(temperature)); + } + + /** + * Cancel extruder heating via the legacy TCP G-code channel (`~M104 S0`). + */ + public async cancelExtruderTemperature(): Promise { + return this.runTemperatureControlCommand(() => this.legacyClient.cancelExtruderTemp()); + } + /** * Get current printer status using legacy API */ public async getPrinterStatus(): Promise { try { - // === RAW API DATA FETCHING (like legacy JavaScript version) === + // === RAW API DATA FETCHING === - // Get basic printer info const printerInfo = await this.legacyClient.getPrinterInfo(); console.log('[DEBUG] Raw printerInfo response:', { type: typeof printerInfo, @@ -165,7 +196,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { printerInfo: printerInfo ? JSON.stringify(printerInfo, null, 2) : 'null/undefined', }); - // Get temperature info (like legacy version) let tempInfo: TempInfo | null = null; try { tempInfo = await this.legacyClient.getTempInfo(); @@ -173,7 +203,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { // Silently handle tempInfo errors } - // Get endstop status (like legacy version) let endstopStatus: EndstopStatus | null = null; try { endstopStatus = await this.legacyClient.getEndstopInfo(); @@ -187,21 +216,18 @@ export class GenericLegacyBackend extends BasePrinterBackend { // === BUILDING STATUS OBJECT (using proper ff-api types) === - // Use proper ff-api temperature extraction let bedTemp = 0; let bedTarget = 0; let nozzleTemp = 0; let nozzleTarget = 0; if (tempInfo) { - // Get bed temperature using proper types const bedTempData: TempData | null = tempInfo.getBedTemp(); if (bedTempData) { bedTemp = bedTempData.getCurrent(); bedTarget = bedTempData.getSet(); } - // Get extruder temperature using proper types const extruderTempData: TempData | null = tempInfo.getExtruderTemp(); if (extruderTempData) { nozzleTemp = extruderTempData.getCurrent(); @@ -210,10 +236,8 @@ export class GenericLegacyBackend extends BasePrinterBackend { } // If tempInfo is null, temperatures remain at default 0 values - // Use proper ff-api state extraction with explicit switch let printerState = 'unknown'; if (endstopStatus) { - // Use explicit switch for reliable core parsing logic const machineStatus: MachineStatus = endstopStatus._MachineStatus; switch (machineStatus) { @@ -253,14 +277,12 @@ export class GenericLegacyBackend extends BasePrinterBackend { const printStatus: PrintStatus | null = await this.legacyClient.getPrintStatus(); if (printStatus) { - // Extract progress percentage const progressPercent = printStatus.getPrintPercent(); if (!Number.isNaN(progressPercent)) { progress = progressPercent; console.log(`[GenericLegacyBackend] Progress: ${progress}%`); } - // Extract layer information const layerProgress = printStatus.getLayerProgress(); if (layerProgress?.includes('/')) { const layerParts = layerProgress.split('/'); @@ -277,8 +299,7 @@ export class GenericLegacyBackend extends BasePrinterBackend { console.log(`[GenericLegacyBackend] Layers: ${currentLayer}/${totalLayers}`); } - // Enhanced job name could be extracted from PrintStatus if needed - // For now, keep using endstopStatus._CurrentFile as it's reliable + // Keep endstopStatus._CurrentFile for the job name; it is reliable } else { console.log('[GenericLegacyBackend] PrintStatus returned null'); } @@ -297,12 +318,12 @@ export class GenericLegacyBackend extends BasePrinterBackend { bedTargetTemperature: bedTarget, nozzleTemperature: nozzleTemp, nozzleTargetTemperature: nozzleTarget, - progress, // Now using PrintStatus.getPrintPercent() when available + progress, // PrintStatus.getPrintPercent() when available currentJob: enhancedJobName, // Legacy API does NOT provide estimatedTime or remainingTime estimatedTime: undefined, remainingTime: undefined, - // Now using PrintStatus.getLayerProgress() when available + // PrintStatus.getLayerProgress() when available currentLayer, totalLayers, // Legacy printers don't provide these fields @@ -346,7 +367,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { try { const fileNames = await this.getLegacyFileList(); - // Convert filenames to BasicJobInfo objects const jobs: BasicJobInfo[] = fileNames.map((fileName) => ({ fileName, printingTime: 0, // Legacy printers don't provide time estimates via M661 @@ -380,7 +400,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { const fileNames = await this.getLegacyFileList(); const recentFileNames = fileNames.slice(0, 10); - // Convert filenames to BasicJobInfo objects const jobs: BasicJobInfo[] = recentFileNames.map((fileName) => ({ fileName, printingTime: 0, // Legacy printers don't provide time estimates via M661 @@ -551,14 +570,12 @@ export class GenericLegacyBackend extends BasePrinterBackend { */ public async getModelPreview(): Promise { try { - // Check if printer is currently printing const status = await this.getPrinterStatus(); if (!status.success || !status.status.currentJob) { // No active print job, no preview available return null; } - // Use the general job thumbnail method for the current job return this.getJobThumbnail(status.status.currentJob); } catch (error) { console.error('Error getting model preview:', error); @@ -579,7 +596,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { console.log(`[ThumbnailRequest] Starting thumbnail request for: ${fileName}`); - // Use the FlashForgeClient getThumbnail method const thumbnailInfo = await this.legacyClient.getThumbnail(fileName); if (!thumbnailInfo) { @@ -587,7 +603,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { return null; } - // Get the base64 data using the proper method const base64Data = thumbnailInfo.getImageData(); if (!base64Data) { console.warn(`Thumbnail data is empty for file: ${fileName}`); @@ -596,7 +611,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { console.log(`[ThumbnailRequest] Successfully fetched thumbnail for: ${fileName}`); - // Convert to base64 data URL return `data:image/png;base64,${base64Data}`; } catch (error) { console.error(`Error getting thumbnail for ${fileName}:`, error); @@ -621,7 +635,6 @@ export class GenericLegacyBackend extends BasePrinterBackend { }; } - // Use proper FlashForgeClient LED methods const result = enabled ? await this.legacyClient.ledOn() : await this.legacyClient.ledOff(); if (!result) { @@ -706,7 +719,7 @@ export class GenericLegacyBackend extends BasePrinterBackend { } protected supportsRecentJobs(): boolean { - return true; // Legacy printers now support recent jobs via M661 (first 10 files) + return true; // Recent jobs via M661, limited to the first 10 files } protected supportsUploadJobs(): boolean { diff --git a/src/printer-backends/ad5x/ad5x-types.ts b/src/printer-backends/ad5x/ad5x-types.ts index 350e9b0..ef4e4eb 100644 --- a/src/printer-backends/ad5x/ad5x-types.ts +++ b/src/printer-backends/ad5x/ad5x-types.ts @@ -28,11 +28,9 @@ export { MatlStationInfo, SlotInfo, } from '@ghosttypes/ff-api'; -// Keep our UI-specific types that transform the data structure // AD5X job info extends the base job info with material station data export { AD5XJobInfo, MaterialSlotInfo, MaterialStationStatus } from '../../types/printer-backend'; -// Import MatlStationInfo for type definitions import type { MatlStationInfo as MatlStationInfoType } from '@ghosttypes/ff-api'; // Type for the raw machine info structure from AD5X API responses diff --git a/src/printer-backends/ad5x/ad5x-utils.ts b/src/printer-backends/ad5x/ad5x-utils.ts index 2e829fd..7240c77 100644 --- a/src/printer-backends/ad5x/ad5x-utils.ts +++ b/src/printer-backends/ad5x/ad5x-utils.ts @@ -144,5 +144,5 @@ export function createColorDifferenceWarning( slotId: number, slotColor: string ): string { - return `Color difference detected: ${getToolDisplayName(toolId)} expects ${toolColor} but ${getSlotDisplayName(slotId - 1)} has ${slotColor}. This is allowed but may affect print appearance.`; + return `${getToolDisplayName(toolId)} expects ${toolColor} but ${getSlotDisplayName(slotId - 1)} has ${slotColor}.`; } diff --git a/src/printer-backends/temperature-control.test.ts b/src/printer-backends/temperature-control.test.ts new file mode 100644 index 0000000..f0ff8bd --- /dev/null +++ b/src/printer-backends/temperature-control.test.ts @@ -0,0 +1,278 @@ +/** + * @fileoverview Jest coverage for backend bed/extruder heater routing. + * + * Covers the client-presence routing introduced for the Creator 5 series: + * - HTTP-only backends (Creator 5 / 5 Pro, no legacy TCP client) must use the + * FiveMClient temperature-control API and never touch raw G-code. + * - Legacy TCP-only backends (GenericLegacyBackend) must keep using the legacy + * G-code channel (`~M140` / `~M104` via the FlashForgeClient temp methods). + * - Dual-API backends with both clients must prefer the legacy channel. + * + * The `@ghosttypes/ff-api` classes are replaced with in-memory doubles so the + * `instanceof` routing inside the backends is exercised without sockets. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { BackendInitOptions, CommandResult } from '../types/printer-backend'; + +jest.mock('@ghosttypes/ff-api', () => { + const actual = jest.requireActual>('@ghosttypes/ff-api'); + return { + ...actual, + FiveMClient: class { + public readonly tempControl = { + setBedTemp: jest.fn<(temp: number) => Promise>(async () => true), + cancelBedTemp: jest.fn<() => Promise>(async () => true), + setExtruderTemp: jest.fn<(temp: number) => Promise>(async () => true), + cancelExtruderTemp: jest.fn<() => Promise>(async () => true), + }; + }, + FlashForgeClient: class { + public readonly setBedTemp = jest.fn<(temp: number) => Promise>(async () => true); + public readonly cancelBedTemp = jest.fn<() => Promise>(async () => true); + public readonly setExtruderTemp = jest.fn<(temp: number) => Promise>( + async () => true + ); + public readonly cancelExtruderTemp = jest.fn<() => Promise>(async () => true); + }, + }; +}); + +import { FiveMClient, FlashForgeClient } from '@ghosttypes/ff-api'; +import { Adventurer5MBackend } from './Adventurer5MBackend'; +import { Creator5Backend } from './Creator5Backend'; +import { GenericLegacyBackend } from './GenericLegacyBackend'; + +interface TempControlDouble { + readonly setBedTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelBedTemp: jest.Mock<() => Promise>; + readonly setExtruderTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelExtruderTemp: jest.Mock<() => Promise>; +} + +interface LegacyClientDouble { + readonly setBedTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelBedTemp: jest.Mock<() => Promise>; + readonly setExtruderTemp: jest.Mock<(temp: number) => Promise>; + readonly cancelExtruderTemp: jest.Mock<() => Promise>; +} + +function createFiveMClient(): FiveMClient { + return new FiveMClient('127.0.0.1', 'FFSN01', 'CODE01'); +} + +function httpTempControl(client: FiveMClient): TempControlDouble { + return (client as unknown as { tempControl: TempControlDouble }).tempControl; +} + +function createLegacyClient(): FlashForgeClient { + return new FlashForgeClient('127.0.0.1'); +} + +function legacyDouble(client: FlashForgeClient): LegacyClientDouble { + return client as unknown as LegacyClientDouble; +} + +function backendOptions( + printerModel: BackendInitOptions['printerModel'], + primaryClient: FiveMClient | FlashForgeClient, + secondaryClient?: FlashForgeClient +): BackendInitOptions { + return { + printerModel, + primaryClient, + secondaryClient, + printerDetails: { + name: 'Test Printer', + ipAddress: '127.0.0.1', + serialNumber: 'FFSN01', + typeName: 'FlashForge Test', + }, + }; +} + +function createCreator5Backend(): { backend: Creator5Backend; tempControl: TempControlDouble } { + const fiveMClient = createFiveMClient(); + const backend = new Creator5Backend(backendOptions('creator-5', fiveMClient)); + return { backend, tempControl: httpTempControl(fiveMClient) }; +} + +function createDualBackend(): { + backend: Adventurer5MBackend; + tempControl: TempControlDouble; + legacy: LegacyClientDouble; +} { + const fiveMClient = createFiveMClient(); + const legacyClient = createLegacyClient(); + const backend = new Adventurer5MBackend( + backendOptions('adventurer-5m', fiveMClient, legacyClient) + ); + return { + backend, + tempControl: httpTempControl(fiveMClient), + legacy: legacyDouble(legacyClient), + }; +} + +function createLegacyBackend(): { backend: GenericLegacyBackend; legacy: LegacyClientDouble } { + const legacyClient = createLegacyClient(); + const backend = new GenericLegacyBackend(backendOptions('generic-legacy', legacyClient)); + return { backend, legacy: legacyDouble(legacyClient) }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('Creator5Backend heater routing (HTTP-only, no legacy client)', () => { + it('sets the bed temperature through the HTTP temperature-control API', async () => { + const { backend, tempControl } = createCreator5Backend(); + const gcodeSpy = jest.spyOn(backend, 'executeGCodeCommand'); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(true); + expect(tempControl.setBedTemp).toHaveBeenCalledTimes(1); + expect(tempControl.setBedTemp).toHaveBeenCalledWith(60); + expect(gcodeSpy).not.toHaveBeenCalled(); + }); + + it('cancels bed heating through the HTTP temperature-control API', async () => { + const { backend, tempControl } = createCreator5Backend(); + + const result = await backend.cancelBedTemperature(); + + expect(result.success).toBe(true); + expect(tempControl.cancelBedTemp).toHaveBeenCalledTimes(1); + expect(tempControl.setBedTemp).not.toHaveBeenCalled(); + }); + + it('sets and cancels the extruder temperature through the HTTP temperature-control API', async () => { + const { backend, tempControl } = createCreator5Backend(); + + const setResult = await backend.setExtruderTemperature(210); + expect(setResult.success).toBe(true); + expect(tempControl.setExtruderTemp).toHaveBeenCalledWith(210); + + const cancelResult = await backend.cancelExtruderTemperature(); + expect(cancelResult.success).toBe(true); + expect(tempControl.cancelExtruderTemp).toHaveBeenCalledTimes(1); + }); + + it('maps HTTP temperature-control failures to an unsuccessful CommandResult', async () => { + const { backend, tempControl } = createCreator5Backend(); + tempControl.setBedTemp.mockRejectedValueOnce(new Error('HTTP request failed')); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP request failed'); + }); + + it('maps a rejected HTTP temperature-control command to an unsuccessful CommandResult', async () => { + const { backend, tempControl } = createCreator5Backend(); + tempControl.cancelExtruderTemp.mockResolvedValueOnce(false); + + const result = await backend.cancelExtruderTemperature(); + + expect(result.success).toBe(false); + expect(result.error).toBe('Temperature command failed'); + }); +}); + +describe('DualAPIBackend heater routing (legacy TCP preferred)', () => { + it('prefers the legacy TCP client for the bed heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(true); + expect(legacy.setBedTemp).toHaveBeenCalledTimes(1); + expect(legacy.setBedTemp).toHaveBeenCalledWith(60); + expect(tempControl.setBedTemp).not.toHaveBeenCalled(); + }); + + it('prefers the legacy TCP client for canceling the bed heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.cancelBedTemperature(); + + expect(result.success).toBe(true); + expect(legacy.cancelBedTemp).toHaveBeenCalledTimes(1); + expect(tempControl.cancelBedTemp).not.toHaveBeenCalled(); + }); + + it('prefers the legacy TCP client for the extruder heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.setExtruderTemperature(210); + + expect(result.success).toBe(true); + expect(legacy.setExtruderTemp).toHaveBeenCalledTimes(1); + expect(legacy.setExtruderTemp).toHaveBeenCalledWith(210); + expect(tempControl.setExtruderTemp).not.toHaveBeenCalled(); + }); + + it('prefers the legacy TCP client for canceling the extruder heater', async () => { + const { backend, tempControl, legacy } = createDualBackend(); + + const result = await backend.cancelExtruderTemperature(); + + expect(result.success).toBe(true); + expect(legacy.cancelExtruderTemp).toHaveBeenCalledTimes(1); + expect(tempControl.cancelExtruderTemp).not.toHaveBeenCalled(); + }); + + it('maps legacy client failures to an unsuccessful CommandResult', async () => { + const { backend, legacy } = createDualBackend(); + legacy.setExtruderTemp.mockRejectedValueOnce(new Error('socket hang up')); + + const result = await backend.setExtruderTemperature(210); + + expect(result.success).toBe(false); + expect(result.error).toBe('socket hang up'); + }); +}); + +describe('GenericLegacyBackend heater routing (TCP G-code only)', () => { + it('sets the bed temperature through the legacy G-code channel', async () => { + const { backend, legacy } = createLegacyBackend(); + + const result = await backend.setBedTemperature(60); + + expect(result.success).toBe(true); + expect(legacy.setBedTemp).toHaveBeenCalledTimes(1); + expect(legacy.setBedTemp).toHaveBeenCalledWith(60); + }); + + it('cancels bed heating through the legacy G-code channel', async () => { + const { backend, legacy } = createLegacyBackend(); + + const result = await backend.cancelBedTemperature(); + + expect(result.success).toBe(true); + expect(legacy.cancelBedTemp).toHaveBeenCalledTimes(1); + }); + + it('sets and cancels the extruder temperature through the legacy G-code channel', async () => { + const { backend, legacy } = createLegacyBackend(); + + const setResult = await backend.setExtruderTemperature(210); + expect(setResult.success).toBe(true); + expect(legacy.setExtruderTemp).toHaveBeenCalledWith(210); + + const cancelResult = await backend.cancelExtruderTemperature(); + expect(cancelResult.success).toBe(true); + expect(legacy.cancelExtruderTemp).toHaveBeenCalledTimes(1); + }); + + it('maps legacy G-code failures to an unsuccessful CommandResult', async () => { + const { backend, legacy } = createLegacyBackend(); + legacy.setBedTemp.mockRejectedValueOnce(new Error('write EPIPE')); + + const result: CommandResult = await backend.setBedTemperature(60); + + expect(result.success).toBe(false); + expect(result.error).toBe('write EPIPE'); + }); +}); diff --git a/src/services/AutoConnectService.ts b/src/services/AutoConnectService.ts index eb2d586..93a2fb5 100644 --- a/src/services/AutoConnectService.ts +++ b/src/services/AutoConnectService.ts @@ -41,7 +41,6 @@ export class AutoConnectService extends EventEmitter { */ public shouldAutoConnect(): boolean { // Auto-connect is always enabled by default - // We could add a config option later if needed return true; } @@ -83,8 +82,7 @@ export class AutoConnectService extends EventEmitter { return null; } - // For now, return null to let the UI handle selection - // We could add last used printer tracking later + // Return null; the UI handles printer selection return null; } @@ -93,8 +91,6 @@ export class AutoConnectService extends EventEmitter { * Can be used for direct connection attempts */ public shouldAutoConnectToPrinter(_serialNumber: string): boolean { - // For now, always return false - // We could add last used printer tracking later return false; } @@ -102,7 +98,6 @@ export class AutoConnectService extends EventEmitter { * Update auto-connect preferences after successful connection */ public updateAutoConnectPreferences(serialNumber: string): void { - // This might update config settings for future auto-connect this.emit('auto-connect-preferences-updated', serialNumber); } @@ -111,7 +106,6 @@ export class AutoConnectService extends EventEmitter { * Allows for a brief delay before attempting auto-connect */ public getAutoConnectDelay(): number { - // Return default delay of 100ms return 100; } @@ -136,7 +130,6 @@ export class AutoConnectService extends EventEmitter { } } -// Export singleton getter function export const getAutoConnectService = (): AutoConnectService => { return AutoConnectService.getInstance(); }; diff --git a/src/services/ConnectionEstablishmentService.test.ts b/src/services/ConnectionEstablishmentService.test.ts new file mode 100644 index 0000000..1741abd --- /dev/null +++ b/src/services/ConnectionEstablishmentService.test.ts @@ -0,0 +1,99 @@ +/** + * @fileoverview Tests for the product-ID short-circuit in + * ConnectionEstablishmentService.createTemporaryConnection (issue #17). + * When the discovery USB product ID identifies any modern (new-API) model, the + * service must synthesize the type info from the discovery packet and return + * without ever opening a legacy TCP probe. Printers with no product ID still + * fall through to the probe, which remains the detection path for legacy models. + */ + +import { describe, expect, it, jest } from '@jest/globals'; +import type { DiscoveredPrinter } from '../types/printer'; +import { getConnectionEstablishmentService } from './ConnectionEstablishmentService'; + +describe('ConnectionEstablishmentService.createTemporaryConnection', () => { + const service = getConnectionEstablishmentService(); + + const basePrinter: DiscoveredPrinter = { + name: 'Printer at 192.168.1.184', + ipAddress: '192.168.1.184', + serialNumber: 'SNCRE5PRO001', + model: undefined, + }; + + it('short-circuits the TCP probe for the Creator 5 (productId 40)', async () => { + const result = await service.createTemporaryConnection({ ...basePrinter, productId: 40 }); + + expect(result.success).toBe(true); + expect(result.typeName).toBe('Creator 5'); + expect(result.printerInfo?.SerialNumber).toBe('SNCRE5PRO001'); + }); + + it('short-circuits the TCP probe for the Creator 5 Pro (productId 41)', async () => { + const result = await service.createTemporaryConnection({ ...basePrinter, productId: 41 }); + + expect(result.success).toBe(true); + expect(result.typeName).toBe('Creator 5 Pro'); + }); + + it('short-circuits the TCP probe for dual-API product IDs', async () => { + // The broadcast carries the serial (0x92) and the product ID (0x88), and + // FiveMClient.initialize() supplies the capability flags — so the probe adds + // nothing for 5M / 5M Pro / AD5X either, and must not run. + const spy = jest.spyOn( + service as unknown as { createLegacyClient: () => unknown }, + 'createLegacyClient' + ); + + try { + for (const [productId, expectedTypeName] of [ + [35, 'Adventurer 5M'], + [36, 'Adventurer 5M Pro'], + [38, 'AD5X'], + ] as const) { + spy.mockClear(); + + const result = await service.createTemporaryConnection( + { ...basePrinter, productId }, + 50, + 1 + ); + + expect(spy).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(result.typeName).toBe(expectedTypeName); + expect(result.printerInfo?.SerialNumber).toBe('SNCRE5PRO001'); + } + } finally { + spy.mockRestore(); + } + }); + + it('still runs the TCP probe when no product ID is present (legacy fallback)', async () => { + // Genuine legacy printers — and manual/headless connects that named no model — + // have no product ID, so type detection must still go over TCP. + const fakeClient = { + initControl: jest.fn(async () => false), + dispose: jest.fn(async () => undefined), + }; + const spy = jest + .spyOn( + service as unknown as { createLegacyClient: () => unknown }, + 'createLegacyClient' + ) + .mockReturnValue(fakeClient); + + try { + const result = await service.createTemporaryConnection( + { ...basePrinter, productId: undefined }, + 50, + 1 + ); + + expect(spy).toHaveBeenCalled(); + expect(result.success).toBe(false); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/services/ConnectionEstablishmentService.ts b/src/services/ConnectionEstablishmentService.ts index cedc868..af0cccb 100644 --- a/src/services/ConnectionEstablishmentService.ts +++ b/src/services/ConnectionEstablishmentService.ts @@ -33,6 +33,7 @@ import { getConnectionErrorMessage, getModelDisplayName, isHttpOnlyModel, + NEW_API_PRODUCT_IDS, } from '../utils/PrinterUtils'; interface PortMutableFlashForgeClient { @@ -133,13 +134,18 @@ export class ConnectionEstablishmentService extends EventEmitter { ): Promise { this.emit('temporary-connection-started', printer); - // HTTP-only models (Creator 5 / 5 Pro) run no legacy TCP server, so the usual - // TCP probe can't work. When discovery's USB product ID identifies such a model, - // synthesize the type info from the discovery packet and skip the TCP probe. + // Any printer whose discovery USB product ID is a known new-API model is typed + // from that ID alone. The same broadcast also carries the serial (0x92) and name + // (0x00), and FiveMClient.initialize() later supplies the authoritative capability + // flags plus reachability — so the legacy TCP probe contributes nothing here and is + // skipped for ALL modern printers (5M / 5M Pro / AD5X / Creator 5 / 5 Pro). It is + // also impossible for the HTTP-only Creator 5 series, which runs no TCP server. + // Printers with no product ID (genuine legacy, or manual/headless connects that + // supplied no model hint) fall through to the TCP probe below. const idModelType = detectPrinterModelTypeFromId(printer.productId, ''); - if (isHttpOnlyModel(idModelType)) { + if (printer.productId !== undefined && printer.productId in NEW_API_PRODUCT_IDS) { const typeName = getModelDisplayName(idModelType); - console.log(`[Connection] HTTP-only model detected via product ID: ${typeName}`); + console.log(`[Connection] Modern model detected via product ID: ${typeName}`); this.emit('printer-type-detected', { typeName, familyInfo: detectPrinterFamily(typeName) }); return { success: true, @@ -161,7 +167,6 @@ export class ConnectionEstablishmentService extends EventEmitter { // Always use legacy API for type detection tempClient = this.createLegacyClient(printer); - // Wrap initControl in timeout console.log(`[Connection] Initializing control connection (timeout: ${timeout}ms)...`); const connected = await Promise.race([ tempClient.initControl(), @@ -184,7 +189,7 @@ export class ConnectionEstablishmentService extends EventEmitter { } if (attempt < retries) { - await this.delay(1000 * attempt); // Exponential backoff + await this.delay(1000 * attempt); // Linear backoff: delay grows by 1s per attempt continue; } @@ -199,7 +204,6 @@ export class ConnectionEstablishmentService extends EventEmitter { `[Connection] Control initialized, fetching printer info (timeout: ${timeout}ms)...` ); - // Get printer info with timeout const printerInfo = await Promise.race([ tempClient.getPrinterInfo(), new Promise((_, reject) => @@ -273,7 +277,6 @@ export class ConnectionEstablishmentService extends EventEmitter { } catch (error) { console.error(`[Connection] Attempt ${attempt} failed:`, error); - // Clean up temp client on error if (tempClient) { try { void tempClient.dispose(); @@ -308,7 +311,7 @@ export class ConnectionEstablishmentService extends EventEmitter { } /** - * Delay helper for exponential backoff + * Delay helper for linear backoff */ private async delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -379,7 +382,6 @@ export class ConnectionEstablishmentService extends EventEmitter { httpOnly, }); - // Validate that we have a valid serial number for FiveMClient if (!printer.serialNumber || printer.serialNumber.trim() === '') { console.error('Cannot create FiveMClient without valid serial number'); throw new Error('Serial number is required for dual API connection but was not provided'); @@ -452,7 +454,6 @@ export class ConnectionEstablishmentService extends EventEmitter { }; } catch (error) { console.error('Error in establishDualAPIConnection:', error); - // Clean up on failure try { await primaryClient.dispose(); } catch (disposeError) { @@ -483,7 +484,6 @@ export class ConnectionEstablishmentService extends EventEmitter { primaryClient: tempInfo.printerInfo._reuseableClient as FlashForgeClient, }; } else { - // Create new legacy connection const primaryClient = this.createLegacyClient(printer); const connected = await primaryClient.initControl(); @@ -519,7 +519,6 @@ export class ConnectionEstablishmentService extends EventEmitter { secondaryClient: FlashForgeClient | null, clientType?: string ): Promise { - // Send logout to legacy clients before disposal if (clientType === 'legacy' && primaryClient) { await this.sendLogoutCommand(primaryClient as FlashForgeClient); await new Promise((resolve) => setTimeout(resolve, 200)); // Give time to process @@ -530,7 +529,6 @@ export class ConnectionEstablishmentService extends EventEmitter { await new Promise((resolve) => setTimeout(resolve, 200)); } - // Dispose clients if (primaryClient) { try { void primaryClient.dispose(); @@ -551,7 +549,6 @@ export class ConnectionEstablishmentService extends EventEmitter { } } -// Export singleton getter function export const getConnectionEstablishmentService = (): ConnectionEstablishmentService => { return ConnectionEstablishmentService.getInstance(); }; diff --git a/src/services/ConnectionStateManager.ts b/src/services/ConnectionStateManager.ts index 58ca12e..2b70ee2 100644 --- a/src/services/ConnectionStateManager.ts +++ b/src/services/ConnectionStateManager.ts @@ -63,7 +63,7 @@ export class ConnectionStateManager extends EventEmitter { /** * Set state to connecting for a specific context * - * @param contextId - Context ID for this connection + * @param contextId - Context ID * @param printer - Printer info */ public setConnecting(contextId: string, printer: { name: string; ipAddress: string }): void { @@ -79,7 +79,6 @@ export class ConnectionStateManager extends EventEmitter { this.contextStates.set(contextId, state); - // Update context manager this.contextManager.updateConnectionState(contextId, 'connecting'); this.emit('state-changed', { contextId, state: 'connecting', printer }); @@ -88,7 +87,7 @@ export class ConnectionStateManager extends EventEmitter { /** * Set state to connected with client instances and printer details * - * @param contextId - Context ID for this connection + * @param contextId - Context ID * @param details - Printer details * @param primaryClient - Primary API client * @param secondaryClient - Optional secondary API client @@ -111,7 +110,6 @@ export class ConnectionStateManager extends EventEmitter { this.contextStates.set(contextId, state); - // Update context manager this.contextManager.updateConnectionState(contextId, 'connected'); this.emit('state-changed', { contextId, state: 'connected', details }); @@ -120,16 +118,14 @@ export class ConnectionStateManager extends EventEmitter { /** * Set state to disconnected and clear client references * - * @param contextId - Context ID for this disconnection + * @param contextId - Context ID */ public setDisconnected(contextId: string): void { const existingState = this.contextStates.get(contextId); const previousDetails = existingState?.details; - // Update context manager this.contextManager.updateConnectionState(contextId, 'disconnected'); - // Remove from map this.contextStates.delete(contextId); this.emit('state-changed', { contextId, state: 'disconnected', previousDetails }); @@ -292,7 +288,7 @@ export class ConnectionStateManager extends EventEmitter { /** * Dispose client connections for a specific context * - * @param contextId - Context ID to dispose clients for + * @param contextId - Context ID */ public async disposeClientsForContext(contextId: string): Promise { const state = this.contextStates.get(contextId); @@ -324,7 +320,7 @@ export class ConnectionStateManager extends EventEmitter { /** * Clear state and dispose resources for a specific context * - * @param contextId - Context ID to clear + * @param contextId - Context ID */ public async clearContext(contextId: string): Promise { await this.disposeClientsForContext(contextId); @@ -345,7 +341,6 @@ export class ConnectionStateManager extends EventEmitter { } } -// Export singleton getter function export const getConnectionStateManager = (): ConnectionStateManager => { return ConnectionStateManager.getInstance(); }; diff --git a/src/services/EnvironmentService.test.ts b/src/services/EnvironmentService.test.ts index 9d2bb93..ac4d3d0 100644 --- a/src/services/EnvironmentService.test.ts +++ b/src/services/EnvironmentService.test.ts @@ -24,7 +24,6 @@ describe('EnvironmentService', () => { let service: EnvironmentService; beforeEach(() => { - // Reset environment process.env = { ...mockEnv }; service = new EnvironmentService(); }); @@ -68,12 +67,10 @@ describe('EnvironmentService', () => { const packagedService = new EnvironmentService(); expect(packagedService.isPackaged()).toBe(true); - // Cleanup processWithPkg.pkg = undefined; }); it('should not detect packaged environment when no indicators present', () => { - // Ensure no pkg indicators delete process.env.PKG_EXECPATH; processWithPkg.pkg = undefined; @@ -169,7 +166,6 @@ describe('EnvironmentService', () => { const mockCwd = '/mock/app'; jest.spyOn(process, 'cwd').mockReturnValue(mockCwd); - // This test verifies the warning logic exists // In real scenarios, fs.existsSync would check the path const devService = new EnvironmentService(); const staticPath = devService.getWebUIStaticPath(); diff --git a/src/services/EnvironmentService.ts b/src/services/EnvironmentService.ts index 88e165f..e6da799 100644 --- a/src/services/EnvironmentService.ts +++ b/src/services/EnvironmentService.ts @@ -25,7 +25,6 @@ export class EnvironmentService { private readonly _isPackaged: boolean; constructor() { - // Detect if running as a pkg-bundled binary // In pkg binaries, __dirname points to a snapshot filesystem path // Also check for the PKG_EXECPATH environment variable which pkg sets this._isPackaged = this.detectPackagedEnvironment(); @@ -140,7 +139,6 @@ export class EnvironmentService { // In development or running via node directly, use process.cwd() const devStaticPath = path.join(process.cwd(), 'dist/webui/static'); - // Verify the path exists for better error messages if (!fs.existsSync(devStaticPath)) { console.warn(`[EnvironmentService] Static path not found: ${devStaticPath}`); console.warn('[EnvironmentService] Did you run "npm run build" first?'); diff --git a/src/services/FileManagerService.ts b/src/services/FileManagerService.ts new file mode 100644 index 0000000..5592a04 --- /dev/null +++ b/src/services/FileManagerService.ts @@ -0,0 +1,864 @@ +/** + * @fileoverview SFTP-backed file management service for FlashForge printers. + * + * Provides listing, deletion, renaming, and thumbnail retrieval for print files + * stored on the printer's internal flash and on plugged-in USB drives. Works over + * the SSH/SFTP access provisioned by the flashforge-easyssh USB script: + * - AD5X: files in /usr/data/gcodes, thumbnail caches in /usr/data/gcodes/thum + * and /usr/data/gcodes/3mf/thum, USB drives mounted under /mnt. + * - Adventurer 5M / 5M Pro: files in /data (flat), thumbnails in + * /data/uploadThumbnail, USB drives mounted under /mnt/usb. + * + * Thumbnail resolution order: local ThumbnailCacheService -> printer-side + * thumbnail cache PNGs -> ranged-read extraction of Metadata/plate_1(_small).png + * from .3mf archives (OrcaSlicer zips) without downloading the whole file. + * Fetched thumbnails are persisted into the shared ThumbnailCacheService so the + * job picker and WebUI benefit from the same cache. + * + * Connections are pooled through the shared SSHConnectionManager under + * 'file-manager:' keys, using the centralized per-printer SSH + * settings store (SSHSettingsService; easyssh defaults root / flashforge). + * + * Key exports: + * - FileManagerService / getFileManagerService(): singleton service + * - FileManagerTarget: printer identity required for operations + */ + +import * as zlib from 'zlib'; +import type { SFTPWrapper } from 'ssh2'; +import type { + FileManagerCapabilities, + FileManagerDeleteOutcome, + FileManagerDeleteResult, + FileManagerListing, + FileManagerRenameResult, + FileManagerStorageKind, + FileManagerThumbnailResult, + PrinterFileEntry, +} from '../types/file-manager'; +import { PRINTABLE_FILE_EXTENSIONS } from '../types/file-manager'; +import type { PrinterModelType } from '../types/printer-backend'; +import { getSSHConnectionManager } from './calibration/ssh'; +import { getSSHSettingsService } from './SSHSettingsService'; +import { getThumbnailCacheService } from './ThumbnailCacheService'; + +/** Printer identity needed to perform file manager operations. */ +export interface FileManagerTarget { + readonly contextId: string; + readonly ipAddress: string; + readonly serialNumber: string; + readonly printerName: string; + readonly modelType?: PrinterModelType; +} + +/** Per-family filesystem layout on the printer. */ +interface FamilyProfile { + /** Directory holding printable files on internal storage. */ + readonly internalRoot: string; + /** Printer-side thumbnail cache directories (checked in order). */ + readonly thumbnailDirs: readonly string[]; + /** Base directory USB drives are mounted under. */ + readonly usbBase: string; +} + +const FIVE_M_PROFILE: FamilyProfile = { + internalRoot: '/data', + thumbnailDirs: ['/data/uploadThumbnail'], + usbBase: '/mnt/usb', +}; + +const FAMILY_PROFILES: Partial> = { + ad5x: { + internalRoot: '/usr/data/gcodes', + thumbnailDirs: ['/usr/data/gcodes/thum', '/usr/data/gcodes/3mf/thum'], + usbBase: '/mnt', + }, + 'adventurer-5m': FIVE_M_PROFILE, + 'adventurer-5m-pro': FIVE_M_PROFILE, +}; + +/** Extensions surfaced in listings (lowercase, with dot). */ +const PRINTABLE_EXTENSIONS = new Set(PRINTABLE_FILE_EXTENSIONS); + +/** Filesystem types that are never USB drives. */ +const NON_USB_FS_TYPES = new Set([ + 'proc', + 'sysfs', + 'devtmpfs', + 'devpts', + 'tmpfs', + 'functionfs', + 'cgroup', + 'cgroup2', + 'debugfs', + 'configfs', + 'overlay', + 'squashfs', + 'ramfs', +]); + +/** Skip .3mf thumbnail extraction when the embedded PNG is unreasonably large. */ +const MAX_EMBEDDED_THUMBNAIL_BYTES = 5 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// Promisified SFTP helpers +// --------------------------------------------------------------------------- + +interface SftpDirEntry { + readonly filename: string; + readonly longname: string; + readonly attrs: { readonly size: number; readonly mtime: number }; +} + +function sftpReaddir(sftp: SFTPWrapper, dir: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(dir, (err, list) => (err ? reject(err) : resolve(list as unknown as SftpDirEntry[]))); + }); +} + +function sftpReadFile(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readFile(path, (err: Error | undefined, data: Buffer) => (err ? reject(err) : resolve(data))); + }); +} + +function sftpUnlink(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(path, (err) => (err ? reject(err) : resolve())); + }); +} + +function sftpRename(sftp: SFTPWrapper, oldPath: string, newPath: string): Promise { + return new Promise((resolve, reject) => { + sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve())); + }); +} + +function sftpStatSize(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve) => { + sftp.stat(path, (err, stats) => resolve(err ? null : stats.size)); + }); +} + +function sftpExists(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve) => { + sftp.stat(path, (err) => resolve(!err)); + }); +} + +/** Read an exact byte range from a remote file. */ +function sftpReadRange(sftp: SFTPWrapper, path: string, start: number, length: number): Promise { + return new Promise((resolve, reject) => { + sftp.open(path, 'r', (openErr, handle) => { + if (openErr) { + reject(openErr); + return; + } + + const buffer = Buffer.alloc(length); + let filled = 0; + + const finish = (error: Error | null): void => { + sftp.close(handle, () => { + if (error) { + reject(error); + } else { + resolve(buffer.subarray(0, filled)); + } + }); + }; + + const readChunk = (): void => { + if (filled >= length) { + finish(null); + return; + } + sftp.read(handle, buffer, filled, length - filled, start + filled, (readErr, bytesRead) => { + if (readErr) { + finish(readErr); + return; + } + if (bytesRead <= 0) { + finish(null); // EOF + return; + } + filled += bytesRead; + readChunk(); + }); + }; + + readChunk(); + }); + }); +} + +// --------------------------------------------------------------------------- +// Minimal remote-zip reader (for extracting thumbnails from .3mf archives) +// --------------------------------------------------------------------------- + +interface ZipEntry { + readonly name: string; + readonly compressionMethod: number; + readonly compressedSize: number; + readonly localHeaderOffset: number; +} + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_DIR_SIGNATURE = 0x02014b50; +const LOCAL_HEADER_SIGNATURE = 0x04034b50; +/** EOCD record is 22 bytes plus up to 65535 bytes of zip comment. */ +const EOCD_SEARCH_SPAN = 22 + 65535; + +/** Parse the central directory of a remote zip using ranged reads. */ +async function readRemoteZipEntries(sftp: SFTPWrapper, path: string, fileSize: number): Promise { + const tailLength = Math.min(fileSize, EOCD_SEARCH_SPAN); + const tail = await sftpReadRange(sftp, path, fileSize - tailLength, tailLength); + + let eocdOffset = -1; + for (let i = tail.length - 22; i >= 0; i--) { + if (tail.readUInt32LE(i) === EOCD_SIGNATURE) { + eocdOffset = i; + break; + } + } + if (eocdOffset < 0) { + throw new Error('Not a zip archive (no end-of-central-directory record)'); + } + + const centralDirSize = tail.readUInt32LE(eocdOffset + 12); + const centralDirOffset = tail.readUInt32LE(eocdOffset + 16); + if (centralDirOffset === 0xffffffff || centralDirSize === 0xffffffff) { + throw new Error('Zip64 archives are not supported for thumbnail extraction'); + } + + const centralDir = await sftpReadRange(sftp, path, centralDirOffset, centralDirSize); + const entries: ZipEntry[] = []; + let cursor = 0; + + while (cursor + 46 <= centralDir.length) { + if (centralDir.readUInt32LE(cursor) !== CENTRAL_DIR_SIGNATURE) { + break; + } + const compressionMethod = centralDir.readUInt16LE(cursor + 10); + const compressedSize = centralDir.readUInt32LE(cursor + 20); + const nameLength = centralDir.readUInt16LE(cursor + 28); + const extraLength = centralDir.readUInt16LE(cursor + 30); + const commentLength = centralDir.readUInt16LE(cursor + 32); + const localHeaderOffset = centralDir.readUInt32LE(cursor + 42); + const name = centralDir.toString('utf8', cursor + 46, cursor + 46 + nameLength); + + entries.push({ name, compressionMethod, compressedSize, localHeaderOffset }); + cursor += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} + +/** Promise wrapper around zlib.inflateRaw for non-blocking decompression. */ +function inflateRawAsync(buffer: Buffer): Promise { + return new Promise((resolve, reject) => { + zlib.inflateRaw(buffer, (err, result) => (err ? reject(err) : resolve(result))); + }); +} + +/** Extract and decompress a single entry from a remote zip. */ +async function extractRemoteZipEntry(sftp: SFTPWrapper, path: string, entry: ZipEntry): Promise { + const localHeader = await sftpReadRange(sftp, path, entry.localHeaderOffset, 30); + if (localHeader.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) { + throw new Error('Corrupt zip local header'); + } + const nameLength = localHeader.readUInt16LE(26); + const extraLength = localHeader.readUInt16LE(28); + const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength; + + const compressed = await sftpReadRange(sftp, path, dataStart, entry.compressedSize); + if (entry.compressionMethod === 0) { + return compressed; // stored + } + if (entry.compressionMethod === 8) { + return inflateRawAsync(compressed); // deflate + } + throw new Error(`Unsupported zip compression method: ${entry.compressionMethod}`); +} + +/** Pick the best thumbnail entry from a .3mf archive (OrcaSlicer layout). */ +function pickThumbnailEntry(entries: readonly ZipEntry[]): ZipEntry | null { + const patterns = [ + /^Metadata\/plate_1_small\.png$/i, + /^Metadata\/plate_\d+_small\.png$/i, + /^Metadata\/plate_1\.png$/i, + /^Metadata\/plate_\d+\.png$/i, + /^Metadata\/thumbnail\.png$/i, + ]; + for (const pattern of patterns) { + const match = entries.find((entry) => pattern.test(entry.name)); + if (match && match.compressedSize <= MAX_EMBEDDED_THUMBNAIL_BYTES) { + return match; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Path / name helpers +// --------------------------------------------------------------------------- + +function baseName(remotePath: string): string { + const idx = remotePath.lastIndexOf('/'); + return idx >= 0 ? remotePath.slice(idx + 1) : remotePath; +} + +function dirName(remotePath: string): string { + const idx = remotePath.lastIndexOf('/'); + return idx > 0 ? remotePath.slice(0, idx) : '/'; +} + +function joinRemote(dir: string, name: string): string { + return dir.endsWith('/') ? `${dir}${name}` : `${dir}/${name}`; +} + +function fileExtension(name: string): string { + const idx = name.lastIndexOf('.'); + return idx > 0 ? name.slice(idx).toLowerCase() : ''; +} + +function isPrintableFile(name: string): boolean { + return PRINTABLE_EXTENSIONS.has(fileExtension(name)); +} + +/** Strip the final printable extension for thumbnail stem candidates. */ +function thumbnailStem(name: string): string { + return name.replace(/\.(3mf|gcode|gx|g)$/i, ''); +} + +/** Validate a user-supplied file name (single path segment, no traversal). */ +function isSafeFileName(name: string): boolean { + if (!name || name === '.' || name === '..') return false; + if (name.length > 200) return false; + if (name.includes('/') || name.includes('\\')) return false; + // Disallow control characters + for (let i = 0; i < name.length; i++) { + if (name.charCodeAt(i) < 0x20) return false; + } + return true; +} + +/** Ensure a path is the given root or nested under it. */ +function isUnderRoot(path: string, root: string): boolean { + if (!path.startsWith('/') || path.includes('..')) return false; + const normalizedRoot = root.endsWith('/') ? root.slice(0, -1) : root; + return path === normalizedRoot || path.startsWith(`${normalizedRoot}/`); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * SFTP-based file management for printers provisioned with flashforge-easyssh. + */ +export class FileManagerService { + private static instance: FileManagerService | null = null; + + /** Cached SFTP sessions keyed by SSH connection key. */ + private readonly sftpSessions = new Map(); + + private constructor() { + const sshManager = getSSHConnectionManager(); + sshManager.on('connection-closed', (id: string) => { + if (id.startsWith('file-manager:')) { + this.sftpSessions.delete(id); + } + }); + } + + public static getInstance(): FileManagerService { + if (!FileManagerService.instance) { + FileManagerService.instance = new FileManagerService(); + } + return FileManagerService.instance; + } + + /** Whether the model family supports SFTP file management. */ + public static isModelSupported(modelType?: PrinterModelType): boolean { + return !!modelType && !!FAMILY_PROFILES[modelType]; + } + + private getProfile(target: FileManagerTarget): FamilyProfile { + const profile = target.modelType ? FAMILY_PROFILES[target.modelType] : undefined; + if (!profile) { + throw new Error(`File management is not supported for this printer model (${target.modelType ?? 'unknown'})`); + } + return profile; + } + + private connectionKey(target: FileManagerTarget): string { + return `file-manager:${target.contextId}`; + } + + /** Ensure an SSH connection exists for the target and return its key. */ + private async ensureConnection(target: FileManagerTarget): Promise { + const sshManager = getSSHConnectionManager(); + const key = this.connectionKey(target); + + if (sshManager.isConnected(key)) { + sshManager.touch(key); + return key; + } + + this.sftpSessions.delete(key); + // Credentials come from the centralized per-printer SSH settings store + const config = await getSSHSettingsService().buildConnectionConfig(target.serialNumber, target.ipAddress); + await sshManager.connect(key, config); + return key; + } + + /** Get (or open) an SFTP session on the pooled SSH connection. */ + private async getSftp(target: FileManagerTarget): Promise { + const key = await this.ensureConnection(target); + const cached = this.sftpSessions.get(key); + if (cached) { + return cached; + } + + const connection = getSSHConnectionManager().getConnection(key); + if (!connection) { + throw new Error('SSH connection unavailable'); + } + + const sftp = await new Promise((resolve, reject) => { + connection.client.sftp((err, session) => (err ? reject(err) : resolve(session))); + }); + + sftp.on('close', () => { + if (this.sftpSessions.get(key) === sftp) { + this.sftpSessions.delete(key); + } + }); + + this.sftpSessions.set(key, sftp); + return sftp; + } + + /** Run an SFTP operation, retrying once with a fresh connection on failure. */ + private async withSftp(target: FileManagerTarget, operation: (sftp: SFTPWrapper) => Promise): Promise { + try { + const sftp = await this.getSftp(target); + return await operation(sftp); + } catch (error) { + // Session may be stale (printer rebooted, connection dropped) - reconnect once + const key = this.connectionKey(target); + this.sftpSessions.delete(key); + await getSSHConnectionManager().disconnect(key); + const sftp = await this.getSftp(target); + console.warn(`[FileManager] Retried SFTP operation after error: ${errorMessage(error)}`); + return await operation(sftp); + } + } + + /** Detect USB drive mount points by parsing /proc/mounts. */ + private async detectUsbMounts(target: FileManagerTarget, profile: FamilyProfile): Promise { + const key = await this.ensureConnection(target); + const result = await getSSHConnectionManager().executeCommand(key, 'cat /proc/mounts'); + if (!result.success) { + throw new Error(result.error || result.stderr || 'Failed to read /proc/mounts'); + } + + const usbBase = profile.usbBase.endsWith('/') ? profile.usbBase.slice(0, -1) : profile.usbBase; + const mounts: string[] = []; + + for (const line of result.stdout.split('\n')) { + const fields = line.trim().split(/\s+/); + if (fields.length < 3) continue; + // /proc/mounts escapes spaces in mount points as \040 + const mountPoint = fields[1].replace(/\\040/g, ' ').replace(/\\011/g, '\t'); + const fsType = fields[2]; + if (NON_USB_FS_TYPES.has(fsType)) continue; + if (mountPoint === usbBase || mountPoint.startsWith(`${usbBase}/`)) { + mounts.push(mountPoint); + } + } + + // De-duplicate while preserving order (bind mounts can repeat) + return Array.from(new Set(mounts)); + } + + /** + * Probe printer support and USB presence. + */ + public async getCapabilities(target: FileManagerTarget): Promise { + if (!FileManagerService.isModelSupported(target.modelType)) { + return { + supported: false, + reason: + 'File management requires SSH/SFTP access, which is available on AD5X and Adventurer 5M series printers provisioned with the FlashForge easy-SSH setup.', + printerName: target.printerName, + usbPresent: false, + usbMounts: [], + }; + } + + const profile = this.getProfile(target); + try { + const usbMounts = await this.detectUsbMounts(target, profile); + return { + supported: true, + printerName: target.printerName, + usbPresent: usbMounts.length > 0, + usbMounts, + }; + } catch (error) { + return { + supported: true, + printerName: target.printerName, + usbPresent: false, + usbMounts: [], + error: `Could not reach the printer over SSH: ${errorMessage(error)}`, + }; + } + } + + /** + * List printable files (and, on USB, directories) at a storage location. + * + * @param path - Absolute directory to list. Empty string means the storage + * root (internal root, the sole USB mount, or a synthetic + * listing of mounts when several USB drives are present). + */ + public async listFiles( + target: FileManagerTarget, + storage: FileManagerStorageKind, + path: string + ): Promise { + try { + const profile = this.getProfile(target); + + if (storage === 'internal') { + const root = profile.internalRoot; + const entries = await this.withSftp(target, async (sftp) => { + const list = await sftpReaddir(sftp, root); + return list + .filter((entry) => !entry.longname.startsWith('d') && isPrintableFile(entry.filename)) + .map( + (entry): PrinterFileEntry => ({ + name: entry.filename, + path: joinRemote(root, entry.filename), + size: entry.attrs.size, + modifiedAt: entry.attrs.mtime, + isDirectory: false, + }) + ); + }); + return { success: true, storage, path: root, rootPath: root, entries: sortEntries(entries) }; + } + + // USB storage + const usbMounts = await this.detectUsbMounts(target, profile); + if (usbMounts.length === 0) { + return { success: false, storage, path: '', rootPath: '', entries: [], error: 'No USB drive detected' }; + } + + // Multiple drives with no explicit path: synthesize a root listing of mounts + if (!path && usbMounts.length > 1) { + const entries = usbMounts.map( + (mount): PrinterFileEntry => ({ + name: baseName(mount) || mount, + path: mount, + size: 0, + modifiedAt: 0, + isDirectory: true, + }) + ); + return { success: true, storage, path: '', rootPath: '', entries }; + } + + const listDir = path || usbMounts[0]; + const withinMount = usbMounts.some((mount) => isUnderRoot(listDir, mount)); + if (!withinMount) { + return { success: false, storage, path: listDir, rootPath: '', entries: [], error: 'Invalid USB path' }; + } + const rootPath = usbMounts.length > 1 ? '' : usbMounts[0]; + + const entries = await this.withSftp(target, async (sftp) => { + const list = await sftpReaddir(sftp, listDir); + return list + .filter((entry) => { + if (entry.filename.startsWith('.')) return false; + const isDir = entry.longname.startsWith('d'); + return isDir || isPrintableFile(entry.filename); + }) + .map( + (entry): PrinterFileEntry => ({ + name: entry.filename, + path: joinRemote(listDir, entry.filename), + size: entry.attrs.size, + modifiedAt: entry.attrs.mtime, + isDirectory: entry.longname.startsWith('d'), + }) + ); + }); + + return { success: true, storage, path: listDir, rootPath, entries: sortEntries(entries) }; + } catch (error) { + return { success: false, storage, path, rootPath: '', entries: [], error: errorMessage(error) }; + } + } + + /** + * Delete one or more files. For internal storage the printer-side sidecar + * thumbnails are removed too, and the local thumbnail cache is invalidated. + */ + public async deleteFiles( + target: FileManagerTarget, + storage: FileManagerStorageKind, + paths: readonly string[] + ): Promise { + try { + const profile = this.getProfile(target); + const outcomes: FileManagerDeleteOutcome[] = []; + + await this.withSftp(target, async (sftp) => { + for (const path of paths) { + const validationError = await this.validateMutationPath(target, profile, storage, path); + if (validationError) { + outcomes.push({ path, success: false, error: validationError }); + continue; + } + + try { + await sftpUnlink(sftp, path); + if (storage === 'internal') { + await this.deleteSidecarThumbnails(sftp, profile, baseName(path)); + } + await getThumbnailCacheService().invalidate(target.serialNumber, baseName(path)); + outcomes.push({ path, success: true }); + } catch (error) { + outcomes.push({ path, success: false, error: errorMessage(error) }); + } + } + }); + + return { success: outcomes.every((outcome) => outcome.success), outcomes }; + } catch (error) { + return { success: false, outcomes: [], error: errorMessage(error) }; + } + } + + /** + * Rename a file in place (same directory, same extension). Printer-side + * sidecar thumbnails follow the rename and the local cache entry is migrated. + */ + public async renameFile( + target: FileManagerTarget, + storage: FileManagerStorageKind, + path: string, + newName: string + ): Promise { + try { + const profile = this.getProfile(target); + + const validationError = await this.validateMutationPath(target, profile, storage, path); + if (validationError) { + return { success: false, error: validationError }; + } + if (!isSafeFileName(newName)) { + return { success: false, error: 'Invalid file name' }; + } + + const oldName = baseName(path); + if (fileExtension(newName) !== fileExtension(oldName)) { + return { success: false, error: `File extension must remain ${fileExtension(oldName)}` }; + } + if (newName === oldName) { + return { success: true, newPath: path }; + } + + const newPath = joinRemote(dirName(path), newName); + + return await this.withSftp(target, async (sftp) => { + if (await sftpExists(sftp, newPath)) { + return { success: false, error: 'A file with that name already exists' }; + } + + await sftpRename(sftp, path, newPath); + + if (storage === 'internal') { + await this.renameSidecarThumbnails(sftp, profile, oldName, newName); + } + + // Migrate the local thumbnail cache entry to the new name + const cache = getThumbnailCacheService(); + const cached = await cache.get(target.serialNumber, oldName); + if (cached.success && cached.data) { + await cache.set(target.serialNumber, newName, cached.data); + } + await cache.invalidate(target.serialNumber, oldName); + + return { success: true, newPath }; + }); + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + } + + /** + * Fetch a thumbnail for a file: local cache -> printer thumbnail cache dirs + * -> embedded .3mf archive extraction. Successful fetches are cached locally. + */ + public async getThumbnail( + target: FileManagerTarget, + storage: FileManagerStorageKind, + path: string + ): Promise { + const name = baseName(path); + + try { + const cache = getThumbnailCacheService(); + const cached = await cache.get(target.serialNumber, name); + if (cached.success && cached.data) { + return { success: true, thumbnail: cached.data }; + } + + const profile = this.getProfile(target); + + const thumbnail = await this.withSftp(target, async (sftp) => { + // 1) Printer-side thumbnail cache (internal storage files only) + if (storage === 'internal') { + const stems = [thumbnailStem(name), name]; + for (const dir of profile.thumbnailDirs) { + for (const stem of stems) { + try { + const data = await sftpReadFile(sftp, joinRemote(dir, `${stem}.png`)); + if (data.length > 0) { + return data; + } + } catch { + // Candidate missing - try the next one + } + } + } + } + + // 2) Embedded thumbnail inside the .3mf archive (ranged reads) + if (fileExtension(name) === '.3mf') { + const size = await sftpStatSize(sftp, path); + if (size && size > 0) { + const entries = await readRemoteZipEntries(sftp, path, size); + const entry = pickThumbnailEntry(entries); + if (entry) { + return await extractRemoteZipEntry(sftp, path, entry); + } + } + } + + return null; + }); + + if (!thumbnail) { + return { success: false, error: 'No thumbnail available' }; + } + + const base64 = thumbnail.toString('base64'); + await cache.set(target.serialNumber, name, base64); + return { success: true, thumbnail: base64 }; + } catch (error) { + return { success: false, error: errorMessage(error) }; + } + } + + /** Close the pooled connection for a context (called on context removal). */ + public async disconnect(contextId: string): Promise { + const key = `file-manager:${contextId}`; + this.sftpSessions.delete(key); + await getSSHConnectionManager().disconnect(key); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** Validate that a mutation path is a safe file path inside the storage root. */ + private async validateMutationPath( + target: FileManagerTarget, + profile: FamilyProfile, + storage: FileManagerStorageKind, + path: string + ): Promise { + if (!isSafeFileName(baseName(path))) { + return 'Invalid file path'; + } + + if (storage === 'internal') { + if (!isUnderRoot(path, profile.internalRoot) || dirName(path) !== profile.internalRoot) { + return 'Path is outside the printer file directory'; + } + return null; + } + + const usbMounts = await this.detectUsbMounts(target, profile); + if (!usbMounts.some((mount) => isUnderRoot(path, mount))) { + return 'Path is outside the USB drive'; + } + return null; + } + + /** Remove printer-side sidecar thumbnail PNGs for a deleted file. */ + private async deleteSidecarThumbnails(sftp: SFTPWrapper, profile: FamilyProfile, name: string): Promise { + const stems = new Set([thumbnailStem(name), name]); + for (const dir of profile.thumbnailDirs) { + for (const stem of stems) { + try { + await sftpUnlink(sftp, joinRemote(dir, `${stem}.png`)); + } catch { + // Sidecar not present - fine + } + } + } + } + + /** Rename printer-side sidecar thumbnail PNGs to follow a file rename. */ + private async renameSidecarThumbnails( + sftp: SFTPWrapper, + profile: FamilyProfile, + oldName: string, + newName: string + ): Promise { + const oldStems = [thumbnailStem(oldName), oldName]; + const newStems = [thumbnailStem(newName), newName]; + + for (const dir of profile.thumbnailDirs) { + for (let i = 0; i < oldStems.length; i++) { + const oldPng = joinRemote(dir, `${oldStems[i]}.png`); + const newPng = joinRemote(dir, `${newStems[i]}.png`); + try { + if ((await sftpExists(sftp, oldPng)) && !(await sftpExists(sftp, newPng))) { + await sftpRename(sftp, oldPng, newPng); + } + } catch { + // Best effort - the printer regenerates thumbnails on rescan + } + } + } + } +} + +/** Sort listings: directories first, then case-insensitive by name. */ +function sortEntries(entries: PrinterFileEntry[]): PrinterFileEntry[] { + return entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); +} + +/** + * Get the FileManagerService singleton. + */ +export function getFileManagerService(): FileManagerService { + return FileManagerService.getInstance(); +} diff --git a/src/services/Go2rtcBinaryManager.ts b/src/services/Go2rtcBinaryManager.ts index 4ff3e06..e0622f3 100644 --- a/src/services/Go2rtcBinaryManager.ts +++ b/src/services/Go2rtcBinaryManager.ts @@ -55,7 +55,14 @@ export class Go2rtcBinaryManager { /** Environment service for packaged/runtime paths. */ private readonly environmentService = getEnvironmentService(); - private constructor() {} + private constructor() { + // The startup-failure and hard-deadline paths end through process.exit(), which + // skips the graceful shutdown. Kill the child synchronously on exit so go2rtc is + // not orphaned holding its ports. + process.once('exit', () => { + this.process?.kill(); + }); + } /** * Get the singleton instance. diff --git a/src/services/MultiContextNotificationCoordinator.ts b/src/services/MultiContextNotificationCoordinator.ts index 1b3209e..34cfaf1 100644 --- a/src/services/MultiContextNotificationCoordinator.ts +++ b/src/services/MultiContextNotificationCoordinator.ts @@ -38,10 +38,6 @@ import type { PrinterStatus } from '../types/polling'; import { EventEmitter } from '../utils/EventEmitter'; import { getMultiContextPrintStateMonitor } from './MultiContextPrintStateMonitor'; -// ============================================================================ -// TYPES -// ============================================================================ - /** * Notification event types */ @@ -70,10 +66,6 @@ interface NotificationCoordinatorEventMap extends Record { 'print-notification': [PrintNotificationEvent]; } -// ============================================================================ -// MULTI-CONTEXT NOTIFICATION COORDINATOR -// ============================================================================ - /** * Coordinates notification events across all printer contexts */ @@ -92,7 +84,6 @@ export class MultiContextNotificationCoordinator extends EventEmitter { this.forwardNotification('print-started', event); }); @@ -126,7 +117,6 @@ export class MultiContextNotificationCoordinator extends EventEmitter { this.handleContextSwitch(event.contextId, event.previousContextId); }); - // Listen for context removal to clean up polling services this.contextManager.on('context-removed', (event: ContextRemovedEvent) => { this.stopPollingForContext(event.contextId); }); @@ -141,7 +139,6 @@ export class MultiContextPollingCoordinator extends EventEmitter = { intervalMs, maxRetries: 3, retryDelayMs: 2000, }; - // Create and configure polling service const pollingService = new PrinterPollingService(config); // Create a wrapper that adapts the context-aware backend to the polling service's interface @@ -235,13 +226,10 @@ export class MultiContextPollingCoordinator extends EventEmitter[0] ); - // Set up event forwarding with context identification this.setupPollingServiceEvents(contextId, pollingService); - // Store and start the polling service this.pollingServices.set(contextId, pollingService); - // Update context manager reference this.contextManager.updatePollingService(contextId, pollingService); const started = pollingService.start(); @@ -269,11 +257,9 @@ export class MultiContextPollingCoordinator extends EventEmitter { this.emit('polling-data', contextId, data); }); - // Forward polling errors with context ID pollingService.on(POLLING_EVENTS.POLLING_ERROR, (errorData: { error: string }) => { this.emit('polling-error', contextId, errorData.error); }); diff --git a/src/services/MultiContextPrintStateMonitor.ts b/src/services/MultiContextPrintStateMonitor.ts index d881b19..cf352f2 100644 --- a/src/services/MultiContextPrintStateMonitor.ts +++ b/src/services/MultiContextPrintStateMonitor.ts @@ -68,7 +68,6 @@ export class MultiContextPrintStateMonitor extends EventEmitter error: string; }, ]; + 'station-deduction': [ + { + contextId: string; + summary: DeductionSummary; + }, + ]; } -// ============================================================================ -// MULTI-CONTEXT SPOOLMAN TRACKER -// ============================================================================ +/** Either tracker flavor owned by the coordinator. */ +export type ContextUsageTracker = SpoolmanUsageTracker | StationUsageTracker; /** * Manages Spoolman usage trackers for all printer contexts */ export class MultiContextSpoolmanTracker extends EventEmitter { - private readonly trackers = new Map(); + private readonly trackers = new Map(); private isInitialized = false; /** @@ -82,9 +93,11 @@ export class MultiContextSpoolmanTracker extends EventEmitter { this.removeTrackerForContext(event.contextId); + // The tracked job itself stays in the store: a printer that reconnects + // mid-print is still tracked under its serial. + forgetStationContext(event.contextId); }); this.isInitialized = true; @@ -95,46 +108,87 @@ export class MultiContextSpoolmanTracker extends EventEmitter { + const config = getConfigManager().getConfig(); + if (!config.SpoolmanEnabled || !config.SpoolmanServerUrl) { + return null; + } + return new SpoolmanService(config.SpoolmanServerUrl); + }, + integrationService: getSpoolmanIntegrationService(), + onSummary: (summary) => { + this.emit('station-deduction', { contextId, summary }); + }, + }); + tracker.setMonitors(printStateMonitor, pollingService); + this.trackers.set(contextId, tracker); + console.log(`[MultiContextSpoolmanTracker] Created station tracker for context ${contextId}`); + } + /** * Setup event forwarding from individual tracker to global listeners */ private setupTrackerEventForwarding(tracker: SpoolmanUsageTracker): void { const contextId = tracker.getContextId(); - // Forward usage-updated events tracker.on('usage-updated', (event) => { this.emit('usage-updated', event); }); - // Forward usage-update-failed events tracker.on('usage-update-failed', (event) => { this.emit('usage-update-failed', event); }); @@ -144,7 +198,7 @@ export class MultiContextSpoolmanTracker extends EventEmitter { 'monitor-removed': [{ contextId: string }]; } -// ============================================================================ -// MULTI-CONTEXT TEMPERATURE MONITOR -// ============================================================================ - /** * Manages temperature monitoring services for all printer contexts */ @@ -92,7 +84,6 @@ export class MultiContextTemperatureMonitor extends EventEmitter { this.removeMonitorForContext(event.contextId); }); @@ -105,7 +96,7 @@ export class MultiContextTemperatureMonitor extends EventEmitter { this.emit('temperature-checked', event); }); - // Forward printer-cooled events monitor.on('printer-cooled', (event) => { this.emit('printer-cooled', event); }); - // Forward monitoring-started events monitor.on('monitoring-started', (event) => { this.emit('monitoring-started', event); }); - // Forward monitoring-stopped events monitor.on('monitoring-stopped', (event) => { this.emit('monitoring-stopped', event); }); @@ -172,7 +154,7 @@ export class MultiContextTemperatureMonitor extends EventEmitter { * Set the printer polling service to monitor */ public setPollingService(pollingService: PrinterPollingService): void { - // Remove listeners from old service if (this.pollingService) { this.removePollingServiceListeners(); } @@ -120,12 +119,10 @@ export class PrintStateMonitor extends EventEmitter { private setupPollingServiceListeners(): void { if (!this.pollingService) return; - // Listen for data updates this.pollingService.on('data-updated', (data: PollingData) => { void this.handlePollingDataUpdate(data); }); - // Listen for status updates this.pollingService.on('status-updated', (status: PrinterStatus) => { void this.handlePrinterStatusUpdate(status); }); @@ -161,19 +158,15 @@ export class PrintStateMonitor extends EventEmitter { const previousState = this.state.currentState; const currentState = status.state; - // Update current state this.state.currentState = currentState; - // Update job name tracking const currentJobName = status.currentJob?.fileName || null; this.state.currentJobName = currentJobName; - // Check for state transitions if (previousState !== currentState && previousState !== null) { await this.handleStateTransition(previousState, currentState, status); } - // Update previous state for next iteration this.state.previousState = currentState; } @@ -192,7 +185,6 @@ export class PrintStateMonitor extends EventEmitter { `[PrintStateMonitor] State change for ${this.contextId}: ${previousState} → ${currentState}` ); - // Emit generic state-changed event this.emit('state-changed', { contextId: this.contextId, previousState, @@ -201,7 +193,6 @@ export class PrintStateMonitor extends EventEmitter { timestamp, }); - // Emit specialized lifecycle events await this.detectPrintLifecycleEvents(previousState, currentState, status, timestamp); } @@ -214,7 +205,6 @@ export class PrintStateMonitor extends EventEmitter { status: PrinterStatus, timestamp: Date ): Promise { - // Print started: Transition TO an active printing state if (this.isActivePrintingState(currentState) && !this.isActivePrintingState(previousState)) { if (this.state.currentJobName) { this.emit('print-started', { @@ -227,7 +217,6 @@ export class PrintStateMonitor extends EventEmitter { } } - // Print completed: Transition TO "Completed" state if (currentState === 'Completed' && previousState !== 'Completed') { const jobName = this.state.currentJobName || 'Unknown'; this.emit('print-completed', { @@ -239,7 +228,6 @@ export class PrintStateMonitor extends EventEmitter { console.log(`[PrintStateMonitor] Print completed: ${jobName}`); } - // Print cancelled: Transition TO "Cancelled" state if (currentState === 'Cancelled' && previousState !== 'Cancelled') { this.emit('print-cancelled', { contextId: this.contextId, @@ -250,7 +238,6 @@ export class PrintStateMonitor extends EventEmitter { console.log(`[PrintStateMonitor] Print cancelled: ${this.state.currentJobName || 'Unknown'}`); } - // Print error: Transition TO "Error" state if (currentState === 'Error' && previousState !== 'Error') { this.emit('print-error', { contextId: this.contextId, diff --git a/src/services/PrinterDataTransformer.ts b/src/services/PrinterDataTransformer.ts index 537585d..d3bfef6 100644 --- a/src/services/PrinterDataTransformer.ts +++ b/src/services/PrinterDataTransformer.ts @@ -67,31 +67,25 @@ export class PrinterDataTransformer { return null; } - // Extract printer state const rawState = safeExtractString(backendData, 'printerState', 'unknown').toLowerCase(); const state = this.mapPrinterState(rawState); - // Extract temperatures const bedTemp = safeExtractNumber(backendData, 'bedTemperature', 0); const bedTarget = safeExtractNumber(backendData, 'bedTargetTemperature', 0); const nozzleTemp = safeExtractNumber(backendData, 'nozzleTemperature', 0); const nozzleTarget = safeExtractNumber(backendData, 'nozzleTargetTemperature', 0); - // Extract current job info const currentJobName = safeExtractString(backendData, 'currentJob', ''); const currentJob = this.extractCurrentJob(backendData, state, currentJobName); - // Extract additional info const nozzleSize = safeExtractString(backendData, 'nozzleSize', '0.4mm'); const filamentType = safeExtractString(backendData, 'filamentType', 'PLA'); const printSpeedAdjust = safeExtractNumber(backendData, 'printSpeedAdjust', 100); const zAxisCompensation = safeExtractNumber(backendData, 'zAxisCompensation', 0); - // Extract fan speeds const coolingFanSpeed = safeExtractNumber(backendData, 'coolingFanSpeed', 0); const chamberFanSpeed = safeExtractNumber(backendData, 'chamberFanSpeed', 0); - // Extract filtration status const tvoc = safeExtractNumber(backendData, 'tvoc', 0); const filtrationInfo = this.extractFiltrationStatus(backendData); @@ -107,7 +101,6 @@ export class PrinterDataTransformer { const chamberTemp = safeExtractNumber(backendData, 'chamberTemp', 0); const chamberTargetTemp = safeExtractNumber(backendData, 'chamberTargetTemp', 0); - // Extract cumulative stats const cumulativePrintTime = safeExtractNumber(backendData, 'cumulativePrintTime', 0); const cumulativeFilament = safeExtractNumber(backendData, 'cumulativeFilament', 0); @@ -262,33 +255,38 @@ export class PrinterDataTransformer { ); } else { // Integer format (0-100) from legacy PrintStatus.getPrintPercent() - progressPercentage = Math.min(rawProgress, 100); // Clamp to 100 + progressPercentage = Math.min(rawProgress, 100); console.log(`[DataTransformer] Using integer progress: ${progressPercentage}%`); } } - // Extract filament usage if available const filamentUsed = safeExtractNumber(backendData, 'estimatedRightLen', 0); const filamentWeight = safeExtractNumber(backendData, 'estimatedRightWeight', 0); - // Extract formatted ETA if available const printEta = safeExtractString(backendData, 'printEta', ''); - // Calculate start time from elapsed time + // The library supplies an absolute completion time (Date | null). Accept a + // string too, for safety across serialization boundaries. + const rawCompletionTime = backendData.completionTime; + const completionTime: Date | null = + rawCompletionTime instanceof Date + ? rawCompletionTime + : typeof rawCompletionTime === 'string' && rawCompletionTime.trim() !== '' + ? new Date(rawCompletionTime) + : null; + const startTime = new Date(Date.now() - printDuration * 1000); // Enhanced layer data processing const currentLayer = rawCurrentLayer > 0 ? rawCurrentLayer : null; const totalLayers = rawTotalLayers > 0 ? rawTotalLayers : null; - // Log layer information for debugging if (currentLayer !== null || totalLayers !== null) { console.log(`[DataTransformer] Layer progress: ${currentLayer}/${totalLayers}`); } - // Create progress object const progressData = { - percentage: progressPercentage, // Now using smart conversion + percentage: progressPercentage, currentLayer, totalLayers, timeRemaining: remainingTime > 0 ? remainingTime : null, // Already in minutes from backend @@ -297,9 +295,9 @@ export class PrinterDataTransformer { weightUsed: filamentWeight, lengthUsed: filamentUsed, formattedEta: printEta || undefined, + completionTime, }; - // Validate progress data for type safety if ( !this.validateJobProgress({ percentage: progressData.percentage, @@ -308,7 +306,6 @@ export class PrinterDataTransformer { }) ) { console.warn(`[DataTransformer] Invalid progress data for job: ${fileName}`); - // Use safe defaults for invalid data progressData.percentage = 0; progressData.currentLayer = null; progressData.totalLayers = null; @@ -372,7 +369,6 @@ export class PrinterDataTransformer { currentLayer: number | null; totalLayers: number | null; }): boolean { - // Validate percentage range if (progressData.percentage < 0 || progressData.percentage > 100) { console.warn( `[DataTransformer] Invalid progress percentage: ${progressData.percentage}% (expected 0-100)` @@ -380,7 +376,6 @@ export class PrinterDataTransformer { return false; } - // Validate layer data consistency if (progressData.currentLayer !== null && progressData.totalLayers !== null) { if (progressData.currentLayer > progressData.totalLayers) { console.warn( @@ -405,18 +400,15 @@ export class PrinterDataTransformer { return false; } - // Check required fields if (!status.state || !status.temperatures || !status.fans) { return false; } - // Validate temperature ranges const { bed, extruder } = status.temperatures; if (bed.current < 0 || bed.current > 150 || extruder.current < 0 || extruder.current > 350) { return false; } - // Validate fan speeds if ( status.fans.coolingFan < 0 || status.fans.coolingFan > 100 || @@ -474,5 +466,4 @@ export class PrinterDataTransformer { } } -// Export singleton instance export const printerDataTransformer = new PrinterDataTransformer(); diff --git a/src/services/PrinterDiscoveryService.ts b/src/services/PrinterDiscoveryService.ts index 9e601a0..0edf9fe 100644 --- a/src/services/PrinterDiscoveryService.ts +++ b/src/services/PrinterDiscoveryService.ts @@ -7,7 +7,7 @@ * - Discovery timeout and interval configuration * - Discovered printer data normalization * - Discovery state management (in-progress tracking) - * - Support for legacy and modern FlashForge UDP discovery packet layouts + * - Integration with ff-api's PrinterDiscovery * * Key exports: * - PrinterDiscoveryService class: Network discovery coordinator @@ -16,31 +16,36 @@ * This service encapsulates all network scanning logic, providing a simple interface * for discovering FlashForge printers on the local network. Used by ConnectionFlowManager * during the printer connection workflow to present available printers to the user. + * + * The UDP protocol itself (multicast/broadcast/loopback probing, and parsing of the + * 276-byte modern and 140-byte legacy response formats) lives in @ghosttypes/ff-api. + * This service only maps the library's result into the local DiscoveredPrinter shape. */ -import * as dgram from 'node:dgram'; -import { networkInterfaces } from 'node:os'; +import { type DiscoveredPrinter as FFDiscoveredPrinter, PrinterDiscovery } from '@ghosttypes/ff-api'; import { EventEmitter } from 'events'; import type { DiscoveredPrinter } from '../types/printer'; -const DISCOVERY_BIND_PORT = 18007; -const DISCOVERY_MESSAGE = Buffer.from([ - 0x77, 0x77, 0x77, 0x2e, 0x75, 0x73, 0x72, 0x22, 0x65, 0x36, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, -]); -const BROADCAST_DISCOVERY_PORT = 48899; -const MODERN_MULTICAST_PORT = 19000; -const LEGACY_MULTICAST_PORT = 8899; -const MODERN_DISCOVERY_PACKET_SIZE = 276; -const LEGACY_DISCOVERY_PACKET_SIZE = 140; -const LOOPBACK_ADDRESS = '127.0.0.1'; -const MULTICAST_ADDRESS = '225.0.0.9'; - -interface DiscoveryTarget { - readonly address: string; - readonly port: number; -} +/** + * Map a library discovery result into the local DiscoveredPrinter shape. + * + * `model` is intentionally left as 'Unknown': model resolution happens later in + * ConnectionEstablishmentService, which maps `productId` through NEW_API_PRODUCT_IDS + * to the local PrinterModelType. + */ +const toDiscoveredPrinter = (printer: FFDiscoveredPrinter): DiscoveredPrinter => ({ + name: printer.name || 'Unknown Printer', + ipAddress: printer.ipAddress, + serialNumber: printer.serialNumber || '', + commandPort: printer.commandPort, + eventPort: printer.eventPort, + // USB product ID identifies the model authoritatively (e.g. Creator 5 = 0x0028), + // which is essential for HTTP-only models that can't be TCP-probed. + productId: printer.productId, + model: 'Unknown', + status: 'Discovered', +}); /** * Service responsible for discovering printers on the network @@ -84,7 +89,15 @@ export class PrinterDiscoveryService extends EventEmitter { this.emit('discovery-started'); try { - const discoveredPrinters = await this.discoverPrintersAsync(timeout, interval, retries); + const discovery = new PrinterDiscovery(); + const rawPrinters = await discovery.discover({ + timeout, + idleTimeout: interval, + maxRetries: retries, + }); + + const discoveredPrinters = rawPrinters.map(toDiscoveredPrinter); + this.emit('discovery-completed', discoveredPrinters); return discoveredPrinters; } catch (error) { @@ -104,12 +117,18 @@ export class PrinterDiscoveryService extends EventEmitter { this.emit('single-scan-started', ipAddress); try { - const rawPrinters = await this.discoverPrintersAsync(5000, 1000, 1); - const matchingPrinter = - rawPrinters.find((printer) => printer.ipAddress === ipAddress) ?? null; + const discovery = new PrinterDiscovery(); + const rawPrinters = await discovery.discover({ + timeout: 5000, + idleTimeout: 1000, + maxRetries: 1, + }); - this.emit('single-scan-completed', matchingPrinter); - return matchingPrinter; + const matchingPrinter = rawPrinters.find((printer) => printer.ipAddress === ipAddress); + const discoveredPrinter = matchingPrinter ? toDiscoveredPrinter(matchingPrinter) : null; + + this.emit('single-scan-completed', discoveredPrinter); + return discoveredPrinter; } catch (error) { this.emit('single-scan-failed', { ipAddress, error }); return null; @@ -133,238 +152,8 @@ export class PrinterDiscoveryService extends EventEmitter { this.emit('discovery-cancelled'); } } - - private async discoverPrintersAsync( - timeoutMs: number, - idleTimeoutMs: number, - maxRetries: number - ): Promise { - const printers = new Map(); - - for (let attempt = 0; attempt < maxRetries; attempt++) { - const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); - - try { - await this.bindSocket(socket); - socket.setBroadcast(true); - socket.setMulticastTTL(1); - - const targets = this.getDiscoveryTargets(); - for (const target of targets) { - try { - socket.send(DISCOVERY_MESSAGE, target.port, target.address); - } catch (error) { - console.warn( - `[Discovery] Failed to send UDP probe to ${target.address}:${target.port}:`, - error - ); - } - } - - await this.receivePrinterResponses(socket, printers, timeoutMs, idleTimeoutMs); - } finally { - socket.close(); - } - - if (printers.size > 0) { - break; - } - - if (attempt < maxRetries - 1) { - await this.delay(1000); - } - } - - return Array.from(printers.values()); - } - - private async bindSocket(socket: dgram.Socket): Promise { - await new Promise((resolve, reject) => { - socket.once('error', reject); - socket.bind(DISCOVERY_BIND_PORT, () => { - socket.off('error', reject); - resolve(); - }); - }); - } - - private async receivePrinterResponses( - socket: dgram.Socket, - printers: Map, - totalTimeoutMs: number, - idleTimeoutMs: number - ): Promise { - await new Promise((resolve, reject) => { - let totalTimeoutHandle: ReturnType | null = null; - let idleTimeoutHandle: ReturnType | null = null; - - const cleanup = (): void => { - if (totalTimeoutHandle) { - clearTimeout(totalTimeoutHandle); - } - if (idleTimeoutHandle) { - clearTimeout(idleTimeoutHandle); - } - socket.off('message', handleMessage); - socket.off('error', handleError); - }; - - const finish = (callback: () => void): void => { - cleanup(); - callback(); - }; - - const resetIdleTimeout = (): void => { - if (idleTimeoutHandle) { - clearTimeout(idleTimeoutHandle); - } - idleTimeoutHandle = setTimeout(() => { - finish(resolve); - }, idleTimeoutMs); - }; - - const handleMessage = (buffer: Buffer, rinfo: dgram.RemoteInfo): void => { - resetIdleTimeout(); - - const printer = this.parsePrinterResponse(buffer, rinfo.address); - if (!printer) { - return; - } - - const key = `${printer.ipAddress}:${printer.commandPort ?? 8899}:${printer.serialNumber}`; - printers.set(key, printer); - }; - - const handleError = (error: Error): void => { - finish(() => reject(error)); - }; - - totalTimeoutHandle = setTimeout(() => { - finish(resolve); - }, totalTimeoutMs); - - socket.on('message', handleMessage); - socket.on('error', handleError); - resetIdleTimeout(); - }); - } - - private parsePrinterResponse(response: Buffer, ipAddress: string): DiscoveredPrinter | null { - if (!response || response.length < LEGACY_DISCOVERY_PACKET_SIZE) { - return null; - } - - if (response.length >= MODERN_DISCOVERY_PACKET_SIZE) { - const name = this.readNullTerminatedAscii(response, 0x00, 132); - const serialNumber = this.readNullTerminatedAscii(response, 0x92, 130); - - return { - name: name || 'Unknown Printer', - ipAddress, - serialNumber, - commandPort: response.readUInt16BE(0x84), - eventPort: response.readUInt16BE(0x8e), - // USB product ID identifies the model authoritatively (e.g. Creator 5 = 0x0028), - // which is essential for HTTP-only models that can't be TCP-probed. - productId: response.readUInt16BE(0x88), - model: 'Unknown', - status: 'Discovered', - }; - } - - const name = this.readNullTerminatedAscii(response, 0x00, 128); - - return { - name: name || 'Unknown Printer', - ipAddress, - serialNumber: '', - commandPort: response.readUInt16BE(0x84), - model: 'Unknown', - status: 'Discovered', - }; - } - - private readNullTerminatedAscii(buffer: Buffer, offset: number, length: number): string { - return buffer - .toString('ascii', offset, offset + length) - .replace(/\0.*$/, '') - .trim(); - } - - private getDiscoveryTargets(): DiscoveryTarget[] { - const targets = new Map(); - - for (const broadcastAddress of this.getBroadcastAddresses()) { - targets.set(`${broadcastAddress}:${BROADCAST_DISCOVERY_PORT}`, { - address: broadcastAddress, - port: BROADCAST_DISCOVERY_PORT, - }); - } - - const fallbackTargets: readonly DiscoveryTarget[] = [ - { address: LOOPBACK_ADDRESS, port: BROADCAST_DISCOVERY_PORT }, - { address: LOOPBACK_ADDRESS, port: MODERN_MULTICAST_PORT }, - { address: LOOPBACK_ADDRESS, port: LEGACY_MULTICAST_PORT }, - { address: MULTICAST_ADDRESS, port: MODERN_MULTICAST_PORT }, - { address: MULTICAST_ADDRESS, port: LEGACY_MULTICAST_PORT }, - ]; - - for (const target of fallbackTargets) { - targets.set(`${target.address}:${target.port}`, target); - } - - return Array.from(targets.values()); - } - - private getBroadcastAddresses(): string[] { - const addresses = new Set(); - const interfaces = networkInterfaces(); - - for (const networkInterface of Object.values(interfaces)) { - if (!networkInterface) { - continue; - } - - for (const iface of networkInterface) { - if (iface.family !== 'IPv4' || iface.internal || !iface.netmask) { - continue; - } - - const broadcastAddress = this.calculateBroadcastAddress(iface.address, iface.netmask); - if (broadcastAddress) { - addresses.add(broadcastAddress); - } - } - } - - return Array.from(addresses.values()); - } - - private calculateBroadcastAddress(ipAddress: string, subnetMask: string): string | null { - try { - const ip = ipAddress.split('.').map(Number); - const mask = subnetMask.split('.').map(Number); - - if (ip.length !== 4 || mask.length !== 4) { - return null; - } - - const broadcast = ip.map((octet, index) => octet | (~mask[index] & 255)); - return broadcast.join('.'); - } catch (error) { - console.warn('[Discovery] Failed to calculate broadcast address:', error); - return null; - } - } - - private async delay(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); - } } -// Export singleton getter function export const getPrinterDiscoveryService = (): PrinterDiscoveryService => { return PrinterDiscoveryService.getInstance(); }; diff --git a/src/services/PrinterPollingService.ts b/src/services/PrinterPollingService.ts index 23a8f9a..4115f2b 100644 --- a/src/services/PrinterPollingService.ts +++ b/src/services/PrinterPollingService.ts @@ -261,13 +261,11 @@ export class PrinterPollingService extends EventEmitter try { this.logDebug('Polling printer data...'); - // Fetch all data in parallel const [printerStatus, materialStation] = await Promise.allSettled([ this.fetchPrinterStatus(), this.fetchMaterialStation(), ]); - // Process results let hasChanges = false; const newData: PollingData = { ...this.currentData, @@ -275,13 +273,11 @@ export class PrinterPollingService extends EventEmitter lastPolled: new Date(), }; - // Process printer status if (printerStatus.status === 'fulfilled' && printerStatus.value) { newData.printerStatus = printerStatus.value; newData.isConnected = true; hasChanges = true; - // Handle job changes and thumbnails await this.handleJobChange(printerStatus.value, newData); this.emit(POLLING_EVENTS.STATUS_UPDATED, printerStatus.value); @@ -298,25 +294,21 @@ export class PrinterPollingService extends EventEmitter hasChanges = true; this.emit(POLLING_EVENTS.CONNECTION_CHANGED, { connected: false }); - // Clear thumbnail on disconnect and clean cache this.clearThumbnailState(); } } - // Process material station if (materialStation.status === 'fulfilled' && materialStation.value) { newData.materialStation = materialStation.value; hasChanges = true; this.emit(POLLING_EVENTS.MATERIAL_STATION_UPDATED, materialStation.value); } - // Update current data and emit if changed if (hasChanges) { this.currentData = newData; this.emit(POLLING_EVENTS.DATA_UPDATED, newData); } - // Reset retry count on success this.retryCount = 0; this.lastSuccessfulPoll = new Date(); } catch (error) { @@ -377,10 +369,9 @@ export class PrinterPollingService extends EventEmitter this.logDebug(`New job detected: ${fileName}`); this.lastJobName = fileName; - // Check cache first if (this.thumbnailCache.has(fileName)) { const cachedThumbnail = this.thumbnailCache.get(fileName); - this.currentThumbnail = cachedThumbnail ?? null; // Handle undefined case + this.currentThumbnail = cachedThumbnail ?? null; this.logDebug( `Using cached thumbnail for ${fileName}: ${this.currentThumbnail ? 'Available' : 'Failed (cached)'}` ); @@ -528,7 +519,7 @@ export class PrinterPollingService extends EventEmitter */ public dispose(): void { this.stop(); - this.clearThumbnailState(); // Clean up thumbnail cache + this.clearThumbnailState(); this.removeAllListeners(); this.backendManager = null; } diff --git a/src/services/PrinterRebootService.ts b/src/services/PrinterRebootService.ts new file mode 100644 index 0000000..e13c1b0 --- /dev/null +++ b/src/services/PrinterRebootService.ts @@ -0,0 +1,388 @@ +/** + * @fileoverview Remote "Reboot Printer" orchestration for the WebUI. + * + * Standalone port of the FlashForgeUI printer-power core. Reboots an + * Adventurer 5M / 5M Pro / AD5X over the flashforge-easyssh root SSH surface. + * The WebUI hides the button for unsupported models; this service re-validates + * the model (defense-in-depth) before dispatching the command. + * + * Lifecycle (broadcast over REBOOT_STATUS WebSocket messages): + * 1. rebooting - emitted once the SSH reboot command has been accepted. + * 2. reconnecting - emitted when polling starts failing (printer going down). + * 3. reconnecting-services - emitted once polling resumes; waits for + * REBOOT_STABLE_POLLS_REQUIRED consecutive stable polls + * so the TCP command socket and camera stream can catch up. + * 4. success - emitted after the consecutive-poll threshold (fully back). + * 5. timeout - emitted after REBOOT_RECONNECT_TIMEOUT_MS with no reconnect. + * 6. failed - emitted if the context is removed mid-reboot. + * + * The reboot command is fire-and-forget: the BusyBox 'reboot' applet SIGTERMs + * every process (including our SSH session), so the exec channel is expected + * to drop. dispatchRebootCommand() races the exec against a short timeout and + * treats a channel-drop / error as success. + * + * Key exports: + * - startPrinterReboot(): validate/dispatch/monitor core (used by the + * printer-power route). + * - dispatchRebootCommand(), REBOOT_COMMAND, REBOOT_STABLE_POLLS_REQUIRED, + * createStablePollCounter(): testable primitives. + */ + +import { getPrinterContextManager } from '../managers/PrinterContextManager'; +import type { PrinterModelType } from '../types/printer-backend'; +import type { ContextRemovedEvent } from '../types/printer'; +import type { RebootStatusPayload } from '../types/printer-power'; +import { isRebootSupportedModel } from '../utils/PrinterUtils'; +import { getWebSocketManager } from '../webui/server/WebSocketManager'; +import { + getSSHConnectionManager, + type SSHConnectionManager, +} from './calibration/ssh/SSHConnectionManager'; +import { getGo2rtcService } from './Go2rtcService'; +import { getMultiContextPollingCoordinator } from './MultiContextPollingCoordinator'; +import { getSSHSettingsService } from './SSHSettingsService'; + +/** + * Reboot shell command sent to the printer. + * + * Backgrounded behind a 2s sleep so the exec channel returns cleanly before + * the printer dies. Redirecting both streams keeps the channel quiet. The + * trailing '&' detaches the subshell so the foreground exec exits immediately. + */ +export const REBOOT_COMMAND = '(sleep 2; reboot) >/dev/null 2>&1 &'; + +/** How long to wait for the reboot exec to return before assuming dispatch. */ +export const REBOOT_DISPATCH_TIMEOUT_MS = 3000; + +/** Grace window before optimistically transitioning to "reconnecting". */ +export const REBOOT_GRACE_PERIOD_MS = 5000; + +/** Hard cap on how long we wait for the printer to come back online. */ +export const REBOOT_RECONNECT_TIMEOUT_MS = 150_000; + +/** + * Number of consecutive successful polls required before declaring the printer + * fully recovered. Polling runs at ~3s intervals, so 3 polls is roughly 6-9s of + * stable data — enough for the TCP command socket and camera stream to catch up + * after the HTTP endpoint comes back first. + */ +export const REBOOT_STABLE_POLLS_REQUIRED = 3; + +/** Minimal SSH surface dispatchRebootCommand depends on (keeps it unit-testable). */ +export interface RebootSshClient { + executeCommand: (contextId: string, command: string) => Promise; + disconnect: (contextId: string) => Promise; +} + +/** + * Fire-and-forget the reboot command. NEVER throws: a connection drop / non-zero + * exit is the expected outcome once the printer dies, so it is treated as + * success. The dedicated power SSH session is torn down best-effort afterward + * (the reboot kills the remote side regardless). + */ +export async function dispatchRebootCommand(ssh: RebootSshClient, powerKey: string): Promise { + const execPromise = Promise.resolve(ssh.executeCommand(powerKey, REBOOT_COMMAND)).then( + () => undefined, + () => undefined + ); + + const timeoutPromise = new Promise((resolve) => { + setTimeout(resolve, REBOOT_DISPATCH_TIMEOUT_MS); + }); + + await Promise.race([execPromise, timeoutPromise]); + + await Promise.resolve(ssh.disconnect(powerKey)).then( + () => undefined, + () => undefined + ); +} + +/** Records and evaluates a streak of consecutive successful polls. */ +export interface StablePollCounter { + /** Record one successful poll; returns the new streak length. */ + recordPoll: () => number; + /** Reset the streak to zero (a poll failed during recovery). */ + reset: () => void; + /** True once `required` consecutive polls have been recorded without a reset. */ + isStable: () => boolean; +} + +/** + * Count consecutive successful polls until a stability threshold is reached. + */ +export function createStablePollCounter(required: number): StablePollCounter { + let count = 0; + return { + recordPoll: () => { + count += 1; + return count; + }, + reset: () => { + count = 0; + }, + isStable: () => count >= required, + }; +} + +/** Active reconnect monitor for a context (one at most per context). */ +interface RebootMonitor { + readonly contextId: string; + readonly printerName: string; + seenError: boolean; + counter: StablePollCounter; + cameraRestarted: boolean; + graceTimer: NodeJS.Timeout | null; + reconnectTimer: NodeJS.Timeout | null; + onData: (contextId: string) => void; + onError: (contextId: string) => void; + cleanup: () => void; +} + +const activeMonitors = new Map(); +let contextRemovalListenerRegistered = false; + +/** Broadcast a reboot status update to connected WebUI WebSocket clients. */ +function sendRebootStatus(contextId: string, payload: RebootStatusPayload): void { + try { + getWebSocketManager().broadcastRebootStatus(contextId, payload); + } catch (error) { + // Broadcasting is best-effort; a WebUI failure must never break the reboot. + console.warn('[PrinterReboot] Failed to broadcast reboot status:', describeError(error)); + } +} + +/** Coerce an unknown thrown value into a single-line message. */ +function describeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return typeof error === 'string' ? error : 'Unknown error'; +} + +/** + * Best-effort: restart the go2rtc camera stream so it reconnects on our + * schedule rather than waiting for a UI interaction. Non-throwing — camera + * failures must never block the reboot from completing. + */ +async function restartCameraStream(contextId: string): Promise { + try { + const go2rtc = getGo2rtcService(); + if (go2rtc.hasStream(contextId)) { + await go2rtc.restartStream(contextId); + } + } catch (error) { + console.warn(`[PrinterReboot] Camera stream restart failed for ${contextId}:`, describeError(error)); + } +} + +/** + * Begin monitoring the polling coordinator for the printer going offline and + * coming back. Emits reconnecting / reconnecting-services / success / timeout. + */ +function startReconnectMonitor( + contextId: string, + printerName: string, + emit: (payload: RebootStatusPayload) => void +): void { + const coordinator = getMultiContextPollingCoordinator(); + + const monitor: RebootMonitor = { + contextId, + printerName, + seenError: false, + counter: createStablePollCounter(REBOOT_STABLE_POLLS_REQUIRED), + cameraRestarted: false, + graceTimer: null, + reconnectTimer: null, + onData: () => undefined, + onError: () => undefined, + cleanup: () => undefined, + }; + + const onData = (pollingContextId: string): void => { + if (pollingContextId !== contextId) { + return; + } + // Only count data that resumes AFTER the printer went offline. Ignoring + // healthy data received before the printer went down prevents a false + // success if polling was healthy the whole time. + if (!monitor.seenError) { + return; + } + + const newCount = monitor.counter.recordPoll(); + if (newCount === 1) { + emit({ + phase: 'reconnecting-services', + message: `Reconnecting services for ${printerName}...`, + printerName, + }); + if (!monitor.cameraRestarted) { + monitor.cameraRestarted = true; + void restartCameraStream(contextId); + } + } + + if (monitor.counter.isStable()) { + emit({ + phase: 'success', + message: `${printerName} is back online`, + printerName, + }); + monitor.cleanup(); + } + }; + + const onError = (pollingContextId: string): void => { + if (pollingContextId !== contextId) { + return; + } + if (!monitor.seenError) { + monitor.seenError = true; + emit({ + phase: 'reconnecting', + message: `Waiting for ${printerName} to come back online...`, + printerName, + }); + } else { + // A poll failed DURING the recovery window: reset the streak so we + // require another clean run, and allow the camera to be re-kicked. + monitor.counter.reset(); + monitor.cameraRestarted = false; + } + }; + + monitor.onData = onData; + monitor.onError = onError; + + monitor.cleanup = (): void => { + coordinator.off('polling-data', onData); + coordinator.off('polling-error', onError); + if (monitor.graceTimer) { + clearTimeout(monitor.graceTimer); + monitor.graceTimer = null; + } + if (monitor.reconnectTimer) { + clearTimeout(monitor.reconnectTimer); + monitor.reconnectTimer = null; + } + activeMonitors.delete(contextId); + }; + + coordinator.on('polling-data', onData); + coordinator.on('polling-error', onError); + + // Grace period: if no polling-error fires in time, optimistically assume the + // printer is going down and transition to reconnecting. + monitor.graceTimer = setTimeout(() => { + if (!monitor.seenError) { + monitor.seenError = true; + emit({ + phase: 'reconnecting', + message: `Waiting for ${printerName} to come back online...`, + printerName, + }); + } + }, REBOOT_GRACE_PERIOD_MS); + + // Hard timeout: surface an actionable error if the printer never reconnects. + monitor.reconnectTimer = setTimeout(() => { + emit({ + phase: 'timeout', + message: `${printerName} didn't come back online. Check that it's powered on and reachable.`, + printerName, + }); + monitor.cleanup(); + }, REBOOT_RECONNECT_TIMEOUT_MS); + + activeMonitors.set(contextId, monitor); +} + +/** Register a one-time listener that aborts any in-flight reboot on context removal. */ +function ensureContextRemovalCleanup(): void { + if (contextRemovalListenerRegistered) { + return; + } + contextRemovalListenerRegistered = true; + + const contextManager = getPrinterContextManager(); + contextManager.on('context-removed', (event: ContextRemovedEvent) => { + const monitor = activeMonitors.get(event.contextId); + if (!monitor) { + return; + } + sendRebootStatus(event.contextId, { + phase: 'failed', + message: `${monitor.printerName} was disconnected during reboot.`, + printerName: monitor.printerName, + }); + monitor.cleanup(); + }); +} + +/** + * Validate, dispatch, and monitor a printer reboot. Entry point for the WebUI + * POST /printer/reboot route. Throws with an actionable message on validation + * or SSH-connect failure. + */ +export async function startPrinterReboot(contextId: unknown): Promise<{ success: true }> { + if (typeof contextId !== 'string' || contextId.length === 0) { + throw new Error('Invalid reboot request: missing context id'); + } + + ensureContextRemovalCleanup(); + + // STRICT context resolution — no active-context fallback. + const context = getPrinterContextManager().getContext(contextId); + if (!context) { + throw new Error('Printer context not found'); + } + + const details = context.printerDetails; + const modelType: PrinterModelType | undefined = details.modelType; + + // Defense-in-depth model guard (the WebUI also hides the button). + if (!isRebootSupportedModel(modelType)) { + throw new Error('Reboot is only supported on Adventurer 5M / 5M Pro / AD5X printers'); + } + + const serialNumber = details.SerialNumber; + const host = details.IPAddress; + const printerName = details.Name || host || 'Printer'; + const powerKey = `power:${contextId}`; + + if (!serialNumber || !host) { + throw new Error('Printer is missing the serial number or IP address required for reboot'); + } + + // Abort any prior in-flight monitor for this context (Retry path). + const existing = activeMonitors.get(contextId); + if (existing) { + existing.cleanup(); + } + + // Dedicated SSH namespace — a reboot must not tear down the file-manager + // or calibration sessions (those use separate connection keys). + const config = await getSSHSettingsService().buildConnectionConfig(serialNumber, host); + try { + await getSSHConnectionManager().connect(powerKey, config); + } catch (error) { + // A genuine SSH failure (unreachable / wrong creds) is a real failure. + throw new Error(`Could not reach ${printerName} over SSH: ${describeError(error)}`); + } + + // Fire-and-forget the reboot command (channel-drop treated as success). + await dispatchRebootCommand(getSSHConnectionManager() as SSHConnectionManager, powerKey); + + sendRebootStatus(contextId, { + phase: 'rebooting', + message: `Rebooting ${printerName}...`, + printerName, + }); + + // Watch for the printer going offline and coming back. + startReconnectMonitor(contextId, printerName, (payload) => sendRebootStatus(contextId, payload)); + + return { success: true }; +} diff --git a/src/services/SSHSettingsService.ts b/src/services/SSHSettingsService.ts new file mode 100644 index 0000000..bc840ba --- /dev/null +++ b/src/services/SSHSettingsService.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview Centralized per-printer SSH credential store. + * + * Single source of truth for SSH credentials used by every SSH-based feature + * (file manager, calibration routes, printer reboot). Settings are keyed by + * printer serial number — stable across sessions, unlike runtime context ids — + * and persisted to ssh-settings.json in the data directory with passwords + * encoded via SecureStorage (base64 in this standalone build). + * + * Resolution rules: + * - Missing/empty fields fall back to the flashforge-easyssh defaults + * (root / flashforge, port 22), so provisioned printers work with zero setup. + * - An optional private key path takes precedence over password auth when set. + * + * Key exports: + * - SSHSettingsService / getSSHSettingsService(): singleton service + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import type { SSHConnectionConfig } from '../types/calibration'; +import { SSH_DEFAULTS, type SSHSettings, type SSHSettingsUpdate } from '../types/ssh-settings'; +import { decryptSecret, encryptSecret } from '../utils/SecureStorage'; +import { getEnvironmentService } from './EnvironmentService'; + +/** On-disk entry (password encoded; absent fields mean "use default"). */ +interface StoredSSHEntry { + username?: string; + password?: string; + port?: number; + keyPath?: string; +} + +interface SSHSettingsFile { + version: number; + printers: Record; +} + +const STORE_VERSION = 1; +const STORE_FILENAME = 'ssh-settings.json'; + +/** + * Centralized per-printer SSH settings, keyed by printer serial number. + */ +export class SSHSettingsService { + private static instance: SSHSettingsService | null = null; + + private readonly storePath: string; + private cache: SSHSettingsFile | null = null; + + private constructor() { + this.storePath = path.join(getEnvironmentService().getDataPath(), STORE_FILENAME); + } + + public static getInstance(): SSHSettingsService { + if (!SSHSettingsService.instance) { + SSHSettingsService.instance = new SSHSettingsService(); + } + return SSHSettingsService.instance; + } + + private async load(): Promise { + if (this.cache) { + return this.cache; + } + + try { + const raw = await fs.readFile(this.storePath, 'utf-8'); + const parsed = JSON.parse(raw) as SSHSettingsFile; + this.cache = { + version: parsed.version || STORE_VERSION, + printers: parsed.printers || {}, + }; + } catch { + this.cache = { version: STORE_VERSION, printers: {} }; + } + + return this.cache; + } + + private async persist(store: SSHSettingsFile): Promise { + this.cache = store; + await fs.mkdir(path.dirname(this.storePath), { recursive: true }); + await fs.writeFile(this.storePath, JSON.stringify(store, null, 2), 'utf-8'); + } + + /** + * Get resolved SSH settings for a printer (easy-SSH defaults applied). + */ + public async getSettings(serialNumber: string): Promise { + const store = await this.load(); + const entry = store.printers[serialNumber] || {}; + + const username = entry.username || SSH_DEFAULTS.username; + const password = (entry.password ? decryptSecret(entry.password) : undefined) || SSH_DEFAULTS.password; + const port = entry.port || SSH_DEFAULTS.port; + const keyPath = entry.keyPath || undefined; + + return { + username, + password, + port, + keyPath, + isCustom: + username !== SSH_DEFAULTS.username || + password !== SSH_DEFAULTS.password || + port !== SSH_DEFAULTS.port || + !!keyPath, + }; + } + + /** + * Update settings for a printer. Empty strings / default values are stored + * as "unset" so the printer keeps tracking the easy-SSH defaults. + */ + public async updateSettings(serialNumber: string, update: SSHSettingsUpdate): Promise { + const store = await this.load(); + const entry: StoredSSHEntry = { ...(store.printers[serialNumber] || {}) }; + + if (update.username !== undefined) { + const username = update.username.trim(); + entry.username = username && username !== SSH_DEFAULTS.username ? username : undefined; + } + if (update.password !== undefined) { + const password = update.password; + entry.password = password && password !== SSH_DEFAULTS.password ? encryptSecret(password) : undefined; + } + if (update.port !== undefined) { + entry.port = update.port > 0 && update.port !== SSH_DEFAULTS.port ? update.port : undefined; + } + if (update.keyPath !== undefined) { + const keyPath = update.keyPath.trim(); + entry.keyPath = keyPath || undefined; + } + + const hasValues = Object.values(entry).some((value) => value !== undefined); + if (hasValues) { + store.printers[serialNumber] = entry; + } else { + delete store.printers[serialNumber]; + } + + await this.persist(store); + console.log(`[SSHSettings] Updated SSH settings for ${serialNumber} (custom: ${hasValues})`); + } + + /** + * Reset a printer back to the easy-SSH defaults. + */ + public async resetSettings(serialNumber: string): Promise { + const store = await this.load(); + delete store.printers[serialNumber]; + await this.persist(store); + console.log(`[SSHSettings] Reset SSH settings for ${serialNumber} to defaults`); + } + + /** + * Build a ready-to-use SSH connection config for a printer. + * Reads the private key file into memory when a key path is configured. + */ + public async buildConnectionConfig(serialNumber: string, host: string): Promise { + const settings = await this.getSettings(serialNumber); + + const config: SSHConnectionConfig = { + host, + port: settings.port, + username: settings.username, + password: settings.password, + timeout: 10000, + keepaliveInterval: 10000, + }; + + if (settings.keyPath) { + try { + config.privateKey = await fs.readFile(settings.keyPath, 'utf-8'); + } catch (error) { + console.warn(`[SSHSettings] Failed to read SSH key at ${settings.keyPath}, falling back to password:`, error); + } + } + + return config; + } +} + +/** + * Get the SSHSettingsService singleton. + */ +export function getSSHSettingsService(): SSHSettingsService { + return SSHSettingsService.getInstance(); +} diff --git a/src/services/SavedPrinterService.ts b/src/services/SavedPrinterService.ts index 021f79c..ee62e8e 100644 --- a/src/services/SavedPrinterService.ts +++ b/src/services/SavedPrinterService.ts @@ -94,7 +94,6 @@ export class SavedPrinterService extends EventEmitter { const printer = this.getSavedPrinter(serialNumber); if (printer) { // Note: PrinterDetailsManager doesn't have a remove method yet - // This would need to be implemented this.emit('printer-removed', serialNumber); } } @@ -201,7 +200,6 @@ export class SavedPrinterService extends EventEmitter { } } -// Export singleton getter function export const getSavedPrinterService = (): SavedPrinterService => { return SavedPrinterService.getInstance(); }; diff --git a/src/services/SpoolmanIntegrationService.ts b/src/services/SpoolmanIntegrationService.ts index 275d206..0e10446 100644 --- a/src/services/SpoolmanIntegrationService.ts +++ b/src/services/SpoolmanIntegrationService.ts @@ -1,28 +1,30 @@ /** - * @fileoverview Spoolman integration service with persistence and AD5X protection + * @fileoverview Spoolman integration service with persistence * - * Manages active spool selections across printer contexts with per-printer persistence, - * AD5X printer detection/blocking, and event broadcasting for WebUI synchronization. + * Manages active spool selections across printer contexts with per-printer persistence + * and event broadcasting for WebUI synchronization. * This service acts as the single source of truth for active spool data. * * Key Features: * - Persistent storage of active spool selections per printer in printer_details.json - * - AD5X printer detection and automatic disablement + * - Support gating per context (any existing context is supported) * - Event-driven updates for real-time synchronization * - Integration with SpoolmanService for spool search and details * - Spoolman configuration validation and connection testing * - * AD5X Detection Logic: - * - Material station feature flag (materialStation.available === true), OR - * - Printer model string starts with "AD5" + * Material-station printers (Creator 5 series, AD5X with station) are + * supported through per-job tracking (see StationUsageTracker): the user + * picks a spool for each tool in the matching dialog when the app starts a + * job, and the slicer estimate is charged to that spool when the job ends. + * The spool choice ends with the job. The single active-spool flow below + * still applies to single-extruder contexts. * * @module services/SpoolmanIntegrationService */ import type { ConfigManager } from '../managers/ConfigManager'; +import type { PrinterBackendManager } from '../managers/PrinterBackendManager'; import type { PrinterContextManager } from '../managers/PrinterContextManager'; -// TODO: Import PrinterBackendManager when Phase 1 is complete -// import type { PrinterBackendManager } from '../managers/PrinterBackendManager'; import { getPrinterDetailsManager } from '../managers/PrinterDetailsManager'; import type { ConfigUpdateEvent } from '../types/config'; import type { PrinterDetails } from '../types/printer'; @@ -31,11 +33,6 @@ import { EventEmitter } from '../utils/EventEmitter'; import { toAppError } from '../utils/error.utils'; import { SpoolmanService } from './SpoolmanService'; -// Temporary stub until PrinterBackendManager is implemented -interface PrinterBackendManager { - getFeatures(contextId: string): { materialStation?: { available: boolean } } | null; -} - /** * Event payload for spool selection changes */ @@ -104,33 +101,22 @@ export class SpoolmanIntegrationService extends EventEmitter { const targetContextId = contextId || this.contextManager.getActiveContextId(); @@ -189,20 +194,15 @@ export class SpoolmanIntegrationService extends EventEmitter { const targetContextId = contextId || this.contextManager.getActiveContextId(); @@ -224,14 +224,11 @@ export class SpoolmanIntegrationService extends EventEmitter { const params = new URLSearchParams(); - // Build query params if (query['filament.name']) params.set('filament.name', query['filament.name']); if (query['filament.material']) params.set('filament.material', query['filament.material']); if (query['filament.vendor.name']) @@ -119,7 +117,6 @@ export class SpoolmanService { * @throws Error if validation fails or request fails */ async updateUsage(spoolId: number, usage: SpoolUsageUpdate): Promise { - // Validate: cannot specify both weight and length if (usage.use_weight !== undefined && usage.use_length !== undefined) { throw new Error('Cannot specify both use_weight and use_length'); } diff --git a/src/services/SpoolmanUsageTracker.ts b/src/services/SpoolmanUsageTracker.ts index 3cc7623..6a0d9be 100644 --- a/src/services/SpoolmanUsageTracker.ts +++ b/src/services/SpoolmanUsageTracker.ts @@ -91,7 +91,6 @@ export class SpoolmanUsageTracker extends EventEmitter { if (event.contextId === this.contextId) { void this.handlePrintCompleted(event); } }); - // Reset tracking when new print starts this.printStateMonitor.on('print-started', (event) => { if (event.contextId === this.contextId) { this.resetTracking(); @@ -148,22 +145,18 @@ export class SpoolmanUsageTracker extends EventEmitter { console.log(`[SpoolmanTracker] Print completed: ${event.jobName}`); - // Validate context if (event.contextId !== this.contextId) { console.warn('[SpoolmanTracker] Context mismatch in print-completed event'); return; } - // Check if already recorded for this print if (this.usageRecordedForPrint === event.jobName) { console.log(`[SpoolmanTracker] Usage already recorded for: ${event.jobName}`); return; } - // Update Spoolman with cached filament data from backend await this.updateSpoolmanUsage(event.status); - // Mark as recorded this.usageRecordedForPrint = event.jobName; } @@ -262,7 +255,6 @@ export class SpoolmanUsageTracker extends EventEmitter {} + +const KEY = 'SERIAL-1'; +const FILE = 'benchy.3mf'; + +function makeService(calls: UsageCall[], shouldFail = false): SpoolmanService { + return { + async updateUsage(spoolId: number, usage: { use_weight?: number; use_length?: number }) { + if (shouldFail) { + throw new Error('spoolman unreachable'); + } + calls.push({ spoolId, usage }); + return {}; + }, + } as unknown as SpoolmanService; +} + +function makeIntegration(mode: 'weight' | 'length' = 'weight'): SpoolmanIntegrationService { + return { getUpdateMode: () => mode } as unknown as SpoolmanIntegrationService; +} + +function status(state: string, currentJob?: { fileName: string; percent: number }): PrinterStatus { + return { + state, + currentJob: currentJob + ? { fileName: currentJob.fileName, progress: { percentage: currentJob.percent } } + : null, + } as unknown as PrinterStatus; +} + +/** Tool 0 prints the first half of the file, tool 1 the second half. */ +const SPLIT_PROFILE: ToolUsageProfile = { + sampleCount: 4, + perTool: { + '0': [0, 0.5, 1, 1, 1], + '1': [0, 0, 0, 0.5, 1], + }, +}; + +function trackedJob(overrides: Partial = {}): TrackedJob { + return { + fileName: FILE, + source: 'upload-3mf', + tools: [ + { toolId: 0, slotId: 1, spoolId: 11, usedG: 10, usedM: 3 }, + { toolId: 1, slotId: 3, spoolId: 22, usedG: 20, usedM: 6 }, + ], + usageProfile: null, + armedAt: new Date(1_000_000).toISOString(), + startedAt: null, + lastProgress: null, + lastProgressAt: null, + ...overrides, + }; +} + +interface Harness { + tracker: StationUsageTracker; + polling: FakePollingService; + store: TrackedJobStore; + calls: UsageCall[]; + summaries: DeductionSummary[]; + clock: { now: number }; +} + +function makeHarness(options?: { + job?: TrackedJob; + mode?: 'weight' | 'length'; + serviceFailure?: boolean; + store?: TrackedJobStore; + prime?: boolean; +}): Harness { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'station-tracker-test-')); + const store = options?.store ?? new TrackedJobStore(path.join(dir, 'jobs.json')); + if (options?.job) { + store.setJob(KEY, options.job); + } + const calls: UsageCall[] = []; + const summaries: DeductionSummary[] = []; + const clock = { now: 1_000_000 }; + + const tracker = new StationUsageTracker({ + contextId: 'ctx-1', + jobs: store, + createSpoolmanService: () => makeService(calls, options?.serviceFailure ?? false), + integrationService: makeIntegration(options?.mode ?? 'weight'), + onSummary: (summary) => summaries.push(summary), + resolveStoreKey: () => KEY, + now: () => clock.now, + }); + + const monitor = new PrintStateMonitor('ctx-1'); + const polling = new FakePollingService(); + monitor.setPollingService(polling as never); + tracker.setMonitors(monitor, polling as never); + if (options?.prime !== false) { + // The monitor records its first status without emitting; lifecycle + // events fire on transitions, exactly like real polling. + polling.emit('status-updated', status('Ready')); + } + return { tracker, polling, store, calls, summaries, clock }; +} + +/** Let async deduction settle. */ +function settle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +function bySpool(calls: UsageCall[]): Map { + return new Map(calls.map((call) => [call.spoolId, call.usage.use_weight ?? call.usage.use_length ?? 0])); +} + +describe('job name matching', () => { + it('ignores directory, extension and case', () => { + expect(jobNameKey('/usr/data/gcodes/Benchy.3MF')).toBe('benchy'); + expect(sameJob('benchy.3mf', 'Benchy.gcode')).toBe(true); + expect(sameJob('benchy.3mf', 'other.3mf')).toBe(false); + expect(sameJob(null, 'benchy.3mf')).toBe(false); + }); +}); + +describe('StationUsageTracker', () => { + it('charges each spool its full estimate when the job completes', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 50 })); + h.polling.emit('status-updated', status('Completed', { fileName: FILE, percent: 100 })); + await settle(); + + expect(bySpool(h.calls)).toEqual(new Map([[11, 10], [22, 20]])); + expect(h.summaries).toHaveLength(1); + expect(h.summaries[0]).toMatchObject({ terminal: 'completed', progress: 100, approximate: false }); + expect(h.store.getJob(KEY)).toBeNull(); + }); + + it('charges a cancelled job per tool from the usage profile', async () => { + const h = makeHarness({ job: trackedJob({ usageProfile: SPLIT_PROFILE }) }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 40 })); + h.polling.emit('status-updated', status('Cancelled')); + await settle(); + + // 40% of the bytes: tool 0 is 80% done, tool 1 has not started. + expect(bySpool(h.calls)).toEqual(new Map([[11, 8]])); + const summary = h.summaries[0]; + expect(summary.terminal).toBe('cancelled'); + expect(summary.deductedCount).toBe(1); + expect(summary.tools.find((tool) => tool.toolId === 1)).toMatchObject({ + status: 'skipped', + fraction: 0, + }); + }); + + it('charges a cancelled job linearly when there is no usage profile', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 40 })); + h.polling.emit('status-updated', status('Error')); + await settle(); + + expect(bySpool(h.calls)).toEqual(new Map([[11, 4], [22, 8]])); + expect(h.summaries[0].terminal).toBe('error'); + }); + + it('charges nothing for a cancel before any progress was seen', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 0 })); + h.polling.emit('status-updated', status('Cancelled')); + await settle(); + + expect(h.calls).toHaveLength(0); + expect(h.summaries[0].skippedCount).toBe(2); + expect(h.store.getJob(KEY)).toBeNull(); + }); + + it('charges nothing on pause and resume, then in full on completion', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 25 })); + h.polling.emit('status-updated', status('Paused', { fileName: FILE, percent: 25 })); + h.polling.emit('status-updated', status('Heating', { fileName: '', percent: 25 })); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 30 })); + await settle(); + expect(h.calls).toHaveLength(0); + expect(h.store.getJob(KEY)?.lastProgress).toBe(30); + + h.polling.emit('status-updated', status('Completed', { fileName: FILE, percent: 100 })); + await settle(); + expect(bySpool(h.calls)).toEqual(new Map([[11, 10], [22, 20]])); + }); + + it('charges a job at most once when two end events arrive', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 50 })); + await h.tracker.deductTrackedJob('cancelled', false); + await h.tracker.deductTrackedJob('error', false); + h.polling.emit('status-updated', status('Cancelled')); + await settle(); + + expect(h.calls).toHaveLength(2); + expect(h.summaries).toHaveLength(1); + }); + + it('drops the spool choice when the printer prints a different file', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: 'other.3mf', percent: 10 })); + h.polling.emit('status-updated', status('Completed', { fileName: 'other.3mf', percent: 100 })); + await settle(); + + expect(h.store.getJob(KEY)).toBeNull(); + expect(h.calls).toHaveLength(0); + }); + + it('ignores the end of an earlier print while the tracked job waits to start', async () => { + const h = makeHarness({ job: trackedJob(), prime: false }); + h.polling.emit('status-updated', status('Printing', { fileName: '', percent: 90 })); + h.polling.emit('status-updated', status('Completed', { fileName: '', percent: 100 })); + await settle(); + + expect(h.calls).toHaveLength(0); + expect(h.store.getJob(KEY)).not.toBeNull(); + }); + + it('drops a job that does not start within the arm timeout', () => { + const h = makeHarness({ job: trackedJob() }); + h.clock.now += ARM_TIMEOUT_MS + 1; + h.polling.emit('status-updated', status('Ready')); + expect(h.store.getJob(KEY)).toBeNull(); + }); + + it('keeps a started job through a heating status without a file name', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 60 })); + h.polling.emit('status-updated', status('Heating')); + await settle(); + expect(h.store.getJob(KEY)).not.toBeNull(); + expect(h.calls).toHaveLength(0); + }); + + it('charges the last progress when a started job is found idle', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 60 })); + h.polling.emit('status-updated', status('Ready')); + await settle(); + + expect(bySpool(h.calls)).toEqual(new Map([[11, 6], [22, 12]])); + expect(h.summaries[0]).toMatchObject({ terminal: 'interrupted', approximate: true, progress: 60 }); + }); + + it('matches a job the printer reports by its gcode name', async () => { + const h = makeHarness({ job: trackedJob() }); + h.polling.emit('status-updated', status('Printing', { fileName: 'benchy.gcode', percent: 50 })); + h.polling.emit('status-updated', status('Completed', { fileName: 'benchy.gcode', percent: 100 })); + await settle(); + expect(h.calls).toHaveLength(2); + }); + + it('uses millimetres in length mode', async () => { + const h = makeHarness({ job: trackedJob(), mode: 'length' }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 50 })); + h.polling.emit('status-updated', status('Cancelled')); + await settle(); + expect(h.calls.map((call) => call.usage)).toEqual([{ use_length: 1500 }, { use_length: 3000 }]); + }); + + it('reports Spoolman failures as skipped tools and still ends the job', async () => { + const h = makeHarness({ job: trackedJob(), serviceFailure: true }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 50 })); + h.polling.emit('status-updated', status('Completed', { fileName: FILE, percent: 100 })); + await settle(); + + expect(h.summaries[0].skippedCount).toBe(2); + expect(h.summaries[0].tools[0].reason).toContain('spoolman update failed'); + expect(h.store.getJob(KEY)).toBeNull(); + }); + + it('skips tools without an estimate', async () => { + const job = trackedJob(); + const h = makeHarness({ + job: { ...job, tools: [{ ...job.tools[0], usedG: null }, job.tools[1]] }, + }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 50 })); + h.polling.emit('status-updated', status('Completed', { fileName: FILE, percent: 100 })); + await settle(); + expect(bySpool(h.calls)).toEqual(new Map([[22, 20]])); + }); + + describe('after an app restart', () => { + function restartedStore(lastProgress: number): TrackedJobStore { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'station-restart-test-')); + const file = path.join(dir, 'jobs.json'); + const before = new TrackedJobStore(file); + before.setJob( + KEY, + trackedJob({ + startedAt: new Date(900_000).toISOString(), + lastProgress, + lastProgressAt: new Date(950_000).toISOString(), + }) + ); + return new TrackedJobStore(file); + } + + it('charges the saved progress when the printer is idle', async () => { + const h = makeHarness({ store: restartedStore(55), prime: false }); + h.polling.emit('status-updated', status('Ready')); + await settle(); + expect(bySpool(h.calls)).toEqual(new Map([[11, 5.5], [22, 11]])); + expect(h.summaries[0]).toMatchObject({ terminal: 'interrupted', approximate: true }); + }); + + it('charges in full when the printer reports the job completed', async () => { + const h = makeHarness({ store: restartedStore(80), prime: false }); + h.polling.emit('status-updated', status('Completed', { fileName: FILE, percent: 100 })); + await settle(); + expect(bySpool(h.calls)).toEqual(new Map([[11, 10], [22, 20]])); + expect(h.summaries[0]).toMatchObject({ terminal: 'completed', approximate: true }); + }); + + it('keeps tracking when the printer still prints the job', async () => { + const h = makeHarness({ store: restartedStore(40), prime: false }); + h.polling.emit('status-updated', status('Printing', { fileName: FILE, percent: 45 })); + await settle(); + expect(h.calls).toHaveLength(0); + expect(h.store.getJob(KEY)?.lastProgress).toBe(45); + + h.polling.emit('status-updated', status('Completed', { fileName: FILE, percent: 100 })); + await settle(); + expect(h.summaries[0]).toMatchObject({ terminal: 'completed', approximate: false }); + }); + }); +}); diff --git a/src/services/StationUsageTracker.ts b/src/services/StationUsageTracker.ts new file mode 100644 index 0000000..305e728 --- /dev/null +++ b/src/services/StationUsageTracker.ts @@ -0,0 +1,433 @@ +/** + * @fileoverview Per-job Spoolman deduction for material-station printers + * (Creator 5 series, AD5X with station). + * + * The firmware reports no per-tool usage, so this tracker works from the one + * {@link TrackedJob} the app stored when it started the job (see + * job-tracking). The job carries the spool the user chose for each tool. + * + * - While the printer prints the job, the tracker records the last progress + * value. Progress is the gcode byte position divided by the file size. + * - When the job completes, each tool is charged its full estimate. + * - When the job is cancelled or fails, each tool is charged the part of its + * estimate used at the last progress value. With a usage profile the part + * comes from the tool's own curve; without one it is the plain progress + * fraction. With no progress value, nothing is charged. + * - Pause and resume charge nothing. + * - If the app was not running when the job ended (restart or reconnect), the + * tracker charges the last recorded progress on the first status update and + * marks the result as approximate. A completed job is charged in full. + * - The record is removed before any Spoolman request, so a job is charged at + * most once. Removing it also ends the spool choice: the next job asks again. + * - Spoolman API failures are logged and reported as skipped tools; they + * never affect printing. + */ + +import type { + DeductionSummary, + DeductionTerminal, + SpoolDeductionMode, + ToolDeduction, + TrackedJob, +} from '../types/spoolman-tracking'; +import type { PrinterStatus } from '../types/polling'; +import type { PrinterState } from '../types/polling'; +import type { SpoolmanIntegrationService } from './SpoolmanIntegrationService'; +import type { SpoolmanService } from './SpoolmanService'; +import type { TrackedJobStore } from './TrackedJobStore'; +import type { PrintStateMonitor } from './PrintStateMonitor'; +import type { PrinterPollingService } from './PrinterPollingService'; +import { resolveStationStoreKey } from './station-store-key'; +import { usedFractionAt } from './tool-usage-profile'; + +/** + * Factory for a ready-to-use Spoolman client, or null while the integration + * is disabled/unconfigured. Resolved lazily at deduction time so runtime + * config changes are honored. + */ +export type SpoolmanServiceFactory = () => SpoolmanService | null; + +/** Rounding for deducted amounts (grams / millimetres). */ +const AMOUNT_DECIMALS = 2; + +/** + * A job the app started but the printer never began is dropped after this + * time, so a failed start cannot bind the spool choice to a later print. + */ +export const ARM_TIMEOUT_MS = 15 * 60 * 1000; + +/** States in which the printer is working on a job. */ +const ACTIVE_STATES: ReadonlySet = new Set([ + 'Printing', + 'Paused', + 'Pausing', + 'Heating', + 'Calibrating', + 'Busy', +]); + +/** Payload shape shared by PrintStateMonitor lifecycle events. */ +interface PrintLifecycleEvent { + readonly contextId: string; + readonly jobName: string | null; + readonly status: PrinterStatus; +} + +/** Dependencies for constructing a per-context station tracker. */ +export interface StationUsageTrackerDeps { + readonly contextId: string; + readonly jobs: Pick; + readonly createSpoolmanService: SpoolmanServiceFactory; + readonly integrationService: Pick; + /** Invoked after every deduction attempt (including all-skipped ones). */ + readonly onSummary?: (summary: DeductionSummary) => void; + /** Store key override for tests (defaults to the printer serial). */ + readonly resolveStoreKey?: (contextId: string) => string; + /** Clock override for tests. */ + readonly now?: () => number; +} + +/** + * Normalize a file name for comparison: no directory, no extension, lower + * case. The printer may report a 3MF job by its 3MF name or by the name of + * the gcode inside it. + */ +export function jobNameKey(name: string | null | undefined): string { + if (!name) { + return ''; + } + const base = name.replace(/\\/g, '/').split('/').pop() ?? ''; + return base.replace(/\.(3mf|gcode|gx|g)$/i, '').trim().toLowerCase(); +} + +/** True when two job names refer to the same file. */ +export function sameJob(a: string | null | undefined, b: string | null | undefined): boolean { + const keyA = jobNameKey(a); + return keyA !== '' && keyA === jobNameKey(b); +} + +/** Round to the tracker's amount precision. */ +function roundAmount(value: number): number { + const factor = 10 ** AMOUNT_DECIMALS; + return Math.round(value * factor) / factor; +} + +/** + * Per-tool deduction executor for one material-station context. + * + * The single-extruder path ({@link SpoolmanUsageTracker}) is untouched; this + * class is only ever instantiated for contexts whose backend exposes the + * material-station feature. + */ +export class StationUsageTracker { + private readonly deps: StationUsageTrackerDeps; + private stateMonitor: PrintStateMonitor | null = null; + private pollingService: PrinterPollingService | null = null; + private lastSummary: DeductionSummary | null = null; + private firstStatusHandled = false; + private disposed = false; + + constructor(deps: StationUsageTrackerDeps) { + this.deps = deps; + } + + /** Attach lifecycle + polling listeners for the context. */ + public setMonitors( + stateMonitor: PrintStateMonitor, + pollingService: PrinterPollingService | null + ): void { + this.detachListeners(); + this.stateMonitor = stateMonitor; + this.pollingService = pollingService; + + stateMonitor.on('print-completed', this.handleTerminal); + stateMonitor.on('print-cancelled', this.handleTerminal); + stateMonitor.on('print-error', this.handleTerminal); + pollingService?.on('status-updated', this.handleStatusUpdated); + this.disposed = false; + } + + /** Detach listeners (context teardown / re-attach). */ + public dispose(): void { + this.detachListeners(); + this.disposed = true; + } + + private detachListeners(): void { + if (this.stateMonitor) { + this.stateMonitor.off('print-completed', this.handleTerminal); + this.stateMonitor.off('print-cancelled', this.handleTerminal); + this.stateMonitor.off('print-error', this.handleTerminal); + this.stateMonitor = null; + } + if (this.pollingService) { + this.pollingService.off('status-updated', this.handleStatusUpdated); + this.pollingService = null; + } + } + + /** Most recent deduction summary for panel display. */ + public getLastSummary(): DeductionSummary | null { + return this.lastSummary; + } + + /** The job this printer tracks now, or null. */ + public getTrackedJob(): TrackedJob | null { + return this.deps.jobs.getJob(this.storeKey()); + } + + private storeKey(): string { + return (this.deps.resolveStoreKey ?? resolveStationStoreKey)(this.deps.contextId); + } + + private now(): number { + return this.deps.now ? this.deps.now() : Date.now(); + } + + // --- event handlers ------------------------------------------------------- + + /** Handle one polled status. Public for tests. */ + public handleStatusUpdated = (status: PrinterStatus): void => { + if (this.disposed) { + return; + } + const key = this.storeKey(); + const job = this.deps.jobs.getJob(key); + const firstStatus = !this.firstStatusHandled; + this.firstStatusHandled = true; + if (!job) { + return; + } + + const reportedName = status.currentJob?.fileName ?? null; + const printingThisJob = ACTIVE_STATES.has(status.state) && sameJob(reportedName, job.fileName); + + if (printingThisJob) { + this.recordProgress(key, job, status); + return; + } + + if (job.startedAt !== null) { + // The job was printing, but the printer no longer works on it and no + // end event reached this tracker (app restart, reconnect, or a missed + // transition). Charge it now from what is known. + // A status without a file name while the printer is active (heating + // for a resume, for example) says nothing, so it never ends the job. + const printingOtherJob = + ACTIVE_STATES.has(status.state) && reportedName !== null && reportedName !== ''; + const idle = status.state === 'Ready'; + const endStateAfterRestart = + firstStatus && !ACTIVE_STATES.has(status.state); + if (printingOtherJob || idle || endStateAfterRestart) { + const completed = + status.state === 'Completed' && (reportedName === null || sameJob(reportedName, job.fileName)); + this.runDeduction(completed ? 'completed' : 'interrupted', true); + } + return; + } + + // Not started yet. + if (ACTIVE_STATES.has(status.state) && reportedName && !sameJob(reportedName, job.fileName)) { + console.warn( + `[StationUsageTracker] Printer on context ${this.deps.contextId} prints ${reportedName}, ` + + `not the tracked ${job.fileName}; the spool choice is dropped.` + ); + this.deps.jobs.takeJob(key); + return; + } + if (this.now() - Date.parse(job.armedAt) > ARM_TIMEOUT_MS) { + console.warn( + `[StationUsageTracker] ${job.fileName} did not start within ` + + `${ARM_TIMEOUT_MS / 60000} minutes; the spool choice is dropped.` + ); + this.deps.jobs.takeJob(key); + } + }; + + private recordProgress(key: string, job: TrackedJob, status: PrinterStatus): void { + const changes: { startedAt?: string; lastProgress?: number; lastProgressAt?: string } = {}; + let persist = false; + if (job.startedAt === null) { + changes.startedAt = new Date(this.now()).toISOString(); + persist = true; + } + const percent = status.currentJob?.progress?.percentage; + if (typeof percent === 'number' && Number.isFinite(percent) && percent > 0) { + const clamped = Math.min(100, percent); + if (clamped !== job.lastProgress) { + changes.lastProgress = clamped; + changes.lastProgressAt = new Date(this.now()).toISOString(); + // Write to disk once per whole percent to keep the file quiet. + persist = persist || Math.floor(clamped) !== Math.floor(job.lastProgress ?? -1); + } + } + if (Object.keys(changes).length > 0) { + this.deps.jobs.updateJob(key, changes, persist); + } + } + + private handleTerminal = (event: PrintLifecycleEvent): void => { + if (this.disposed) { + return; + } + const terminal = this.terminalFromStatus(event.status); + if (!terminal) { + return; + } + const job = this.deps.jobs.getJob(this.storeKey()); + if (!job) { + return; + } + const eventName = + (event.jobName && event.jobName !== 'Unknown' ? event.jobName : null) ?? + event.status.currentJob?.fileName ?? + null; + // An end event for a job the tracker never saw start belongs to an + // earlier print, unless it names this job. + if (job.startedAt === null && !sameJob(eventName, job.fileName)) { + return; + } + if (eventName !== null && !sameJob(eventName, job.fileName)) { + return; + } + this.runDeduction(terminal, false); + }; + + private runDeduction(terminal: DeductionTerminal, approximate: boolean): void { + // Fire-and-forget on purpose (poll handlers must never await network + // I/O), but always observe the promise so a rejection can never become + // an unhandledRejection; per-tool Spoolman errors are handled inside. + void this.deductTrackedJob(terminal, approximate).catch((error: unknown) => { + console.warn( + `[StationUsageTracker] Deduction failed on context ${this.deps.contextId}:`, + error + ); + }); + } + + private terminalFromStatus(status: PrinterStatus): DeductionTerminal | null { + switch (status.state) { + case 'Completed': + return 'completed'; + case 'Cancelled': + return 'cancelled'; + case 'Error': + return 'error'; + default: + return null; + } + } + + // --- deduction ------------------------------------------------------------ + + /** + * Charge the tracked job and remove it. Public for tests. + * + * @param terminal - How the job ended + * @param approximate - True when the end state was not observed + */ + public async deductTrackedJob( + terminal: DeductionTerminal, + approximate: boolean + ): Promise { + // Remove the record BEFORE any await: a second end event that arrives + // while Spoolman is slow finds nothing to charge. + const job = this.deps.jobs.takeJob(this.storeKey()); + if (!job) { + return null; + } + + const progress = terminal === 'completed' ? 100 : job.lastProgress ?? 0; + const mode = this.resolveMode(); + const service = this.deps.createSpoolmanService(); + const tools: ToolDeduction[] = []; + + if (!service) { + console.log( + `[StationUsageTracker] Spoolman integration not configured; ${job.fileName} is not charged.` + ); + } else { + for (const tool of job.tools) { + const fraction = + terminal === 'completed' ? 1 : usedFractionAt(job.usageProfile, tool.toolId, progress); + tools.push(await this.chargeTool(service, job, tool, mode, fraction, progress)); + } + } + + const deductedCount = tools.filter((entry) => entry.status === 'deducted').length; + const summary: DeductionSummary = { + fileName: job.fileName, + terminal, + progress, + approximate, + tools, + deductedCount, + skippedCount: tools.length - deductedCount, + at: new Date(this.now()).toISOString(), + }; + console.log( + `[StationUsageTracker] ${job.fileName} ended (${terminal}${approximate ? ', approximate' : ''}) ` + + `at ${progress}%: ${deductedCount} spool(s) charged, ${summary.skippedCount} skipped.` + ); + this.lastSummary = summary; + this.deps.onSummary?.(summary); + return summary; + } + + private async chargeTool( + service: SpoolmanService, + job: TrackedJob, + tool: TrackedJob['tools'][number], + mode: SpoolDeductionMode, + fraction: number, + progress: number + ): Promise { + const base = { toolId: tool.toolId, slotId: tool.slotId, spoolId: tool.spoolId, mode, fraction }; + const estimate = mode === 'weight' ? tool.usedG : tool.usedM; + const unit = mode === 'weight' ? 'grams' : 'metres'; + + if (estimate === null || !Number.isFinite(estimate) || estimate <= 0) { + return this.skipped(base, `no ${unit} estimate for tool ${tool.toolId + 1}`); + } + if (!(progress > 0)) { + return this.skipped(base, 'no printer progress was seen for this job'); + } + + // Length mode consumes millimetres; estimates are stored in metres. + const amount = roundAmount(mode === 'weight' ? estimate * fraction : estimate * 1000 * fraction); + if (amount <= 0) { + return this.skipped(base, `tool ${tool.toolId + 1} had not printed yet`); + } + + try { + const payload = mode === 'weight' ? { use_weight: amount } : { use_length: amount }; + await service.updateUsage(tool.spoolId, payload); + console.log( + `[StationUsageTracker] Charged ${amount}${mode === 'weight' ? 'g' : 'mm'} to spool ` + + `${tool.spoolId} (tool ${tool.toolId + 1}, slot ${tool.slotId}, job ${job.fileName}).` + ); + return { ...base, amount, status: 'deducted' }; + } catch (error) { + // Invariant: Spoolman failures never affect printing. + const message = error instanceof Error ? error.message : String(error); + console.error( + `[StationUsageTracker] Spoolman update failed for spool ${tool.spoolId} ` + + `(tool ${tool.toolId + 1}, job ${job.fileName}): ${message}` + ); + return this.skipped(base, `spoolman update failed: ${message}`); + } + } + + /** Weight-first per config; falls back to length mode only per config. */ + private resolveMode(): SpoolDeductionMode { + return this.deps.integrationService.getUpdateMode() === 'length' ? 'length' : 'weight'; + } + + private skipped( + base: Omit, + reason: string + ): ToolDeduction { + console.warn(`[StationUsageTracker] Skipping tool ${base.toolId + 1}: ${reason}.`); + return { ...base, amount: null, status: 'skipped', reason }; + } +} diff --git a/src/services/TemperatureMonitoringService.ts b/src/services/TemperatureMonitoringService.ts index 0f5beec..29274e3 100644 --- a/src/services/TemperatureMonitoringService.ts +++ b/src/services/TemperatureMonitoringService.ts @@ -130,7 +130,6 @@ export class TemperatureMonitoringService extends EventEmitter { this.lastPrinterStatus = status; - // Update temperature monitoring if active if (this.state.monitoringActive) { void this.checkTemperature(status); } @@ -188,7 +184,6 @@ export class TemperatureMonitoringService extends EventEmitter { if (event.contextId === this.contextId) { console.log('[TemperatureMonitor] Print completed, starting temperature monitoring'); @@ -196,7 +191,6 @@ export class TemperatureMonitoringService extends EventEmitter { if (event.contextId === this.contextId) { console.log('[TemperatureMonitor] Print started, resetting state'); @@ -204,7 +198,6 @@ export class TemperatureMonitoringService extends EventEmitter { if (event.contextId === this.contextId) { console.log('[TemperatureMonitor] Print cancelled, resetting state'); @@ -212,7 +205,6 @@ export class TemperatureMonitoringService extends EventEmitter { if (event.contextId === this.contextId) { console.log('[TemperatureMonitor] Print error, resetting state'); @@ -241,19 +233,15 @@ export class TemperatureMonitoringService extends EventEmitter { if (this.lastPrinterStatus) { void this.checkTemperature(this.lastPrinterStatus); @@ -281,12 +269,10 @@ export class TemperatureMonitoringService extends EventEmitter { - // Skip if already cooled if (this.state.hasCooled) { return; } - // Skip if print complete time not set if (!this.state.printCompleteTime) { return; } @@ -295,7 +281,6 @@ export class TemperatureMonitoringService extends EventEmitter; +} + +/** + * Options for cache service + */ +interface CacheOptions { + readonly basePath?: string; +} + +/** + * Result of cache operations + */ +interface CacheResult { + readonly success: boolean; + readonly data?: T; + readonly error?: string; +} + +/** + * Service for managing thumbnail cache with file-based persistence + */ +export class ThumbnailCacheService { + private static instance: ThumbnailCacheService | null = null; + + private readonly basePath: string; + private readonly metadataCache = new Map(); + + private constructor(options: CacheOptions = {}) { + // Dedicated Thumbnails directory under the app data directory + this.basePath = options.basePath || path.join(getDataPath(), 'Thumbnails'); + } + + /** + * Get singleton instance + */ + public static getInstance(options?: CacheOptions): ThumbnailCacheService { + if (!ThumbnailCacheService.instance) { + ThumbnailCacheService.instance = new ThumbnailCacheService(options); + } + return ThumbnailCacheService.instance; + } + + /** + * Initialize cache service and ensure directories exist + */ + public async initialize(): Promise { + try { + await fs.mkdir(this.basePath, { recursive: true }); + console.log(`ThumbnailCacheService initialized at: ${this.basePath}`); + } catch (error) { + console.error('Failed to initialize thumbnail cache:', error); + throw error; + } + } + + /** + * Get cached thumbnail if available + */ + public async get(printerSerial: string, fileName: string): Promise> { + try { + const hash = this.hashFileName(fileName); + const metadata = await this.getMetadata(printerSerial); + + const entry = metadata.entries[hash]; + if (!entry) { + return { success: false, error: 'Not in cache' }; + } + + const filePath = this.getThumbnailPath(printerSerial, hash); + const data = await fs.readFile(filePath, 'base64'); + + console.log(`[ThumbnailCache] Cache hit for ${fileName} (${printerSerial})`); + return { success: true, data }; + } catch (error) { + console.error(`Error reading cached thumbnail for ${fileName}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Store thumbnail in cache + */ + public async set(printerSerial: string, fileName: string, base64Data: string): Promise> { + try { + const hash = this.hashFileName(fileName); + const printerDir = path.join(this.basePath, printerSerial); + + await fs.mkdir(printerDir, { recursive: true }); + + // Strip data URL prefix if present + const imageData = base64Data.replace(/^data:image\/\w+;base64,/, ''); + + const filePath = this.getThumbnailPath(printerSerial, hash); + const buffer = Buffer.from(imageData, 'base64'); + await fs.writeFile(filePath, buffer); + + const metadata = await this.getMetadata(printerSerial); + metadata.entries[hash] = { + fileName, + hash, + cachedAt: Date.now(), + size: buffer.length, + }; + + await this.saveMetadata(printerSerial, metadata); + + console.log(`[ThumbnailCache] Cached thumbnail for ${fileName} (${printerSerial})`); + return { success: true }; + } catch (error) { + console.error(`Error caching thumbnail for ${fileName}:`, error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Check if thumbnail exists in cache (without reading the file) + */ + public async has(printerSerial: string, fileName: string): Promise { + try { + const hash = this.hashFileName(fileName); + const metadata = await this.getMetadata(printerSerial); + + const entry = metadata.entries[hash]; + if (!entry) { + return false; + } + + const filePath = this.getThumbnailPath(printerSerial, hash); + try { + await fs.access(filePath); + return true; + } catch { + // File doesn't exist, clean up metadata + delete metadata.entries[hash]; + await this.saveMetadata(printerSerial, metadata); + return false; + } + } catch (error) { + console.error(`Error checking cache for ${fileName}:`, error); + return false; + } + } + + /** + * Invalidate (remove) a specific thumbnail from cache + */ + public async invalidate(printerSerial: string, fileName: string): Promise { + try { + const hash = this.hashFileName(fileName); + await this.removeEntry(printerSerial, hash); + console.log(`[ThumbnailCache] Invalidated cache for ${fileName} (${printerSerial})`); + } catch (error) { + console.error(`Error invalidating cache for ${fileName}:`, error); + } + } + + /** + * Clear all thumbnails for a specific printer + */ + public async clearPrinter(printerSerial: string): Promise { + try { + const printerDir = path.join(this.basePath, printerSerial); + await fs.rm(printerDir, { recursive: true, force: true }); + this.metadataCache.delete(printerSerial); + console.log(`[ThumbnailCache] Cleared all cache for printer ${printerSerial}`); + } catch (error) { + console.error(`Error clearing cache for printer ${printerSerial}:`, error); + } + } + + /** + * Clear entire cache + */ + public async clearAll(): Promise { + try { + await fs.rm(this.basePath, { recursive: true, force: true }); + await fs.mkdir(this.basePath, { recursive: true }); + this.metadataCache.clear(); + console.log('[ThumbnailCache] Cleared entire cache'); + } catch (error) { + console.error('Error clearing entire cache:', error); + } + } + + /** + * Get cache statistics + */ + public async getStats(): Promise<{ + totalFiles: number; + totalSize: number; + printerCount: number; + oldestEntry: number | null; + newestEntry: number | null; + }> { + try { + let totalFiles = 0; + let totalSize = 0; + let oldestEntry: number | null = null; + let newestEntry: number | null = null; + + const printers = await fs.readdir(this.basePath); + const printerCount = printers.length; + + for (const printerSerial of printers) { + const metadata = await this.getMetadata(printerSerial); + + for (const entry of Object.values(metadata.entries)) { + totalFiles++; + totalSize += entry.size; + + if (!oldestEntry || entry.cachedAt < oldestEntry) { + oldestEntry = entry.cachedAt; + } + if (!newestEntry || entry.cachedAt > newestEntry) { + newestEntry = entry.cachedAt; + } + } + } + + return { + totalFiles, + totalSize, + printerCount, + oldestEntry, + newestEntry, + }; + } catch (error) { + console.error('Error getting cache stats:', error); + return { + totalFiles: 0, + totalSize: 0, + printerCount: 0, + oldestEntry: null, + newestEntry: null, + }; + } + } + + // Private helper methods + + /** + * Generate hash for file name to use as cache key + */ + private hashFileName(fileName: string): string { + return crypto.createHash('md5').update(fileName).digest('hex'); + } + + /** + * Get path to thumbnail file + */ + private getThumbnailPath(printerSerial: string, hash: string): string { + return path.join(this.basePath, printerSerial, `${hash}.png`); + } + + /** + * Get path to metadata file + */ + private getMetadataPath(printerSerial: string): string { + return path.join(this.basePath, printerSerial, 'metadata.json'); + } + + /** + * Load metadata for a printer + */ + private async getMetadata(printerSerial: string): Promise { + const cached = this.metadataCache.get(printerSerial); + if (cached) { + return cached; + } + + try { + const metadataPath = this.getMetadataPath(printerSerial); + const data = await fs.readFile(metadataPath, 'utf-8'); + const metadata = JSON.parse(data) as CacheMetadata; + + if (!metadata.version || metadata.version < 1) { + metadata.version = 1; + } + if (!metadata.entries) { + metadata.entries = {}; + } + + this.metadataCache.set(printerSerial, metadata); + return metadata; + } catch { + const emptyMetadata: CacheMetadata = { + version: 1, + entries: {}, + }; + this.metadataCache.set(printerSerial, emptyMetadata); + return emptyMetadata; + } + } + + /** + * Save metadata for a printer + */ + private async saveMetadata(printerSerial: string, metadata: CacheMetadata): Promise { + const metadataPath = this.getMetadataPath(printerSerial); + await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); + this.metadataCache.set(printerSerial, metadata); + } + + /** + * Remove a specific entry + */ + private async removeEntry(printerSerial: string, hash: string): Promise { + const filePath = this.getThumbnailPath(printerSerial, hash); + try { + await fs.unlink(filePath); + } catch { + // Ignore if file doesn't exist + } + + const metadata = await this.getMetadata(printerSerial); + delete metadata.entries[hash]; + await this.saveMetadata(printerSerial, metadata); + } +} + +/** + * Get singleton instance of ThumbnailCacheService + */ +export function getThumbnailCacheService(): ThumbnailCacheService { + return ThumbnailCacheService.getInstance(); +} diff --git a/src/services/ThumbnailRequestQueue.ts b/src/services/ThumbnailRequestQueue.ts index d94baa4..eaaa74f 100644 --- a/src/services/ThumbnailRequestQueue.ts +++ b/src/services/ThumbnailRequestQueue.ts @@ -10,7 +10,7 @@ * - Backend-specific concurrency (legacy: 1, modern: 3 concurrent requests) * - Request deduplication to avoid redundant network calls * - Priority-based queue ordering with FIFO within priority levels - * - Automatic retry with exponential backoff (up to 2 retries) + * - Automatic retry (up to 2 retries) * - Multi-context support via PrinterContextManager integration * - Comprehensive statistics tracking and event emission * - Graceful cancellation and queue reset capabilities @@ -130,7 +130,6 @@ export class ThumbnailRequestQueue extends EventEmitter { */ public enqueue(fileName: string, priority: number = 0): Promise { return new Promise((resolve) => { - // Check if already processing or queued const existingItem = this.findExistingRequest(fileName); if (existingItem) { console.log(`[ThumbnailQueue] Request already queued for ${fileName}, adding callback`); @@ -138,7 +137,6 @@ export class ThumbnailRequestQueue extends EventEmitter { return; } - // Create new queue item const item: QueueItem = { id: `${Date.now()}-${Math.random()}`, fileName, @@ -148,16 +146,13 @@ export class ThumbnailRequestQueue extends EventEmitter { callback: resolve, }; - // Add to queue this.queue.push(item); this.sortQueue(); console.log(`[ThumbnailQueue] Enqueued ${fileName}, queue size: ${this.queue.length}`); - // Start processing if not already running if (!this.isProcessing) { console.log('[ThumbnailQueue] Starting new processing cycle'); - // Reset cancelled flag when starting a new cycle this.isCancelled = false; this.processQueue().catch((error) => { console.error('[ThumbnailQueue] Processing error:', error); @@ -174,7 +169,6 @@ export class ThumbnailRequestQueue extends EventEmitter { console.log('[ThumbnailQueue] Cancelling all requests'); this.isCancelled = true; - // Clear queue const cancelledCount = this.queue.length; this.queue.length = 0; @@ -202,7 +196,6 @@ export class ThumbnailRequestQueue extends EventEmitter { } this.pendingCallbacks.clear(); - // Reset processing flag to allow new processing cycles this.isProcessing = false; console.log(`[ThumbnailQueue] Cancelled ${cancelledCount} queued items`); @@ -261,7 +254,6 @@ export class ThumbnailRequestQueue extends EventEmitter { let lastStatusLog = Date.now(); while ((this.queue.length > 0 || this.processing.size > 0) && !this.isCancelled) { - // Log status every 2 seconds if (Date.now() - lastStatusLog > 2000) { console.log( `[ThumbnailQueue] Status - Queue: ${this.queue.length}, Processing: ${this.processing.size}, Completed: ${this.stats.completed}, Failed: ${this.stats.failed}` @@ -269,7 +261,6 @@ export class ThumbnailRequestQueue extends EventEmitter { lastStatusLog = Date.now(); } - // Process up to max concurrent items while ( this.processing.size < concurrency.maxConcurrent && this.queue.length > 0 && @@ -283,14 +274,12 @@ export class ThumbnailRequestQueue extends EventEmitter { console.error(`[ThumbnailQueue] Error processing ${item.fileName}:`, error); }); - // Add delay between requests to prevent overwhelming the printer if (this.queue.length > 0) { await new Promise((resolve) => setTimeout(resolve, concurrency.requestDelay)); } } } - // Wait a bit before checking again await new Promise((resolve) => setTimeout(resolve, 100)); } } finally { @@ -310,7 +299,6 @@ export class ThumbnailRequestQueue extends EventEmitter { try { console.log(`[ThumbnailQueue] Processing ${item.fileName}`); - // Get active context ID const contextManager = getPrinterContextManager(); const contextId = contextManager.getActiveContextId(); @@ -318,7 +306,6 @@ export class ThumbnailRequestQueue extends EventEmitter { throw new Error('No active printer context'); } - // Check if backend is ready if (!this.backendManager || !this.backendManager.isBackendReady(contextId)) { throw new Error('Backend not ready'); } @@ -333,10 +320,8 @@ export class ThumbnailRequestQueue extends EventEmitter { thumbnail: thumbnail.replace('data:image/png;base64,', ''), }; - // Notify main callback item.callback(result); - // Notify any pending callbacks this.notifyPendingCallbacks(item.fileName, result); this.stats.completed++; @@ -357,11 +342,10 @@ export class ThumbnailRequestQueue extends EventEmitter { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.error(`[ThumbnailQueue] Failed to process ${item.fileName}:`, errorMessage); - // Check if we should retry if (item.retryCount < 2 && !this.isCancelled) { item.retryCount++; console.log(`[ThumbnailQueue] Retrying ${item.fileName} (attempt ${item.retryCount + 1})`); - this.queue.unshift(item); // Add back to front of queue + this.queue.unshift(item); } else { const result: ThumbnailResult = { success: false, @@ -369,7 +353,6 @@ export class ThumbnailRequestQueue extends EventEmitter { error: errorMessage, }; - // Notify callbacks item.callback(result); this.notifyPendingCallbacks(item.fileName, result); @@ -396,7 +379,6 @@ export class ThumbnailRequestQueue extends EventEmitter { return { modelType: 'generic-legacy', maxConcurrent: 1, requestDelay: 100 }; } - // Get active context ID const contextManager = getPrinterContextManager(); const contextId = contextManager.getActiveContextId(); @@ -419,12 +401,10 @@ export class ThumbnailRequestQueue extends EventEmitter { * Find existing request in queue or processing */ private findExistingRequest(fileName: string): QueueItem | undefined { - // Check processing first if (this.processing.has(fileName)) { return this.processing.get(fileName); } - // Check queue return this.queue.find((item) => item.fileName === fileName); } diff --git a/src/services/TrackedJobStore.test.ts b/src/services/TrackedJobStore.test.ts new file mode 100644 index 0000000..dcf261b --- /dev/null +++ b/src/services/TrackedJobStore.test.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview Unit tests for TrackedJobStore: one job per printer, progress + * updates, removal, persistence across instances, and corrupt-file recovery. + */ + +import { describe, expect, it } from '@jest/globals'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { TrackedJob } from '../types/spoolman-tracking'; +import { MAX_TRACKED_PRINTERS, TrackedJobStore } from './TrackedJobStore'; + +function tmpFile(): string { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'tracked-job-store-')), 'jobs.json'); +} + +function job(fileName: string): TrackedJob { + return { + fileName, + source: 'upload-3mf', + tools: [{ toolId: 0, slotId: 1, spoolId: 3, usedG: 4, usedM: 1 }], + usageProfile: null, + armedAt: '2026-09-23T00:00:00.000Z', + startedAt: null, + lastProgress: null, + lastProgressAt: null, + }; +} + +describe('TrackedJobStore', () => { + it('keeps one job per printer and replaces it on the next job', () => { + const store = new TrackedJobStore(tmpFile()); + store.setJob('A', job('one.3mf')); + store.setJob('A', job('two.3mf')); + store.setJob('B', job('three.3mf')); + expect(store.getJob('A')?.fileName).toBe('two.3mf'); + expect(store.getJob('B')?.fileName).toBe('three.3mf'); + }); + + it('removes a job once and reports nothing the second time', () => { + const store = new TrackedJobStore(tmpFile()); + store.setJob('A', job('one.3mf')); + expect(store.takeJob('A')?.fileName).toBe('one.3mf'); + expect(store.takeJob('A')).toBeNull(); + expect(store.clearContext('A')).toBe(false); + }); + + it('persists the job and its progress across instances', () => { + const file = tmpFile(); + const store = new TrackedJobStore(file); + store.setJob('A', job('one.3mf')); + store.updateJob('A', { startedAt: '2026-09-23T01:00:00.000Z', lastProgress: 42 }, true); + + const reloaded = new TrackedJobStore(file); + expect(reloaded.getJob('A')).toMatchObject({ startedAt: '2026-09-23T01:00:00.000Z', lastProgress: 42 }); + }); + + it('keeps unpersisted progress in memory only', () => { + const file = tmpFile(); + const store = new TrackedJobStore(file); + store.setJob('A', job('one.3mf')); + store.updateJob('A', { lastProgress: 7 }, false); + expect(store.getJob('A')?.lastProgress).toBe(7); + expect(new TrackedJobStore(file).getJob('A')?.lastProgress).toBeNull(); + }); + + it('starts empty from a corrupt file', () => { + const file = tmpFile(); + fs.writeFileSync(file, '{not json'); + expect(new TrackedJobStore(file).getJob('A')).toBeNull(); + fs.writeFileSync(file, JSON.stringify({ version: 99, jobs: {} })); + expect(new TrackedJobStore(file).getJob('A')).toBeNull(); + }); + + it('drops the oldest printer beyond the cap', () => { + const store = new TrackedJobStore(tmpFile()); + for (let index = 0; index <= MAX_TRACKED_PRINTERS; index++) { + store.setJob(`P${index}`, job(`${index}.3mf`)); + } + expect(store.getJob('P0')).toBeNull(); + expect(store.getJob(`P${MAX_TRACKED_PRINTERS}`)).not.toBeNull(); + }); +}); diff --git a/src/services/TrackedJobStore.ts b/src/services/TrackedJobStore.ts new file mode 100644 index 0000000..fbd3d18 --- /dev/null +++ b/src/services/TrackedJobStore.ts @@ -0,0 +1,177 @@ +/** + * @fileoverview Persistent store of the one Spoolman-tracked job per printer. + * + * A material-station printer runs one job at a time, so the store keeps at + * most one {@link TrackedJob} per printer. The app writes it when it starts a + * job with spool choices from the matching dialog, and deletes it when the job + * ends. The file on disk exists only so a job that is printing survives an app + * restart or a printer reconnect; no spool choice outlives its job. + * + * Records are keyed by printer serial (see station-store-key; the context id + * is the fallback when no serial is known). + * + * Persistence discipline: small JSON file in the data directory, atomic + * write (temp file + rename), tolerant of corrupt content (starts empty and + * logs a warning rather than crashing). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { TrackedJob } from '../types/spoolman-tracking'; +import { getDataPath } from '../utils/setup'; + +/** On-disk schema version. */ +const STORE_VERSION = 1; + +/** Cap of printer keys kept; least-recently-written evicted first. */ +export const MAX_TRACKED_PRINTERS = 32; + +/** On-disk file shape. */ +interface TrackedJobStoreFile { + version: number; + jobs: Record; +} + +function emptyStore(): TrackedJobStoreFile { + return { version: STORE_VERSION, jobs: {} }; +} + +/** + * Atomic JSON write: serialize to a temp file next to the target, then rename + * over the target so a crash mid-write never truncates the previous state. + */ +function atomicWriteJson(filePath: string, data: unknown): void { + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data), 'utf8'); + fs.renameSync(tmpPath, filePath); +} + +/** + * Tracked job store. Instantiate directly with a custom path in tests; the + * app uses the {@link getTrackedJobStore} singleton. + */ +export class TrackedJobStore { + private readonly storePath: string; + private data: TrackedJobStoreFile; + + constructor(storePath?: string) { + this.storePath = storePath ?? path.join(getDataPath(), 'spoolman_tracked_jobs.json'); + this.data = this.load(); + } + + /** Set the tracked job for a printer, replacing any earlier one. */ + public setJob(storeKey: string, job: TrackedJob): void { + delete this.data.jobs[storeKey]; + this.data.jobs[storeKey] = job; + const keys = Object.keys(this.data.jobs); + for (const key of keys.slice(0, Math.max(0, keys.length - MAX_TRACKED_PRINTERS))) { + delete this.data.jobs[key]; + console.warn(`[TrackedJobStore] Printer cap reached; dropped tracked job for ${key}.`); + } + this.persist(); + } + + /** The tracked job for a printer, or null. */ + public getJob(storeKey: string): TrackedJob | null { + return this.data.jobs[storeKey] ?? null; + } + + /** + * Update progress fields of the tracked job in place. + * + * @param persist - Write the change to disk now (callers throttle this) + */ + public updateJob( + storeKey: string, + changes: Partial>, + persist: boolean + ): void { + const job = this.data.jobs[storeKey]; + if (!job) { + return; + } + Object.assign(job, changes); + if (persist) { + this.persist(); + } + } + + /** + * Remove and return the tracked job for a printer. + * + * @returns The removed job, or null when there was none + */ + public takeJob(storeKey: string): TrackedJob | null { + const job = this.data.jobs[storeKey]; + if (!job) { + return null; + } + delete this.data.jobs[storeKey]; + this.persist(); + return job; + } + + /** + * Drop all state for a printer key. + * + * @returns True when the key held a job that was removed + */ + public clearContext(storeKey: string): boolean { + return this.takeJob(storeKey) !== null; + } + + /** Test/debug hook: path of the backing file. */ + public get filePath(): string { + return this.storePath; + } + + private load(): TrackedJobStoreFile { + try { + if (!fs.existsSync(this.storePath)) { + return emptyStore(); + } + const raw = JSON.parse(fs.readFileSync(this.storePath, 'utf8')) as TrackedJobStoreFile; + if ( + typeof raw !== 'object' || + raw === null || + raw.version !== STORE_VERSION || + typeof raw.jobs !== 'object' || + raw.jobs === null + ) { + console.warn('[TrackedJobStore] Unrecognized store content, starting empty:', this.storePath); + return emptyStore(); + } + return raw; + } catch (error) { + console.warn( + `[TrackedJobStore] Failed to load ${this.storePath}, starting empty:`, + error instanceof Error ? error.message : error + ); + return emptyStore(); + } + } + + private persist(): void { + try { + fs.mkdirSync(path.dirname(this.storePath), { recursive: true }); + atomicWriteJson(this.storePath, this.data); + } catch (error) { + // Invariant: persistence failures must never break printing/uploading. + console.error( + '[TrackedJobStore] Failed to persist tracked job store:', + error instanceof Error ? error.message : error + ); + } + } +} + +/** Singleton instance (lazy). */ +let singletonInstance: TrackedJobStore | null = null; + +/** Access the app-wide tracked job store singleton. */ +export function getTrackedJobStore(): TrackedJobStore { + if (!singletonInstance) { + singletonInstance = new TrackedJobStore(); + } + return singletonInstance; +} diff --git a/src/services/calibration/__tests__/Bed.test.ts b/src/services/calibration/__tests__/Bed.test.ts new file mode 100644 index 0000000..faa9194 --- /dev/null +++ b/src/services/calibration/__tests__/Bed.test.ts @@ -0,0 +1,365 @@ +/** + * @fileoverview Unit tests for the Bed model class. + * Tests bed mesh data management, corner calculations, and statistics. + * + * @module services/calibration/__tests__/Bed.test + */ + +import { describe, expect, it } from '@jest/globals'; +import type { BedConfig, MeshData } from '../../../types/calibration'; +import { BedCorner, DEFAULT_BED_CONFIG } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; + +describe('Bed', () => { + // Sample 3x3 mesh for testing + const sampleMesh3x3 = [ + [0.1, 0.0, -0.1], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; + + // Sample 5x5 mesh with more variation + const sampleMesh5x5 = [ + [0.2, 0.15, 0.1, 0.05, 0.0], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.1, 0.05, 0.0, -0.05, -0.1], + [0.05, 0.0, -0.05, -0.1, -0.15], + [0.0, -0.05, -0.1, -0.15, -0.2], + ]; + + const config3x3: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 3, + meshPointsY: 3, + }; + + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + describe('constructor', () => { + it('should create a Bed with default config', () => { + const bed = new Bed(); + expect(bed.config).toEqual(DEFAULT_BED_CONFIG); + expect(bed.hasMeshData).toBe(false); + }); + + it('should create a Bed with custom config', () => { + const bed = new Bed(config3x3); + expect(bed.config.meshPointsX).toBe(3); + expect(bed.config.meshPointsY).toBe(3); + expect(bed.config.sizeX).toBe(220); + expect(bed.config.sizeY).toBe(220); + }); + }); + + describe('setMeshData', () => { + it('should accept valid mesh data', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + expect(bed.hasMeshData).toBe(true); + expect(bed.meshData).toEqual(sampleMesh3x3); + }); + + it('should deep copy mesh data', () => { + const bed = new Bed(config3x3); + const originalMesh = [ + [0.1, 0.0, -0.1], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; + bed.setMeshData(originalMesh); + + // Modify original + originalMesh[0][0] = 999; + + // Bed data should be unchanged + expect(bed.meshData?.[0][0]).toBe(0.1); + }); + + it('should throw on invalid row count', () => { + const bed = new Bed(config3x3); + const invalidMesh = [ + [0.1, 0.0, -0.1], + [0.05, 0.0, -0.05], + ]; // Only 2 rows + expect(() => bed.setMeshData(invalidMesh)).toThrow(/Invalid mesh row count/); + }); + + it('should throw on invalid column count', () => { + const bed = new Bed(config3x3); + const invalidMesh = [ + [0.1, 0.0], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; // First row has only 2 columns + expect(() => bed.setMeshData(invalidMesh)).toThrow(/Invalid mesh column count/); + }); + }); + + describe('loadFromMeshData', () => { + it('should load mesh from MeshData object', () => { + const bed = new Bed(); + const meshData: MeshData = { + matrix: sampleMesh5x5, + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 5, + pointsY: 5, + profileName: 'default', + }; + + bed.loadFromMeshData(meshData); + expect(bed.hasMeshData).toBe(true); + expect(bed.config.meshPointsX).toBe(5); + expect(bed.config.meshPointsY).toBe(5); + }); + }); + + describe('clearMeshData', () => { + it('should clear mesh data', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + expect(bed.hasMeshData).toBe(true); + + bed.clearMeshData(); + expect(bed.hasMeshData).toBe(false); + expect(bed.meshData).toBeNull(); + }); + }); + + describe('getCornerHeight', () => { + it('should get correct corner heights without averaging', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(bed.getCornerHeight(BedCorner.FRONT_LEFT)).toBe(0.1); + expect(bed.getCornerHeight(BedCorner.FRONT_RIGHT)).toBe(-0.1); + expect(bed.getCornerHeight(BedCorner.REAR_LEFT)).toBe(0.0); + expect(bed.getCornerHeight(BedCorner.REAR_RIGHT)).toBe(-0.1); + }); + + it('should throw when no mesh data loaded', () => { + const bed = new Bed(config3x3); + expect(() => bed.getCornerHeight(BedCorner.FRONT_LEFT)).toThrow('No mesh data loaded'); + }); + + it('should average corner heights when averaging size > 0', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + // With averaging size 1, should average a 3x3 area around corner (clamped to mesh bounds) + const avgFrontLeft = bed.getCornerHeight(BedCorner.FRONT_LEFT, 1); + // Front-left (0,0) with averaging: (0.1 + 0.0 + 0.05 + 0.0) / 4 + const expected = (0.1 + 0.0 + 0.05 + 0.0) / 4; + expect(avgFrontLeft).toBeCloseTo(expected, 5); + }); + }); + + describe('getCornerValues', () => { + it('should return all corner values', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const corners = bed.getCornerValues(); + expect(corners.frontLeft).toBe(0.1); + expect(corners.frontRight).toBe(-0.1); + expect(corners.rearLeft).toBe(0.0); + expect(corners.rearRight).toBe(-0.1); + }); + }); + + describe('getPointHeight', () => { + it('should return height at specific point', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(bed.getPointHeight(0, 0)).toBe(0.1); + expect(bed.getPointHeight(1, 1)).toBe(0.0); + expect(bed.getPointHeight(2, 2)).toBe(-0.1); + }); + + it('should throw for out of range coordinates', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(() => bed.getPointHeight(-1, 0)).toThrow(/out of range/); + expect(() => bed.getPointHeight(0, 5)).toThrow(/out of range/); + expect(() => bed.getPointHeight(10, 0)).toThrow(/out of range/); + }); + }); + + describe('getMeshStats', () => { + it('should calculate correct mesh statistics', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const stats = bed.getMeshStats(); + + // min = -0.1, max = 0.1, range = 0.2 + expect(stats.min).toBeCloseTo(-0.1, 5); + expect(stats.max).toBeCloseTo(0.1, 5); + expect(stats.range).toBeCloseTo(0.2, 5); + + // Mean of all values + const allValues = sampleMesh3x3.flat(); + const expectedMean = allValues.reduce((a, b) => a + b, 0) / allValues.length; + expect(stats.mean).toBeCloseTo(expectedMean, 5); + + // Standard deviation should be positive + expect(stats.standardDeviation).toBeGreaterThan(0); + }); + + it('should throw when no mesh data loaded', () => { + const bed = new Bed(config3x3); + expect(() => bed.getMeshStats()).toThrow('No mesh data loaded'); + }); + }); + + describe('getMinValue/getMaxValue/getRange/getMeanValue', () => { + it('should return correct values', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + expect(bed.getMinValue()).toBeCloseTo(-0.1, 5); + expect(bed.getMaxValue()).toBeCloseTo(0.1, 5); + expect(bed.getRange()).toBeCloseTo(0.2, 5); + }); + }); + + describe('getMmPerPoint', () => { + it('should calculate correct mm per point', () => { + const bed = new Bed(config3x3); + const mmPerPoint = bed.getMmPerPoint(); + + // 220mm / (3-1) = 110mm per point + expect(mmPerPoint.x).toBe(110); + expect(mmPerPoint.y).toBe(110); + }); + + it('should handle different mesh sizes', () => { + const bed = new Bed(config5x5); + const mmPerPoint = bed.getMmPerPoint(); + + // 220mm / (5-1) = 55mm per point + expect(mmPerPoint.x).toBe(55); + expect(mmPerPoint.y).toBe(55); + }); + }); + + describe('generateIdealPlane', () => { + it('should generate flat plane at mean height', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const idealPlane = bed.generateIdealPlane(); + const meanHeight = bed.getMeanValue(); + + // All values should equal the mean + for (const row of idealPlane) { + for (const val of row) { + expect(val).toBeCloseTo(meanHeight, 5); + } + } + + // Should have correct dimensions + expect(idealPlane.length).toBe(3); + expect(idealPlane[0].length).toBe(3); + }); + }); + + describe('calculateDeviationMap', () => { + it('should calculate deviations from ideal plane', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const deviationMap = bed.calculateDeviationMap(); + const meanHeight = bed.getMeanValue(); + + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + const expected = sampleMesh3x3[row][col] - meanHeight; + expect(deviationMap[row][col]).toBeCloseTo(expected, 5); + } + } + }); + }); + + describe('simulateAdjustment', () => { + it('should simulate corner adjustments', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const adjustedMesh = bed.simulateAdjustment({ + [BedCorner.FRONT_LEFT]: -0.1, // Lower front-left by 0.1 + }); + + // Front-left corner should be affected most + expect(adjustedMesh[0][0]).toBeLessThan(sampleMesh3x3[0][0]); + + // Original mesh should be unchanged + expect(bed.meshData?.[0][0]).toBe(0.1); + }); + + it('should handle multiple corner adjustments', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const adjustedMesh = bed.simulateAdjustment({ + [BedCorner.FRONT_LEFT]: -0.05, + [BedCorner.REAR_RIGHT]: 0.05, + }); + + // Both corners should be affected + expect(adjustedMesh[0][0]).toBeLessThan(sampleMesh3x3[0][0]); + expect(adjustedMesh[2][2]).toBeGreaterThan(sampleMesh3x3[2][2]); + }); + + it('should skip zero adjustments', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const adjustedMesh = bed.simulateAdjustment({ + [BedCorner.FRONT_LEFT]: 0, + }); + + // Mesh should be unchanged + expect(adjustedMesh).toEqual(sampleMesh3x3); + }); + }); + + describe('clone', () => { + it('should create an independent copy', () => { + const bed = new Bed(config3x3); + bed.setMeshData(sampleMesh3x3); + + const cloned = bed.clone(); + + expect(cloned.config).toEqual(bed.config); + expect(cloned.meshData).toEqual(bed.meshData); + + // Modify cloned mesh + if (!cloned.meshData) { + throw new Error('Expected cloned mesh data'); + } + cloned.meshData[0][0] = 999; + + // Original should be unchanged + expect(bed.meshData?.[0][0]).toBe(0.1); + }); + + it('should clone bed without mesh data', () => { + const bed = new Bed(config3x3); + const cloned = bed.clone(); + + expect(cloned.hasMeshData).toBe(false); + expect(cloned.config).toEqual(bed.config); + }); + }); +}); diff --git a/src/services/calibration/__tests__/DeviationAnalyzer.test.ts b/src/services/calibration/__tests__/DeviationAnalyzer.test.ts new file mode 100644 index 0000000..d675f7b --- /dev/null +++ b/src/services/calibration/__tests__/DeviationAnalyzer.test.ts @@ -0,0 +1,346 @@ +/** + * @fileoverview Unit tests for the DeviationAnalyzer class. + * Tests mesh deviation analysis and calibration strategy recommendations. + * + * @module services/calibration/__tests__/DeviationAnalyzer.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig } from '../../../types/calibration'; +import { BedCorner } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { DeviationAnalyzer } from '../engine/DeviationAnalyzer'; + +describe('DeviationAnalyzer', () => { + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + // Tilted mesh (front-left high, rear-right low) + const tiltedMesh = [ + [0.2, 0.15, 0.1, 0.05, 0.0], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.1, 0.05, 0.0, -0.05, -0.1], + [0.05, 0.0, -0.05, -0.1, -0.15], + [0.0, -0.05, -0.1, -0.15, -0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ]; + + // Slight deviation (within tape threshold) + const slightMesh = [ + [0.015, 0.01, 0.005, 0.0, -0.005], + [0.01, 0.005, 0.0, -0.005, -0.01], + [0.005, 0.0, 0.0, 0.0, -0.005], + [0.0, -0.005, 0.0, 0.005, 0.01], + [-0.005, -0.01, 0.005, 0.01, 0.015], + ]; + + // X-axis tilt (belt sync needed) + const xTiltMesh = [ + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + ]; + + let bed: Bed; + let analyzer: DeviationAnalyzer; + + beforeEach(() => { + bed = new Bed(config5x5); + bed.setMeshData(tiltedMesh); + analyzer = new DeviationAnalyzer(bed); + }); + + describe('constructor', () => { + it('should create analyzer with default options', () => { + const analyzer = new DeviationAnalyzer(bed); + expect(analyzer).toBeDefined(); + }); + + it('should create analyzer with custom options', () => { + const analyzer = new DeviationAnalyzer(bed, { + cornerAveragingSize: 2, + screwThreshold: 0.05, + tapeThreshold: 0.03, + }); + expect(analyzer).toBeDefined(); + }); + }); + + describe('setOptions', () => { + it('should update analyzer options', () => { + analyzer.setOptions({ screwThreshold: 0.1 }); + const result = analyzer.analyze(); + expect(result).toBeDefined(); + }); + }); + + describe('setCornerAveragingSize', () => { + it('should update corner averaging size', () => { + analyzer.setCornerAveragingSize(2); + const stats = analyzer.getStats(); + expect(stats).toBeDefined(); + }); + + it('should clamp negative values to 0', () => { + analyzer.setCornerAveragingSize(-5); + // Should not throw and stats should still work + const stats = analyzer.getStats(); + expect(stats).toBeDefined(); + }); + }); + + describe('setScrewThreshold', () => { + it('should update screw threshold', () => { + analyzer.setScrewThreshold(0.1); + const result = analyzer.analyze(); + // With higher threshold, might not need screw adjustment + expect(result).toBeDefined(); + }); + }); + + describe('setTapeThreshold', () => { + it('should update tape threshold', () => { + analyzer.setTapeThreshold(0.05); + const result = analyzer.analyze(); + expect(result).toBeDefined(); + }); + }); + + describe('getStats', () => { + it('should calculate deviation statistics', () => { + const stats = analyzer.getStats(); + + expect(stats.meanHeight).toBeDefined(); + expect(stats.maxDeviation).toBeGreaterThan(0); + expect(stats.cornerDeviations).toBeDefined(); + expect(stats.hasCriticalDeviation).toBe(true); // Tilted mesh has deviations + }); + + it('should not have critical deviation for level mesh', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelAnalyzer = new DeviationAnalyzer(levelBed); + + const stats = levelAnalyzer.getStats(); + expect(stats.hasCriticalDeviation).toBe(false); + }); + + it('should calculate corner deviations from mean', () => { + const stats = analyzer.getStats(); + + // All corner deviations should be non-negative (absolute values) + expect(stats.cornerDeviations.frontLeft).toBeGreaterThanOrEqual(0); + expect(stats.cornerDeviations.frontRight).toBeGreaterThanOrEqual(0); + expect(stats.cornerDeviations.rearLeft).toBeGreaterThanOrEqual(0); + expect(stats.cornerDeviations.rearRight).toBeGreaterThanOrEqual(0); + }); + }); + + describe('analyzeLevelingStage', () => { + it('should identify need for screw adjustment', () => { + const stage = analyzer.analyzeLevelingStage(); + + expect(stage.needsScrewAdjustment).toBe(true); + expect(stage.maxCornerDiff).toBeGreaterThan(0); + }); + + it('should identify that screws can fix the problem', () => { + const stage = analyzer.analyzeLevelingStage(); + + // Tilted mesh deviation is within max adjustment range + expect(stage.canUseScrews).toBe(true); + }); + + it('should identify problematic corners', () => { + const stage = analyzer.analyzeLevelingStage(); + + expect(stage.problematicCorners.length).toBeGreaterThan(0); + }); + + it('should not recommend adjustment for level mesh', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelAnalyzer = new DeviationAnalyzer(levelBed); + + const stage = levelAnalyzer.analyzeLevelingStage(); + + expect(stage.needsScrewAdjustment).toBe(false); + expect(stage.needsTape).toBe(false); + expect(stage.problematicCorners.length).toBe(0); + }); + }); + + describe('analyze', () => { + it('should perform full analysis', () => { + const result = analyzer.analyze(); + + expect(result.meshRange).toBeGreaterThan(0); + expect(result.maxDeviation).toBeGreaterThan(0); + expect(result.standardDeviation).toBeGreaterThan(0); + expect(result.cornerDeviations).toBeDefined(); + expect(result.referenceCorner).toBeDefined(); + expect(result.recommendations).toBeDefined(); + }); + + it('should identify reference corner as lowest', () => { + const result = analyzer.analyze(); + + // Rear-right is lowest in tilted mesh + expect(result.referenceCorner).toBe(BedCorner.REAR_RIGHT); + }); + + it('should recommend screw adjustment for tilted bed', () => { + const result = analyzer.analyze(); + + expect(result.recommendations.needsScrewAdjust).toBe(true); + }); + + it('should recommend belt sync for X-axis tilt', () => { + const xTiltBed = new Bed(config5x5); + xTiltBed.setMeshData(xTiltMesh); + const xTiltAnalyzer = new DeviationAnalyzer(xTiltBed, { + cornerAveragingSize: 0, + screwThreshold: 0.02, + tapeThreshold: 0.02, + beltThreshold: 0.1, + screwConfig: { pitch: 0.5, minAdjust: 0.01, maxAdjust: 2.0 }, + }); + + const result = xTiltAnalyzer.analyze(); + + // Front-left to front-right difference is 0.2, which exceeds 0.1 threshold + expect(result.recommendations.needsBeltSync).toBe(true); + }); + + it('should not recommend actions for level bed', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelAnalyzer = new DeviationAnalyzer(levelBed); + + const result = levelAnalyzer.analyze(); + + expect(result.recommendations.needsBeltSync).toBe(false); + expect(result.recommendations.needsScrewAdjust).toBe(false); + expect(result.recommendations.needsTapeCompensation).toBe(false); + }); + }); + + describe('getIdealPlane', () => { + it('should return ideal plane at mean height', () => { + const idealPlane = analyzer.getIdealPlane(); + const meanHeight = bed.getMeanValue(); + + // All values should equal mean + for (const row of idealPlane) { + for (const val of row) { + expect(val).toBeCloseTo(meanHeight, 5); + } + } + }); + }); + + describe('estimateBedAfterScrewAdjustment', () => { + it('should simulate screw adjustment effect', () => { + const adjusted = analyzer.estimateBedAfterScrewAdjustment(); + + expect(adjusted.length).toBe(5); + expect(adjusted[0].length).toBe(5); + + // Range should be reduced after adjustment + let minAdj = Infinity; + let maxAdj = -Infinity; + for (const row of adjusted) { + for (const val of row) { + minAdj = Math.min(minAdj, val); + maxAdj = Math.max(maxAdj, val); + } + } + const adjustedRange = maxAdj - minAdj; + const originalRange = bed.getRange(); + + // Adjusted range should be less than or equal to original + expect(adjustedRange).toBeLessThanOrEqual(originalRange + 0.001); + }); + }); + + describe('findOptimalStrategy', () => { + it('should find optimal calibration strategy', () => { + const strategy = analyzer.findOptimalStrategy(); + + expect(strategy.originalDeviation).toBeGreaterThan(0); + expect(strategy.deviationAfterScrews).toBeDefined(); + expect(strategy.needsScrews).toBe(true); + expect(strategy.simulatedBedAfterScrews).toBeDefined(); + }); + + it('should show improvement with screws', () => { + const strategy = analyzer.findOptimalStrategy(); + + // Deviation should be reduced after screws + expect(strategy.deviationAfterScrews).toBeLessThanOrEqual(strategy.originalDeviation); + }); + }); + + describe('getScrewSolver', () => { + it('should return screw solver instance', () => { + const solver = analyzer.getScrewSolver(); + expect(solver).toBeDefined(); + + // Should be functional + solver.autoSelectReferenceCorner(); + const adjustments = solver.calculateAdjustments(); + expect(adjustments.length).toBe(4); + }); + }); + + describe('edge cases', () => { + it('should handle mesh with all same values', () => { + const sameMesh = [ + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + [0.1, 0.1, 0.1, 0.1, 0.1], + ]; + + const sameBed = new Bed(config5x5); + sameBed.setMeshData(sameMesh); + const sameAnalyzer = new DeviationAnalyzer(sameBed); + + const result = sameAnalyzer.analyze(); + expect(result.meshRange).toBeCloseTo(0, 5); + expect(result.recommendations.needsScrewAdjust).toBe(false); + }); + + it('should handle mesh with slight variations', () => { + const slightBed = new Bed(config5x5); + slightBed.setMeshData(slightMesh); + const slightAnalyzer = new DeviationAnalyzer(slightBed, { + cornerAveragingSize: 0, + screwThreshold: 0.02, + tapeThreshold: 0.02, + beltThreshold: 0.1, + screwConfig: { pitch: 0.5, minAdjust: 0.01, maxAdjust: 2.0 }, + }); + + const result = slightAnalyzer.analyze(); + + // With 0.02 threshold and max corner deviation ~0.015, should not need major adjustment + expect(result.meshRange).toBeLessThan(0.05); + }); + }); +}); diff --git a/src/services/calibration/__tests__/FFTProcessor.test.ts b/src/services/calibration/__tests__/FFTProcessor.test.ts new file mode 100644 index 0000000..cf1236f --- /dev/null +++ b/src/services/calibration/__tests__/FFTProcessor.test.ts @@ -0,0 +1,316 @@ +/** + * @fileoverview Unit tests for the FFTProcessor class. + * Tests FFT computation, CSV parsing, and power spectral density analysis. + * + * @module services/calibration/__tests__/FFTProcessor.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { FFTProcessor } from '../shaper/FFTProcessor'; + +describe('FFTProcessor', () => { + let processor: FFTProcessor; + + beforeEach(() => { + processor = new FFTProcessor(); + }); + + describe('constructor', () => { + it('should create processor with default options', () => { + const proc = new FFTProcessor(); + expect(proc).toBeDefined(); + }); + + it('should create processor with custom options', () => { + const proc = new FFTProcessor({ + windowFunction: 'hamming', + minFreq: 10, + maxFreq: 150, + }); + expect(proc).toBeDefined(); + }); + }); + + describe('setOptions', () => { + it('should update processing options', () => { + processor.setOptions({ minFreq: 20, maxFreq: 100 }); + // Options should be updated (verified by processing behavior) + expect(processor).toBeDefined(); + }); + }); + + describe('parseCSV', () => { + it('should parse valid accelerometer CSV', () => { + const csvContent = `time,accel_x,accel_y,accel_z +0.000,0.1,0.2,9.8 +0.001,0.15,0.25,9.81 +0.002,0.12,0.22,9.79 +0.003,0.11,0.21,9.8 +0.004,0.13,0.23,9.82`; + + const data = processor.parseCSV(csvContent); + + expect(data.time.length).toBe(5); + expect(data.accelX.length).toBe(5); + expect(data.accelY.length).toBe(5); + expect(data.accelZ.length).toBe(5); + + expect(data.time[0]).toBe(0); + expect(data.accelX[0]).toBeCloseTo(0.1, 5); + expect(data.sampleRate).toBeGreaterThan(0); + }); + + it('should skip header line automatically', () => { + const csvWithHeader = `time,accel_x,accel_y,accel_z +0.0,1.0,2.0,9.8 +0.001,1.1,2.1,9.81`; + + const data = processor.parseCSV(csvWithHeader); + expect(data.time.length).toBe(2); + expect(data.time[0]).toBe(0); + }); + + it('should skip comment lines', () => { + const csvWithComments = `# Comment line +time,accel_x,accel_y,accel_z +# Another comment +0.0,1.0,2.0,9.8 +0.001,1.1,2.1,9.81`; + + const data = processor.parseCSV(csvWithComments); + expect(data.time.length).toBe(2); + }); + + it('should calculate sample rate from time values', () => { + const csvContent = `time,accel_x,accel_y,accel_z +0.0,0.1,0.2,9.8 +0.001,0.1,0.2,9.8 +0.002,0.1,0.2,9.8 +0.003,0.1,0.2,9.8`; + + const data = processor.parseCSV(csvContent); + // Delta t = 0.001s, so sample rate = 1000 Hz + expect(data.sampleRate).toBeCloseTo(1000, 0); + }); + + it('should handle empty CSV', () => { + const emptyCSV = `time,accel_x,accel_y,accel_z`; + const data = processor.parseCSV(emptyCSV); + expect(data.time.length).toBe(0); + }); + }); + + describe('processAxis', () => { + it('should compute power spectrum for sinusoidal input', () => { + const sampleRate = 1000; + const frequency = 50; + const samples = 1024; + const data: number[] = []; + + for (let i = 0; i < samples; i++) { + const t = i / sampleRate; + data.push(Math.sin(2 * Math.PI * frequency * t)); + } + + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.frequencies.length).toBeGreaterThan(0); + expect(spectrum.power.length).toBe(spectrum.frequencies.length); + expect(spectrum.peakFrequency).toBeGreaterThan(0); + + // Peak should be near 50 Hz (within FFT resolution) + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + + it('should filter frequencies to specified range', () => { + processor.setOptions({ minFreq: 20, maxFreq: 100 }); + + const sampleRate = 1000; + const data = new Array(512).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + + const spectrum = processor.processAxis(data, sampleRate); + + // All frequencies should be within range + for (const freq of spectrum.frequencies) { + expect(freq).toBeGreaterThanOrEqual(20); + expect(freq).toBeLessThanOrEqual(100); + } + }); + + it('should normalize power when requested', () => { + processor.setOptions({ normalize: true }); + + const sampleRate = 1000; + const data = new Array(512).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + + const spectrum = processor.processAxis(data, sampleRate); + + // Max power should be 1 when normalized + const maxPower = Math.max(...spectrum.power); + expect(maxPower).toBeCloseTo(1, 5); + }); + + it('should handle constant signal (DC)', () => { + const sampleRate = 1000; + const data = new Array(256).fill(1.0); // Constant value + + const spectrum = processor.processAxis(data, sampleRate); + + // Should still produce a spectrum (mostly DC component) + expect(spectrum.frequencies.length).toBeGreaterThan(0); + }); + + it('should handle noisy signal', () => { + const sampleRate = 1000; + const data = new Array(512).fill(0).map(() => Math.random() - 0.5); + + const spectrum = processor.processAxis(data, sampleRate); + + // Should still produce valid spectrum + expect(spectrum.frequencies.length).toBeGreaterThan(0); + expect(spectrum.power.every((p) => !Number.isNaN(p))).toBe(true); + }); + }); + + describe('processData', () => { + it('should process all three axes', () => { + const sampleRate = 1000; + const samples = 256; + + const mockData = { + time: new Array(samples).fill(0).map((_, i) => i / sampleRate), + accelX: new Array(samples).fill(0).map((_, i) => Math.sin(2 * Math.PI * 30 * (i / sampleRate))), + accelY: new Array(samples).fill(0).map((_, i) => Math.sin(2 * Math.PI * 40 * (i / sampleRate))), + accelZ: new Array(samples).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))), + sampleRate, + }; + + const result = processor.processData(mockData); + + expect(result.x).toBeDefined(); + expect(result.y).toBeDefined(); + expect(result.z).toBeDefined(); + + // Each axis should have different peak (approximately) + expect(result.x.peakFrequency).toBeGreaterThan(25); + expect(result.x.peakFrequency).toBeLessThan(35); + + expect(result.y.peakFrequency).toBeGreaterThan(35); + expect(result.y.peakFrequency).toBeLessThan(45); + + expect(result.z.peakFrequency).toBeGreaterThan(45); + expect(result.z.peakFrequency).toBeLessThan(55); + }); + }); + + describe('findPeaks', () => { + it('should find resonance peaks in spectrum', () => { + const sampleRate = 1000; + const samples = 1024; + const data: number[] = []; + + for (let i = 0; i < samples; i++) { + const t = i / sampleRate; + // 30 Hz and 70 Hz components + data.push(Math.sin(2 * Math.PI * 30 * t) + 0.5 * Math.sin(2 * Math.PI * 70 * t)); + } + + const spectrum = processor.processAxis(data, sampleRate); + const peaks = processor.findPeaks(spectrum, 0.2); + + // Should find at least one peak + expect(peaks.length).toBeGreaterThan(0); + + // One peak should be near 30 Hz + const has30Hz = peaks.some((p) => p > 25 && p < 35); + expect(has30Hz).toBe(true); + }); + + it('should return empty array for flat spectrum', () => { + const flatSpectrum = { + frequencies: [10, 20, 30, 40, 50], + power: [0.5, 0.5, 0.5, 0.5, 0.5], + peakFrequency: 30, + peakPower: 0.5, + }; + + const peaks = processor.findPeaks(flatSpectrum, 0.3); + expect(peaks.length).toBe(0); + }); + + it('should handle very short spectrum', () => { + const shortSpectrum = { + frequencies: [10, 20], + power: [0.5, 0.8], + peakFrequency: 20, + peakPower: 0.8, + }; + + const peaks = processor.findPeaks(shortSpectrum); + // Too short to find peaks (need at least 3 points for local maximum) + expect(peaks.length).toBe(0); + }); + }); + + describe('window functions', () => { + it('should apply Hanning window by default', () => { + processor.setOptions({ windowFunction: 'hanning' }); + + const sampleRate = 1000; + const data = new Array(256).fill(1.0); + const spectrum = processor.processAxis(data, sampleRate); + + // Should produce valid spectrum (windowing reduces DC leakage) + expect(spectrum.frequencies.length).toBeGreaterThan(0); + }); + + it('should apply Hamming window', () => { + processor.setOptions({ windowFunction: 'hamming' }); + + const sampleRate = 1000; + const data = new Array(256).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + + it('should apply Blackman window', () => { + processor.setOptions({ windowFunction: 'blackman' }); + + const sampleRate = 1000; + const data = new Array(256).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + + it('should work without window function', () => { + processor.setOptions({ windowFunction: 'none' }); + + const sampleRate = 1000; + const data = new Array(256).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + const spectrum = processor.processAxis(data, sampleRate); + + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + }); + + describe('padding', () => { + it('should pad non-power-of-2 input to next power of 2', () => { + const sampleRate = 1000; + // 300 samples - should be padded to 512 + const data = new Array(300).fill(0).map((_, i) => Math.sin(2 * Math.PI * 50 * (i / sampleRate))); + + const spectrum = processor.processAxis(data, sampleRate); + + // Should still produce valid spectrum + expect(spectrum.frequencies.length).toBeGreaterThan(0); + expect(spectrum.peakFrequency).toBeGreaterThan(45); + expect(spectrum.peakFrequency).toBeLessThan(55); + }); + }); +}); diff --git a/src/services/calibration/__tests__/KlipperConfigParser.test.ts b/src/services/calibration/__tests__/KlipperConfigParser.test.ts new file mode 100644 index 0000000..cc1ee79 --- /dev/null +++ b/src/services/calibration/__tests__/KlipperConfigParser.test.ts @@ -0,0 +1,327 @@ +/** + * @fileoverview Unit tests for the KlipperConfigParser class. + * Tests parsing of Klipper printer.cfg files with bed mesh data. + * + * @module services/calibration/__tests__/KlipperConfigParser.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { KlipperConfigParser, klipperConfigParser } from '../parsers/KlipperConfigParser'; + +describe('KlipperConfigParser', () => { + // Sample valid printer.cfg content (using spaces after #*#) + const validConfig = `# Klipper config file +[printer] +kinematics: cartesian + +#*# <---------------------- SAVE_CONFIG ----------------------> +#*# DO NOT EDIT THIS BLOCK OR BELOW. The contents are auto-generated. +#*# +#*# [bed_mesh default] +#*# version = 1 +#*# points = +#*# 0.025000, 0.012500, -0.003125, 0.009375, 0.015625, 0.018750, 0.031250 +#*# 0.018750, 0.005625, -0.010000, 0.000000, 0.009375, 0.012500, 0.025000 +#*# 0.012500, -0.001250, -0.016875, -0.006250, 0.003125, 0.006250, 0.018750 +#*# 0.006250, -0.007500, -0.023750, -0.012500, -0.003125, 0.000000, 0.012500 +#*# 0.000000, -0.013750, -0.030625, -0.018750, -0.009375, -0.006250, 0.006250 +#*# -0.006250, -0.020000, -0.037500, -0.025000, -0.015625, -0.012500, 0.000000 +#*# -0.012500, -0.026250, -0.044375, -0.031250, -0.021875, -0.018750, -0.006250 +#*# x_count = 7 +#*# y_count = 7 +#*# mesh_x_pps = 2 +#*# mesh_y_pps = 2 +#*# algo = bicubic +#*# tension = 0.2 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + + // Config with multiple profiles + const multiProfileConfig = `#*# [bed_mesh default] +#*# version = 1 +#*# points = +#*# 0.1, 0.0, -0.1 +#*# 0.05, 0.0, -0.05 +#*# 0.0, -0.05, -0.1 +#*# x_count = 3 +#*# y_count = 3 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0 +#*# +#*# [bed_mesh petg] +#*# version = 1 +#*# points = +#*# 0.15, 0.05, -0.05 +#*# 0.1, 0.0, -0.1 +#*# 0.05, -0.05, -0.15 +#*# x_count = 3 +#*# y_count = 3 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + + let parser: KlipperConfigParser; + + beforeEach(() => { + parser = new KlipperConfigParser(); + }); + + describe('parseConfigFile', () => { + it('should parse valid config with default profile', () => { + const result = parser.parseConfigFile(validConfig); + + expect(result.success).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data?.profileName).toBe('default'); + expect(result.data?.pointsX).toBe(7); + expect(result.data?.pointsY).toBe(7); + expect(result.data?.minX).toBe(15.0); + expect(result.data?.maxX).toBe(205.0); + expect(result.data?.minY).toBe(15.0); + expect(result.data?.maxY).toBe(205.0); + }); + + it('should parse mesh matrix correctly', () => { + const result = parser.parseConfigFile(validConfig); + + expect(result.success).toBe(true); + expect(result.data?.matrix.length).toBe(7); // 7 rows + expect(result.data?.matrix[0].length).toBe(7); // 7 columns + + expect(result.data?.matrix[0][0]).toBeCloseTo(0.025, 5); + expect(result.data?.matrix[0][1]).toBeCloseTo(0.0125, 5); + expect(result.data?.matrix[0][2]).toBeCloseTo(-0.003125, 5); + }); + + it('should parse specific profile from multi-profile config', () => { + const result = parser.parseConfigFile(multiProfileConfig, 'petg'); + + expect(result.success).toBe(true); + expect(result.data?.profileName).toBe('petg'); + expect(result.data?.matrix[0][0]).toBeCloseTo(0.15, 5); + }); + + it('should fail for non-existent profile', () => { + const result = parser.parseConfigFile(multiProfileConfig, 'nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toContain("No mesh data found for profile 'nonexistent'"); + }); + + it('should fail for config without mesh data', () => { + const noMeshConfig = ` +[printer] +kinematics: cartesian +`; + const result = parser.parseConfigFile(noMeshConfig); + + expect(result.success).toBe(false); + expect(result.error).toContain('No mesh data found'); + }); + + it('should fail for missing required parameters', () => { + const incompleteConfig = `#*# [bed_mesh default] +#*# version = 1 +#*# points = +#*# 0.1, 0.0, -0.1 +#*# x_count = 3 +#*# y_count = 1 +#*# min_x = 15.0 +#*# max_x = 205.0`; + const result = parser.parseConfigFile(incompleteConfig); + + expect(result.success).toBe(false); + expect(result.error).toContain('Missing required parameter'); + }); + + it('should fail for dimension mismatch', () => { + const mismatchConfig = `#*# [bed_mesh default] +#*# points = +#*# 0.1, 0.0, -0.1 +#*# 0.05, 0.0, -0.05 +#*# x_count = 3 +#*# y_count = 3 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + const result = parser.parseConfigFile(mismatchConfig); + + expect(result.success).toBe(false); + expect(result.error).toContain('Row count mismatch'); + }); + }); + + describe('getAvailableProfiles', () => { + it('should list all available profiles', () => { + const profiles = parser.getAvailableProfiles(multiProfileConfig); + + expect(profiles).toContain('default'); + expect(profiles).toContain('petg'); + expect(profiles.length).toBe(2); + }); + + it('should return empty array for no profiles', () => { + const noProfileConfig = ` +[printer] +kinematics: cartesian +`; + const profiles = parser.getAvailableProfiles(noProfileConfig); + + expect(profiles.length).toBe(0); + }); + }); + + describe('validateMeshData', () => { + it('should validate correct mesh data', () => { + const result = parser.parseConfigFile(validConfig); + expect(result.success).toBe(true); + + if (!result.data) { + throw new Error('Expected parsed mesh data'); + } + const validation = parser.validateMeshData(result.data); + expect(validation.isValid).toBe(true); + }); + + it('should reject mesh with dimension mismatch', () => { + const validation = parser.validateMeshData({ + matrix: [ + [0.1, 0.0], + [0.05, 0.0, -0.05], + ], // Inconsistent columns + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 2, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain("doesn't match pointsX"); + }); + + it('should reject mesh with unreasonable values', () => { + const validation = parser.validateMeshData({ + matrix: [[15.0, 0.0, -0.1]], // 15mm is way too high + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 1, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Unreasonable deviation'); + }); + + it('should reject mesh with invalid coordinate bounds', () => { + const validation = parser.validateMeshData({ + matrix: [[0.1, 0.0, -0.1]], + minX: 205, // minX > maxX + maxX: 15, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 1, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid X bounds'); + }); + + it('should reject mesh with NaN values', () => { + const validation = parser.validateMeshData({ + matrix: [[0.1, NaN, -0.1]], + minX: 15, + maxX: 205, + minY: 15, + maxY: 205, + pointsX: 3, + pointsY: 1, + profileName: 'test', + }); + + expect(validation.isValid).toBe(false); + expect(validation.error).toContain('Invalid value'); + }); + }); + + describe('createBedConfigFromMesh', () => { + it('should create bed config from mesh data', () => { + const result = parser.parseConfigFile(validConfig); + expect(result.success).toBe(true); + + if (!result.data) { + throw new Error('Expected parsed mesh data'); + } + const bedConfig = parser.createBedConfigFromMesh(result.data); + + expect(bedConfig.sizeX).toBe(190); // 205 - 15 + expect(bedConfig.sizeY).toBe(190); // 205 - 15 + expect(bedConfig.meshPointsX).toBe(7); + expect(bedConfig.meshPointsY).toBe(7); + }); + }); + + describe('singleton instance', () => { + it('should provide a singleton parser instance', () => { + expect(klipperConfigParser).toBeInstanceOf(KlipperConfigParser); + + const result = klipperConfigParser.parseConfigFile(validConfig); + expect(result.success).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should handle empty lines in config', () => { + const configWithEmptyLines = ` +#*# [bed_mesh default] + +#*# points = +#*# 0.1, 0.0, -0.1 + +#*# x_count = 3 +#*# y_count = 1 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + const result = parser.parseConfigFile(configWithEmptyLines); + expect(result.success).toBe(true); + }); + + it('should handle tabs vs spaces in point values', () => { + const configWithTabs = + '#*# [bed_mesh default]\n#*# points =\n#*#\t0.1,\t0.0,\t-0.1\n#*# x_count = 3\n#*# y_count = 1\n#*# min_x = 15.0\n#*# max_x = 205.0\n#*# min_y = 15.0\n#*# max_y = 205.0'; + const result = parser.parseConfigFile(configWithTabs); + expect(result.success).toBe(true); + expect(result.data?.matrix[0][0]).toBeCloseTo(0.1, 5); + }); + + it('should handle scientific notation in values', () => { + const configWithScientific = `#*# [bed_mesh default] +#*# points = +#*# 1e-2, 0.0, -1e-2 +#*# x_count = 3 +#*# y_count = 1 +#*# min_x = 15.0 +#*# max_x = 205.0 +#*# min_y = 15.0 +#*# max_y = 205.0`; + const result = parser.parseConfigFile(configWithScientific); + expect(result.success).toBe(true); + expect(result.data?.matrix[0][0]).toBeCloseTo(0.01, 5); + }); + }); +}); diff --git a/src/services/calibration/__tests__/ScrewSolver.test.ts b/src/services/calibration/__tests__/ScrewSolver.test.ts new file mode 100644 index 0000000..7d0e7c1 --- /dev/null +++ b/src/services/calibration/__tests__/ScrewSolver.test.ts @@ -0,0 +1,300 @@ +/** + * @fileoverview Unit tests for the ScrewSolver class. + * Tests screw adjustment calculations, rotation conversions, and direction determination. + * + * @module services/calibration/__tests__/ScrewSolver.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig, ScrewConfig } from '../../../types/calibration'; +import { BedCorner, RotationDirection } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { ScrewSolver } from '../engine/ScrewSolver'; + +describe('ScrewSolver', () => { + const config3x3: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 3, + meshPointsY: 3, + }; + + // Mesh with front-left high, rear-right low + const tiltedMesh = [ + [0.2, 0.1, 0.0], + [0.1, 0.0, -0.1], + [0.0, -0.1, -0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + ]; + + let bed: Bed; + let solver: ScrewSolver; + + beforeEach(() => { + bed = new Bed(config3x3); + bed.setMeshData(tiltedMesh); + solver = new ScrewSolver(bed); + }); + + describe('constructor', () => { + it('should create solver with default config', () => { + const solver = new ScrewSolver(bed); + expect(solver).toBeDefined(); + }); + + it('should create solver with custom config', () => { + const customConfig: ScrewConfig = { + pitch: 0.8, + minAdjust: 0.02, + maxAdjust: 3.0, + }; + const solver = new ScrewSolver(bed, customConfig); + expect(solver).toBeDefined(); + }); + }); + + describe('setReferenceCorner', () => { + it('should set reference corner manually', () => { + solver.setReferenceCorner(BedCorner.REAR_RIGHT); + const adjustments = solver.calculateAdjustments(); + + // Rear-right should have no adjustment (it's the reference) + const rrAdj = adjustments.find((a) => a.corner === BedCorner.REAR_RIGHT); + expect(rrAdj?.deviation).toBeCloseTo(0, 5); + }); + }); + + describe('autoSelectReferenceCorner', () => { + it('should select lowest corner as reference', () => { + const selected = solver.autoSelectReferenceCorner(); + // Rear-right is lowest at -0.2 + expect(selected).toBe(BedCorner.REAR_RIGHT); + }); + + it('should handle level bed', () => { + const levelBed = new Bed(config3x3); + levelBed.setMeshData(levelMesh); + const levelSolver = new ScrewSolver(levelBed); + + const selected = levelSolver.autoSelectReferenceCorner(); + // When all are equal, front-left is chosen (first checked) + expect(selected).toBe(BedCorner.FRONT_LEFT); + }); + }); + + describe('deviationToMinutes', () => { + it('should convert deviation to minutes correctly', () => { + // With default pitch of 0.5mm: + // 0.5mm deviation = 1 full turn = 360 * 60 = 21600 minutes + // 0.1mm deviation = 0.2 turns = 72 * 60 = 4320 minutes + const minutes = solver.deviationToMinutes(0.1); + expect(minutes).toBeCloseTo(4320, 0); + }); + + it('should handle negative deviation', () => { + // Should return absolute value + const minutes = solver.deviationToMinutes(-0.1); + expect(minutes).toBeCloseTo(4320, 0); + }); + + it('should return 0 for zero deviation', () => { + const minutes = solver.deviationToMinutes(0); + expect(minutes).toBe(0); + }); + }); + + describe('deviationToDegrees', () => { + it('should convert deviation to degrees correctly', () => { + // 0.5mm deviation = 360 degrees (1 full turn) + // 0.1mm deviation = 72 degrees + const degrees = solver.deviationToDegrees(0.1); + expect(degrees).toBeCloseTo(72, 0); + }); + }); + + describe('deviationToTurns', () => { + it('should convert deviation to turns correctly', () => { + // 0.5mm deviation = 1 turn + // 0.1mm deviation = 0.2 turns + const turns = solver.deviationToTurns(0.1); + expect(turns).toBeCloseTo(0.2, 5); + }); + }); + + describe('getDirection', () => { + it('should return CCW for positive deviation (bed too low)', () => { + const direction = solver.getDirection(0.1); + expect(direction).toBe(RotationDirection.COUNTERCLOCKWISE); + }); + + it('should return CW for negative deviation (bed too high)', () => { + const direction = solver.getDirection(-0.1); + expect(direction).toBe(RotationDirection.CLOCKWISE); + }); + + it('should return CW for zero deviation', () => { + const direction = solver.getDirection(0); + // Zero is not > 0, so returns CW + expect(direction).toBe(RotationDirection.CLOCKWISE); + }); + }); + + describe('heightChangeFromMinutes', () => { + it('should calculate height change for CW rotation', () => { + // CW should lower the bed (negative change) + const change = solver.heightChangeFromMinutes(4320, RotationDirection.CLOCKWISE); + expect(change).toBeLessThan(0); + expect(change).toBeCloseTo(-0.1, 2); + }); + + it('should calculate height change for CCW rotation', () => { + // CCW should raise the bed (positive change) + const change = solver.heightChangeFromMinutes(4320, RotationDirection.COUNTERCLOCKWISE); + expect(change).toBeGreaterThan(0); + expect(change).toBeCloseTo(0.1, 2); + }); + }); + + describe('calculateCornerAdjustment', () => { + it('should calculate adjustment for single corner', () => { + const adjustment = solver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + + expect(adjustment.corner).toBe(BedCorner.FRONT_LEFT); + expect(adjustment.deviation).toBeCloseTo(-0.2, 5); + expect(adjustment.direction).toBe(RotationDirection.CLOCKWISE); + expect(adjustment.requiresAdjustment).toBe(true); + }); + + it('should not require adjustment for minimal deviation', () => { + const levelBed = new Bed(config3x3); + levelBed.setMeshData(levelMesh); + const levelSolver = new ScrewSolver(levelBed); + + const adjustment = levelSolver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + expect(adjustment.requiresAdjustment).toBe(false); + expect(adjustment.formattedAmount).toBe('No adjustment needed'); + }); + + it('should format large adjustments as turns', () => { + const largeMesh = [ + [1.0, 0.5, 0.0], + [0.5, 0.0, -0.5], + [0.0, -0.5, -1.0], + ]; + const largeBed = new Bed(config3x3); + largeBed.setMeshData(largeMesh); + const largeSolver = new ScrewSolver(largeBed); + + const adjustment = largeSolver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + expect(adjustment.turns).toBeGreaterThanOrEqual(1); + expect(adjustment.formattedAmount).toContain('turns'); + }); + + it('should format medium adjustments as degrees', () => { + const medMesh = [ + [0.1, 0.05, 0.0], + [0.05, 0.0, -0.05], + [0.0, -0.05, -0.1], + ]; + const medBed = new Bed(config3x3); + medBed.setMeshData(medMesh); + const medSolver = new ScrewSolver(medBed); + + const adjustment = medSolver.calculateCornerAdjustment(BedCorner.FRONT_LEFT, 0, 0); + // 0.1mm = 72 degrees, which is > 30 degrees + expect(adjustment.formattedAmount).toContain('°'); + }); + }); + + describe('calculateAdjustments', () => { + it('should calculate adjustments for all corners', () => { + solver.autoSelectReferenceCorner(); + const adjustments = solver.calculateAdjustments(); + + expect(adjustments.length).toBe(4); + + const corners = adjustments.map((a) => a.corner); + expect(corners).toContain(BedCorner.FRONT_LEFT); + expect(corners).toContain(BedCorner.FRONT_RIGHT); + expect(corners).toContain(BedCorner.REAR_LEFT); + expect(corners).toContain(BedCorner.REAR_RIGHT); + }); + + it('should have zero deviation for reference corner', () => { + solver.autoSelectReferenceCorner(); // Sets rear-right as reference + const adjustments = solver.calculateAdjustments(); + + const rrAdj = adjustments.find((a) => a.corner === BedCorner.REAR_RIGHT); + expect(rrAdj?.deviation).toBeCloseTo(0, 5); + expect(rrAdj?.requiresAdjustment).toBe(false); + }); + + it('should calculate correct deviations relative to reference', () => { + solver.setReferenceCorner(BedCorner.REAR_RIGHT); + const adjustments = solver.calculateAdjustments(); + + // Front-left is 0.2, rear-right is -0.2, target is -0.2, so deviation = -0.4 + const flAdj = adjustments.find((a) => a.corner === BedCorner.FRONT_LEFT); + expect(flAdj?.deviation).toBeCloseTo(-0.4, 5); + }); + }); + + describe('calculateAdjustmentsToTarget', () => { + it('should calculate adjustments to reach target height', () => { + const targetHeight = 0; + const adjustments = solver.calculateAdjustmentsToTarget(targetHeight); + + // Front-left at 0.2 should need adjustment to reach 0 + const flAdj = adjustments.find((a) => a.corner === BedCorner.FRONT_LEFT); + expect(flAdj?.deviation).toBeCloseTo(-0.2, 5); + expect(flAdj?.direction).toBe(RotationDirection.CLOCKWISE); // Lower it + + // Rear-right at -0.2 should need adjustment to reach 0 + const rrAdj = adjustments.find((a) => a.corner === BedCorner.REAR_RIGHT); + expect(rrAdj?.deviation).toBeCloseTo(0.2, 5); + expect(rrAdj?.direction).toBe(RotationDirection.COUNTERCLOCKWISE); // Raise it + }); + }); + + describe('getAdjustmentSummary', () => { + it('should provide adjustment summary', () => { + solver.autoSelectReferenceCorner(); + const adjustments = solver.calculateAdjustments(); + const summary = solver.getAdjustmentSummary(adjustments); + + expect(summary.totalAdjustmentsNeeded).toBeGreaterThan(0); + expect(summary.maxMinutes).toBeGreaterThan(0); + expect(summary.corners.length).toBe(4); + }); + + it('should show no adjustments for level bed', () => { + const levelBed = new Bed(config3x3); + levelBed.setMeshData(levelMesh); + const levelSolver = new ScrewSolver(levelBed); + + levelSolver.autoSelectReferenceCorner(); + const adjustments = levelSolver.calculateAdjustments(); + const summary = levelSolver.getAdjustmentSummary(adjustments); + + expect(summary.totalAdjustmentsNeeded).toBe(0); + }); + }); + + describe('setConfig', () => { + it('should update screw configuration', () => { + const newConfig: Partial = { pitch: 0.8 }; + solver.setConfig(newConfig); + + // With larger pitch, same deviation should produce fewer minutes + const minutesWithNewPitch = solver.deviationToMinutes(0.1); + const expectedMinutes = (0.1 * 360 * 60) / 0.8; + expect(minutesWithNewPitch).toBeCloseTo(expectedMinutes, 0); + }); + }); +}); diff --git a/src/services/calibration/__tests__/ShaperAnalyzer.test.ts b/src/services/calibration/__tests__/ShaperAnalyzer.test.ts new file mode 100644 index 0000000..4cf3ba5 --- /dev/null +++ b/src/services/calibration/__tests__/ShaperAnalyzer.test.ts @@ -0,0 +1,305 @@ +/** + * @fileoverview Unit tests for the ShaperAnalyzer class. + * Tests input shaper analysis, shaper evaluation, and Klipper config generation. + * + * @module services/calibration/__tests__/ShaperAnalyzer.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { ShaperType } from '../../../types/calibration'; +import { + getAllShaperDefinitions, + getShaperDefinition, + SHAPER_DEFINITIONS, + ShaperAnalyzer, +} from '../shaper/ShaperAnalyzer'; + +describe('ShaperAnalyzer', () => { + let analyzer: ShaperAnalyzer; + + beforeEach(() => { + analyzer = new ShaperAnalyzer(); + }); + + // Generate test CSV data simulating accelerometer output with a resonance at a specific frequency + function generateTestCSV(resonanceFreq: number, sampleRate = 1000, duration = 1.0): string { + const lines = ['time,accel_x,accel_y,accel_z']; + const samples = Math.floor(sampleRate * duration); + + for (let i = 0; i < samples; i++) { + const t = i / sampleRate; + // Primary resonance + noise + const accelX = Math.sin(2 * Math.PI * resonanceFreq * t) + 0.1 * Math.sin(2 * Math.PI * 15 * t); + const accelY = Math.sin(2 * Math.PI * resonanceFreq * t) + 0.1 * Math.sin(2 * Math.PI * 20 * t); + const accelZ = 9.8 + 0.01 * Math.random(); + + lines.push(`${t.toFixed(4)},${accelX.toFixed(4)},${accelY.toFixed(4)},${accelZ.toFixed(4)}`); + } + + return lines.join('\n'); + } + + describe('constructor', () => { + it('should create analyzer with default options', () => { + const analyzer = new ShaperAnalyzer(); + expect(analyzer).toBeDefined(); + }); + + it('should create analyzer with custom FFT options', () => { + const analyzer = new ShaperAnalyzer({ minFreq: 10, maxFreq: 150 }); + expect(analyzer).toBeDefined(); + }); + }); + + describe('analyzeAxis', () => { + it('should analyze X axis data and return calibration', () => { + const csvData = generateTestCSV(45, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'x'); + + expect(calibration.axis).toBe('x'); + expect(calibration.frequencyBins.length).toBeGreaterThan(0); + expect(calibration.powerSpectralDensity.length).toBe(calibration.frequencyBins.length); + expect(calibration.peakFrequencies.length).toBeGreaterThan(0); + expect(calibration.recommendedShaper).toBeDefined(); + expect(calibration.allShaperResults.length).toBe(5); // All 5 shaper types + }); + + it('should analyze Y axis data', () => { + const csvData = generateTestCSV(50, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'y'); + + expect(calibration.axis).toBe('y'); + expect(calibration.recommendedShaper).toBeDefined(); + }); + + it('should detect resonance frequency near input', () => { + const resonanceFreq = 45; + const csvData = generateTestCSV(resonanceFreq, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'x'); + + // Peak frequency should be detected near the input resonance + expect(calibration.peakFrequencies.length).toBeGreaterThan(0); + + // At least one peak should be near our input frequency + const nearbyPeak = calibration.peakFrequencies.some( + (f) => Math.abs(f - resonanceFreq) < 10 // Within 10 Hz + ); + expect(nearbyPeak).toBe(true); + }); + }); + + describe('evaluateShapers', () => { + it('should evaluate all shaper types', () => { + const mockSpectrum = { + frequencies: [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], + power: [0.1, 0.2, 0.3, 0.8, 1.0, 0.7, 0.3, 0.2, 0.1, 0.05], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + + expect(results.length).toBe(5); + + // Should include all shaper types + const types = results.map((r) => r.type); + expect(types).toContain('zv' as ShaperType); + expect(types).toContain('mzv' as ShaperType); + expect(types).toContain('ei' as ShaperType); + expect(types).toContain('2hump_ei' as ShaperType); + expect(types).toContain('3hump_ei' as ShaperType); + }); + + it('should sort results by score (best first)', () => { + const mockSpectrum = { + frequencies: [30, 40, 50, 60, 70], + power: [0.3, 0.7, 1.0, 0.6, 0.2], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + + // Results should be sorted by score descending + for (let i = 1; i < results.length; i++) { + expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score); + } + }); + + it('should calculate valid shaper parameters', () => { + const mockSpectrum = { + frequencies: [40, 45, 50, 55, 60], + power: [0.5, 0.8, 1.0, 0.7, 0.4], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + + for (const result of results) { + expect(result.frequency).toBeGreaterThan(0); + expect(result.vibrationReduction).toBeGreaterThanOrEqual(0); + expect(result.vibrationReduction).toBeLessThanOrEqual(1); + expect(result.smoothingTime).toBeGreaterThan(0); + expect(result.maxAcceleration).toBeGreaterThan(0); + expect(result.score).toBeGreaterThan(0); + } + }); + }); + + describe('findOptimalFrequency', () => { + it('should find optimal frequency for ZV shaper', () => { + const mockSpectrum = { + frequencies: [40, 45, 50, 55, 60], + power: [0.3, 0.6, 1.0, 0.5, 0.2], + peakFrequency: 50, + peakPower: 1.0, + }; + + const optimal = analyzer.findOptimalFrequency('zv' as ShaperType, 50, mockSpectrum, 10, 100); + + // Should find a frequency that provides good vibration reduction + expect(optimal).toBeGreaterThan(10); + expect(optimal).toBeLessThan(100); + }); + + it('should respect min/max frequency bounds', () => { + const mockSpectrum = { + frequencies: [20, 30, 40, 50, 60], + power: [0.3, 0.6, 1.0, 0.5, 0.2], + peakFrequency: 40, + peakPower: 1.0, + }; + + const optimal = analyzer.findOptimalFrequency('mzv' as ShaperType, 40, mockSpectrum, 30, 60); + + expect(optimal).toBeGreaterThanOrEqual(30); + expect(optimal).toBeLessThanOrEqual(60); + }); + }); + + describe('findBestShaper', () => { + it('should return the highest scoring shaper', () => { + const mockSpectrum = { + frequencies: [40, 50, 60, 70, 80], + power: [0.5, 1.0, 0.6, 0.3, 0.1], + peakFrequency: 50, + peakPower: 1.0, + }; + + const results = analyzer.evaluateShapers(50, mockSpectrum); + const best = analyzer.findBestShaper(results); + + // Best should have the highest score + expect(best.score).toBe(Math.max(...results.map((r) => r.score))); + }); + }); + + describe('generateKlipperConfig', () => { + it('should generate valid Klipper config for X axis', () => { + const result = { + type: 'mzv' as ShaperType, + frequency: 48.5, + vibrationReduction: 0.85, + smoothingTime: 12.3, + maxAcceleration: 8000, + score: 75.2, + }; + + const config = analyzer.generateKlipperConfig('x', result); + + expect(config).toContain('[input_shaper]'); + expect(config).toContain('shaper_freq_x: 48.5'); + expect(config).toContain('shaper_type_x: mzv'); + }); + + it('should generate valid Klipper config for Y axis', () => { + const result = { + type: 'ei' as ShaperType, + frequency: 52.3, + vibrationReduction: 0.78, + smoothingTime: 15.6, + maxAcceleration: 6500, + score: 68.4, + }; + + const config = analyzer.generateKlipperConfig('y', result); + + expect(config).toContain('shaper_freq_y: 52.3'); + expect(config).toContain('shaper_type_y: ei'); + }); + }); + + describe('generateReport', () => { + it('should generate human-readable calibration report', () => { + const csvData = generateTestCSV(45, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'x'); + + const report = analyzer.generateReport(calibration); + + expect(report).toContain('X Axis'); + expect(report).toContain('Recommended Shaper'); + expect(report).toContain('Frequency'); + expect(report).toContain('Vibration Reduction'); + expect(report).toContain('Klipper Configuration'); + }); + + it('should include all shaper results in report', () => { + const csvData = generateTestCSV(50, 1000, 2.0); + const calibration = analyzer.analyzeAxis(csvData, 'y'); + + const report = analyzer.generateReport(calibration); + + // Should mention all shaper types + expect(report).toContain('zv'); + expect(report).toContain('mzv'); + expect(report).toContain('ei'); + }); + }); +}); + +describe('SHAPER_DEFINITIONS', () => { + it('should define all 5 shaper types', () => { + expect(SHAPER_DEFINITIONS.length).toBe(5); + }); + + it('should have valid frequency ranges', () => { + for (const def of SHAPER_DEFINITIONS) { + expect(def.minFreq).toBeGreaterThan(0); + expect(def.maxFreq).toBeGreaterThan(def.minFreq); + expect(def.initFreq).toBeGreaterThanOrEqual(def.minFreq); + expect(def.initFreq).toBeLessThanOrEqual(def.maxFreq); + } + }); + + it('should have proper type identifiers', () => { + const types = SHAPER_DEFINITIONS.map((d) => d.type); + expect(types).toContain('zv'); + expect(types).toContain('mzv'); + expect(types).toContain('ei'); + expect(types).toContain('2hump_ei'); + expect(types).toContain('3hump_ei'); + }); +}); + +describe('getShaperDefinition', () => { + it('should return definition for valid type', () => { + const def = getShaperDefinition('mzv' as ShaperType); + expect(def).toBeDefined(); + expect(def?.type).toBe('mzv'); + expect(def?.name).toContain('MZV'); + }); + + it('should return undefined for invalid type', () => { + const def = getShaperDefinition('invalid' as ShaperType); + expect(def).toBeUndefined(); + }); +}); + +describe('getAllShaperDefinitions', () => { + it('should return copy of all definitions', () => { + const defs = getAllShaperDefinitions(); + expect(defs.length).toBe(5); + expect(defs).not.toBe(SHAPER_DEFINITIONS); // Should be a copy + }); +}); diff --git a/src/services/calibration/__tests__/TapeCalculator.test.ts b/src/services/calibration/__tests__/TapeCalculator.test.ts new file mode 100644 index 0000000..2b37542 --- /dev/null +++ b/src/services/calibration/__tests__/TapeCalculator.test.ts @@ -0,0 +1,344 @@ +/** + * @fileoverview Unit tests for the TapeCalculator class. + * Tests tape compensation calculations for bed leveling. + * + * @module services/calibration/__tests__/TapeCalculator.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig, TapeConfig } from '../../../types/calibration'; +import { BedCorner, DEFAULT_TAPE_CONFIG } from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { TapeCalculator } from '../engine/TapeCalculator'; + +describe('TapeCalculator', () => { + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + // Mesh with one corner high + const unevenMesh = [ + [0.2, 0.1, 0.0, -0.05, -0.1], + [0.1, 0.05, 0.0, -0.03, -0.05], + [0.0, 0.0, 0.0, 0.0, 0.0], + [-0.05, -0.03, 0.0, 0.05, 0.1], + [-0.1, -0.05, 0.0, 0.1, 0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ]; + + // Mesh with center low (bowl shape) + const bowlMesh = [ + [0.1, 0.05, 0.0, 0.05, 0.1], + [0.05, 0.02, -0.05, 0.02, 0.05], + [0.0, -0.05, -0.15, -0.05, 0.0], + [0.05, 0.02, -0.05, 0.02, 0.05], + [0.1, 0.05, 0.0, 0.05, 0.1], + ]; + + let bed: Bed; + let calculator: TapeCalculator; + + beforeEach(() => { + bed = new Bed(config5x5); + bed.setMeshData(unevenMesh); + calculator = new TapeCalculator(bed); + }); + + describe('constructor', () => { + it('should create calculator with default config', () => { + const calc = new TapeCalculator(bed); + expect(calc).toBeDefined(); + }); + + it('should create calculator with custom config', () => { + const customConfig: TapeConfig = { + tapeThickness: 0.08, + minHeightDiff: 0.03, + }; + const calc = new TapeCalculator(bed, customConfig); + expect(calc).toBeDefined(); + }); + }); + + describe('setConfig', () => { + it('should update tape configuration', () => { + calculator.setConfig({ tapeThickness: 0.1 }); + const recommendations = calculator.calculateLayers(); + // With thicker tape, fewer layers should be needed + expect(recommendations.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('calculateLayers', () => { + it('should calculate tape recommendations for corners', () => { + const recommendations = calculator.calculateLayers(); + + expect(recommendations.length).toBeGreaterThan(0); + + for (const rec of recommendations) { + expect(rec.layers).toBeGreaterThan(0); + expect(rec.totalThickness).toBe(rec.layers * DEFAULT_TAPE_CONFIG.tapeThickness); + expect(rec.deviation).toBeGreaterThan(0); + expect(Object.values(BedCorner)).toContain(rec.corner); + } + }); + + it('should not recommend tape for level bed', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelCalc = new TapeCalculator(levelBed); + + const recommendations = levelCalc.calculateLayers(); + expect(recommendations.length).toBe(0); + }); + + it('should use lowest corner as reference', () => { + const recommendations = calculator.calculateLayers(); + + // The lowest corner(s) should not have recommendations + // since they're the reference point + const corners = bed.getCornerValues(); + const lowestValue = Math.min(corners.frontLeft, corners.frontRight, corners.rearLeft, corners.rearRight); + + const lowestCorners: BedCorner[] = []; + if (Math.abs(corners.frontLeft - lowestValue) < 0.001) lowestCorners.push(BedCorner.FRONT_LEFT); + if (Math.abs(corners.frontRight - lowestValue) < 0.001) lowestCorners.push(BedCorner.FRONT_RIGHT); + if (Math.abs(corners.rearLeft - lowestValue) < 0.001) lowestCorners.push(BedCorner.REAR_LEFT); + if (Math.abs(corners.rearRight - lowestValue) < 0.001) lowestCorners.push(BedCorner.REAR_RIGHT); + + // Lowest corners should not have tape recommendations + for (const rec of recommendations) { + expect(lowestCorners).not.toContain(rec.corner); + } + }); + + it('should respect corner averaging size', () => { + const rec1 = calculator.calculateLayers(0); + const rec2 = calculator.calculateLayers(1); + + // Different averaging should potentially give different results + // (depending on mesh data) + expect(rec1).toBeDefined(); + expect(rec2).toBeDefined(); + }); + }); + + describe('getTotalLayers', () => { + it('should sum all tape layers', () => { + const recommendations = calculator.calculateLayers(); + const total = calculator.getTotalLayers(); + + const expectedTotal = recommendations.reduce((sum, r) => sum + r.layers, 0); + expect(total).toBe(expectedTotal); + }); + + it('should return 0 for level bed', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + const levelCalc = new TapeCalculator(levelBed); + + expect(levelCalc.getTotalLayers()).toBe(0); + }); + }); + + describe('findLowSpots', () => { + it('should find low spots in mesh', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + + // Bowl mesh has center low, so should find spots there + expect(spots.length).toBeGreaterThan(0); + + // Center spot should be identified + const centerSpot = spots.find((s) => s.x === 2 && s.y === 2); + expect(centerSpot).toBeDefined(); + }); + + it('should skip screw corner positions', () => { + const spots = calculator.findLowSpots(unevenMesh); + + // No spots should be at corner positions + for (const spot of spots) { + const isCorner = + (spot.x === 0 && spot.y === 0) || + (spot.x === 4 && spot.y === 0) || + (spot.x === 0 && spot.y === 4) || + (spot.x === 4 && spot.y === 4); + expect(isCorner).toBe(false); + } + }); + + it('should assign correct priority based on height difference', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + + for (const spot of spots) { + // Priority should be 1-3 + expect(spot.priority).toBeGreaterThanOrEqual(1); + expect(spot.priority).toBeLessThanOrEqual(3); + + // Higher deviation should have higher priority (lower number) + if (spot.heightDiff > 0.3) { + expect(spot.priority).toBe(1); + } + } + }); + + it('should sort spots by priority and height diff', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + + // Should be sorted by priority (ascending) then height diff (descending) + for (let i = 1; i < spots.length; i++) { + if (spots[i].priority === spots[i - 1].priority) { + expect(spots[i].heightDiff).toBeLessThanOrEqual(spots[i - 1].heightDiff); + } else { + expect(spots[i].priority).toBeGreaterThanOrEqual(spots[i - 1].priority); + } + } + }); + }); + + describe('optimizeTapeLayout', () => { + it('should merge nearby spots', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + const optimized = bowlCalc.optimizeTapeLayout(spots); + + // Optimized should have same or fewer spots + expect(optimized.length).toBeLessThanOrEqual(spots.length); + }); + + it('should handle empty spots array', () => { + const optimized = calculator.optimizeTapeLayout([]); + expect(optimized.length).toBe(0); + }); + + it('should preserve isolated spots', () => { + const isolatedSpots = [ + { x: 1, y: 1, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }, + { x: 3, y: 3, layers: 1, heightDiff: 0.05, priority: 3, areaSize: 100 }, + ]; + + const optimized = calculator.optimizeTapeLayout(isolatedSpots); + + // Isolated spots should be preserved + expect(optimized.length).toBe(2); + }); + }); + + describe('applySpots', () => { + it('should apply tape spots to mesh', () => { + const spots = [{ x: 2, y: 2, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }]; + + const applied = calculator.applySpots(bowlMesh, spots); + + // Center area should be raised + expect(applied[2][2]).toBeGreaterThan(bowlMesh[2][2]); + }); + + it('should not modify original mesh', () => { + const originalValue = bowlMesh[2][2]; + const spots = [{ x: 2, y: 2, layers: 3, heightDiff: 0.15, priority: 1, areaSize: 100 }]; + + calculator.applySpots(bowlMesh, spots); + + expect(bowlMesh[2][2]).toBe(originalValue); + }); + + it('should affect surrounding area', () => { + const spots = [{ x: 2, y: 2, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }]; + + const applied = calculator.applySpots(bowlMesh, spots); + + // Adjacent cells should also be affected + expect(applied[1][2]).toBeGreaterThan(bowlMesh[1][2]); + expect(applied[2][1]).toBeGreaterThan(bowlMesh[2][1]); + expect(applied[3][2]).toBeGreaterThan(bowlMesh[3][2]); + expect(applied[2][3]).toBeGreaterThan(bowlMesh[2][3]); + }); + }); + + describe('estimateImprovement', () => { + it('should estimate deviation change', () => { + const bowlBed = new Bed(config5x5); + bowlBed.setMeshData(bowlMesh); + const bowlCalc = new TapeCalculator(bowlBed); + + const spots = bowlCalc.findLowSpots(bowlMesh); + const improvement = bowlCalc.estimateImprovement(spots); + + // Should return a valid number (improvement can be positive or negative + // depending on mesh geometry and spot locations) + expect(typeof improvement).toBe('number'); + expect(Number.isFinite(improvement)).toBe(true); + }); + + it('should return 0 for no mesh data', () => { + const emptyBed = new Bed(config5x5); + const emptyCalc = new TapeCalculator(emptyBed); + + const improvement = emptyCalc.estimateImprovement([]); + expect(improvement).toBe(0); + }); + }); + + describe('getTapeInstructions', () => { + it('should generate instructions for spots', () => { + const spots = [{ x: 2, y: 2, layers: 2, heightDiff: 0.1, priority: 2, areaSize: 100 }]; + + const instructions = calculator.getTapeInstructions(spots); + + expect(instructions.length).toBeGreaterThan(0); + expect(instructions.join('\n')).toContain('location'); + expect(instructions.join('\n')).toContain('layer'); + }); + + it('should return "no compensation needed" for empty spots', () => { + const instructions = calculator.getTapeInstructions([]); + + expect(instructions.length).toBe(1); + expect(instructions[0]).toContain('No tape compensation required'); + }); + + it('should include position identifiers', () => { + const spots = [{ x: 1, y: 2, layers: 1, heightDiff: 0.05, priority: 3, areaSize: 100 }]; + + const instructions = calculator.getTapeInstructions(spots); + + // Position should be in format like "3B" (row 3, column B) + expect(instructions.join('\n')).toContain('Position'); + }); + + it('should flag high priority spots', () => { + const spots = [{ x: 2, y: 2, layers: 3, heightDiff: 0.35, priority: 1, areaSize: 100 }]; + + const instructions = calculator.getTapeInstructions(spots); + + expect(instructions.join('\n')).toContain('High priority'); + }); + }); +}); diff --git a/src/services/calibration/__tests__/WorkflowEngine.test.ts b/src/services/calibration/__tests__/WorkflowEngine.test.ts new file mode 100644 index 0000000..95ddc5e --- /dev/null +++ b/src/services/calibration/__tests__/WorkflowEngine.test.ts @@ -0,0 +1,379 @@ +/** + * @fileoverview Unit tests for the WorkflowEngine class. + * Tests multi-stage calibration workflow computation. + * + * @module services/calibration/__tests__/WorkflowEngine.test + */ + +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { BedConfig, CalibrationSettings } from '../../../types/calibration'; +import { + DEFAULT_CALIBRATION_SETTINGS, + DEFAULT_SCREW_CONFIG, + DEFAULT_TAPE_CONFIG, + WorkflowStage, +} from '../../../types/calibration'; +import { Bed } from '../engine/Bed'; +import { ScrewSolver } from '../engine/ScrewSolver'; +import { TapeCalculator } from '../engine/TapeCalculator'; +import { WorkflowEngine } from '../engine/WorkflowEngine'; + +describe('WorkflowEngine', () => { + const config5x5: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 5, + meshPointsY: 5, + }; + + // Tilted mesh requiring calibration + const tiltedMesh = [ + [0.2, 0.15, 0.1, 0.05, 0.0], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.1, 0.05, 0.0, -0.05, -0.1], + [0.05, 0.0, -0.05, -0.1, -0.15], + [0.0, -0.05, -0.1, -0.15, -0.2], + ]; + + // Level mesh + const levelMesh = [ + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 0.0, 0.0], + ]; + + let bed: Bed; + let screwSolver: ScrewSolver; + let tapeCalculator: TapeCalculator; + let settings: CalibrationSettings; + let engine: WorkflowEngine; + + beforeEach(() => { + bed = new Bed(config5x5); + bed.setMeshData(tiltedMesh); + + screwSolver = new ScrewSolver(bed, DEFAULT_SCREW_CONFIG); + tapeCalculator = new TapeCalculator(bed, DEFAULT_TAPE_CONFIG); + settings = { ...DEFAULT_CALIBRATION_SETTINGS }; + + engine = new WorkflowEngine(bed, screwSolver, tapeCalculator, settings); + }); + + describe('constructor', () => { + it('should create workflow engine', () => { + expect(engine).toBeDefined(); + }); + }); + + describe('setSettings', () => { + it('should update settings', () => { + const newSettings = { ...settings, workflow: { enableBelt: false, enableScrews: true, enableTape: true } }; + engine.setSettings(newSettings); + const workflow = engine.computeWorkflow(); + expect(workflow).toBeDefined(); + }); + }); + + describe('computeWorkflow', () => { + it('should compute complete workflow', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.currentStage).toBe(WorkflowStage.COMPLETE); + expect(workflow.startTime).toBeGreaterThan(0); + expect(workflow.completedStages.length).toBeGreaterThan(0); + expect(workflow.initialRange).toBeGreaterThan(0); + }); + + it('should include all stages', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.stages.has(WorkflowStage.INITIAL)).toBe(true); + expect(workflow.stages.has(WorkflowStage.BELT_SYNC)).toBe(true); + expect(workflow.stages.has(WorkflowStage.SCREW_ADJUST)).toBe(true); + expect(workflow.stages.has(WorkflowStage.TAPE_COMPENSATE)).toBe(true); + expect(workflow.stages.has(WorkflowStage.THERMAL_PREDICT)).toBe(true); + }); + + it('should calculate improvement percentage', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.initialRange).toBeGreaterThan(0); + expect(workflow.improvementPercent).toBeDefined(); + }); + + it('should include screw adjustments', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.screwAdjustments).toBeDefined(); + expect(workflow.screwAdjustments?.length).toBe(4); + }); + + it('should include belt sync result', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.beltSyncResult).toBeDefined(); + expect(typeof workflow.beltSyncResult?.needsSync).toBe('boolean'); + }); + + it('should include tape recommendations', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.tapeRecommendations).toBeDefined(); + }); + + it('should include thermal prediction', () => { + const workflow = engine.computeWorkflow(); + + expect(workflow.thermalPrediction).toBeDefined(); + expect(workflow.thermalPrediction?.predictedMesh).toBeDefined(); + }); + + it('should throw when no mesh data loaded', () => { + const emptyBed = new Bed(config5x5); + const emptySolver = new ScrewSolver(emptyBed); + const emptyCalc = new TapeCalculator(emptyBed); + const emptyEngine = new WorkflowEngine(emptyBed, emptySolver, emptyCalc, settings); + + expect(() => emptyEngine.computeWorkflow()).toThrow('No mesh data loaded'); + }); + }); + + describe('stage processing', () => { + it('should process initial stage correctly', () => { + const workflow = engine.computeWorkflow(); + const initialStage = workflow.stages.get(WorkflowStage.INITIAL); + + expect(initialStage).toBeDefined(); + expect(initialStage?.success).toBe(true); + expect(initialStage?.metrics.deviation).toBeGreaterThan(0); + }); + + it('should process belt stage', () => { + const workflow = engine.computeWorkflow(); + const beltStage = workflow.stages.get(WorkflowStage.BELT_SYNC); + + expect(beltStage).toBeDefined(); + expect(beltStage?.success).toBe(true); + }); + + it('should process screw stage', () => { + const workflow = engine.computeWorkflow(); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + expect(screwStage).toBeDefined(); + expect(screwStage?.success).toBe(true); + }); + + it('should process tape stage', () => { + const workflow = engine.computeWorkflow(); + const tapeStage = workflow.stages.get(WorkflowStage.TAPE_COMPENSATE); + + expect(tapeStage).toBeDefined(); + expect(tapeStage?.success).toBe(true); + }); + + it('should process thermal stage', () => { + const workflow = engine.computeWorkflow(); + const thermalStage = workflow.stages.get(WorkflowStage.THERMAL_PREDICT); + + expect(thermalStage).toBeDefined(); + expect(thermalStage?.success).toBe(true); + }); + }); + + describe('stage disabling', () => { + it('should skip belt stage when disabled', () => { + const noSyncSettings = { + ...settings, + workflow: { enableBelt: false, enableScrews: true, enableTape: true }, + }; + engine.setSettings(noSyncSettings); + + const workflow = engine.computeWorkflow(); + const beltStage = workflow.stages.get(WorkflowStage.BELT_SYNC); + + expect(beltStage?.data).toHaveProperty('enabled', false); + }); + + it('should skip screw stage when disabled', () => { + const noScrewSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: false, enableTape: true }, + }; + engine.setSettings(noScrewSettings); + + const workflow = engine.computeWorkflow(); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + expect(screwStage?.data).toHaveProperty('enabled', false); + }); + + it('should skip tape stage when disabled', () => { + const noTapeSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: true, enableTape: false }, + }; + engine.setSettings(noTapeSettings); + + const workflow = engine.computeWorkflow(); + const tapeStage = workflow.stages.get(WorkflowStage.TAPE_COMPENSATE); + + expect(tapeStage?.data).toHaveProperty('enabled', false); + }); + }); + + describe('getStageResult', () => { + it('should return result for specific stage', () => { + const workflow = engine.computeWorkflow(); + const result = engine.getStageResult(workflow, WorkflowStage.SCREW_ADJUST); + + expect(result).toBeDefined(); + expect(result?.stage).toBe(WorkflowStage.SCREW_ADJUST); + }); + + it('should return undefined for non-existent stage', () => { + const workflow = engine.computeWorkflow(); + workflow.stages.delete(WorkflowStage.BELT_SYNC); + + const result = engine.getStageResult(workflow, WorkflowStage.BELT_SYNC); + expect(result).toBeUndefined(); + }); + }); + + describe('canSkipStage', () => { + it('should indicate belt stage can be skipped when disabled', () => { + const noSyncSettings = { + ...settings, + workflow: { enableBelt: false, enableScrews: true, enableTape: true }, + }; + engine.setSettings(noSyncSettings); + + expect(engine.canSkipStage(WorkflowStage.BELT_SYNC)).toBe(true); + }); + + it('should indicate screw stage can be skipped when disabled', () => { + const noScrewSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: false, enableTape: true }, + }; + engine.setSettings(noScrewSettings); + + expect(engine.canSkipStage(WorkflowStage.SCREW_ADJUST)).toBe(true); + }); + + it('should indicate tape stage can be skipped when disabled', () => { + const noTapeSettings = { + ...settings, + workflow: { enableBelt: true, enableScrews: true, enableTape: false }, + }; + engine.setSettings(noTapeSettings); + + expect(engine.canSkipStage(WorkflowStage.TAPE_COMPENSATE)).toBe(true); + }); + + it('should not skip INITIAL or COMPLETE stages', () => { + expect(engine.canSkipStage(WorkflowStage.INITIAL)).toBe(false); + expect(engine.canSkipStage(WorkflowStage.COMPLETE)).toBe(false); + }); + }); + + describe('thermal preset handling', () => { + it('should apply thermal preset when selected', () => { + const thermalSettings = { + ...settings, + activeThermalPreset: 'PEI Spring Steel', + }; + engine.setSettings(thermalSettings); + + const workflow = engine.computeWorkflow(); + expect(workflow.thermalPrediction).toBeDefined(); + }); + + it('should skip thermal when no preset selected', () => { + const noThermalSettings = { + ...settings, + activeThermalPreset: null, + }; + engine.setSettings(noThermalSettings); + + const workflow = engine.computeWorkflow(); + const thermalStage = workflow.stages.get(WorkflowStage.THERMAL_PREDICT); + + // Should have warning about no preset + expect(thermalStage?.data).toHaveProperty('warnings'); + }); + }); + + describe('level bed handling', () => { + it('should handle level bed gracefully', () => { + const levelBed = new Bed(config5x5); + levelBed.setMeshData(levelMesh); + + const levelSolver = new ScrewSolver(levelBed); + const levelCalc = new TapeCalculator(levelBed); + const levelEngine = new WorkflowEngine(levelBed, levelSolver, levelCalc, settings); + + const workflow = levelEngine.computeWorkflow(); + + expect(workflow.currentStage).toBe(WorkflowStage.COMPLETE); + expect(workflow.initialRange).toBeCloseTo(0, 5); + + // Screw stage should indicate no adjustments needed + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + expect(screwStage?.data).toHaveProperty('actions'); + }); + }); + + describe('stage actions', () => { + it('should generate belt actions when needed', () => { + // X-tilt mesh + const xTiltMesh = [ + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + [0.15, 0.1, 0.05, 0.0, -0.05], + ]; + + const xTiltBed = new Bed(config5x5); + xTiltBed.setMeshData(xTiltMesh); + + const xSolver = new ScrewSolver(xTiltBed); + const xCalc = new TapeCalculator(xTiltBed); + const xEngine = new WorkflowEngine(xTiltBed, xSolver, xCalc, settings); + + const workflow = xEngine.computeWorkflow(); + const beltStage = workflow.stages.get(WorkflowStage.BELT_SYNC); + + // Should have belt actions due to X-axis tilt + expect(beltStage?.data).toHaveProperty('actions'); + const actions = (beltStage?.data as { actions: unknown[] }).actions; + expect(actions.length).toBeGreaterThan(0); + }); + + it('should generate screw actions for tilted bed', () => { + const workflow = engine.computeWorkflow(); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + const actions = (screwStage?.data as { actions: unknown[] }).actions; + expect(actions.length).toBeGreaterThan(0); + }); + }); + + describe('deviation tracking', () => { + it('should track deviation through stages', () => { + const workflow = engine.computeWorkflow(); + + const initialStage = workflow.stages.get(WorkflowStage.INITIAL); + const screwStage = workflow.stages.get(WorkflowStage.SCREW_ADJUST); + + const initialDev = (initialStage?.data as { deviation: number }).deviation; + const screwDev = (screwStage?.data as { deviation: number }).deviation; + + // Deviation should decrease after screw adjustment + expect(screwDev).toBeLessThanOrEqual(initialDev); + }); + }); +}); diff --git a/src/services/calibration/engine/Bed.ts b/src/services/calibration/engine/Bed.ts new file mode 100644 index 0000000..327fd54 --- /dev/null +++ b/src/services/calibration/engine/Bed.ts @@ -0,0 +1,378 @@ +/** + * @fileoverview Model representing the 3D printer bed with mesh data management. + * Provides methods for analyzing bed mesh, calculating corner heights, and + * simulating adjustments. + * + * @module services/calibration/engine/Bed + */ + +import type { BedConfig, BedCorners, MeshData } from '../../../types/calibration'; +import { BedCorner, DEFAULT_BED_CONFIG } from '../../../types/calibration'; + +/** + * Corner index mapping for mesh data access. + * Maps corner names to [row, col] indices in the mesh matrix. + */ +interface CornerIndices { + frontLeft: [number, number]; + frontRight: [number, number]; + rearLeft: [number, number]; + rearRight: [number, number]; +} + +/** + * Mesh statistics. + */ +export interface MeshStats { + mean: number; + min: number; + max: number; + range: number; + standardDeviation: number; +} + +/** + * Model of the printer bed with mesh data management. + */ +export class Bed { + /** Bed configuration (dimensions, mesh points) */ + public readonly config: BedConfig; + + /** Current mesh data (2D array of Z heights) */ + private _meshData: number[][] | null = null; + + /** Corner indices for mesh access */ + private readonly cornerIndices: CornerIndices; + + /** + * Create a new Bed instance. + * + * @param config - Bed configuration, defaults to AD5M config + */ + constructor(config: BedConfig = DEFAULT_BED_CONFIG) { + this.config = config; + + // Note: mesh is stored as [row][col] where row=Y, col=X + // Front = row 0, Rear = last row + // Left = col 0, Right = last col + this.cornerIndices = { + frontLeft: [0, 0], + frontRight: [0, config.meshPointsX - 1], + rearLeft: [config.meshPointsY - 1, 0], + rearRight: [config.meshPointsY - 1, config.meshPointsX - 1], + }; + } + + /** + * Get the current mesh data. + */ + get meshData(): number[][] | null { + return this._meshData; + } + + /** + * Check if mesh data is loaded. + */ + get hasMeshData(): boolean { + return this._meshData !== null; + } + + /** + * Set mesh data with dimension validation. + * + * @param data - 2D array of Z-height values + * @throws Error if dimensions don't match config + */ + setMeshData(data: number[][]): void { + // Validate row count (Y dimension) + if (data.length !== this.config.meshPointsY) { + throw new Error(`Invalid mesh row count: ${data.length}, expected: ${this.config.meshPointsY}`); + } + + // Validate column count for each row (X dimension) + for (let row = 0; row < data.length; row++) { + if (data[row].length !== this.config.meshPointsX) { + throw new Error( + `Invalid mesh column count in row ${row}: ${data[row].length}, expected: ${this.config.meshPointsX}` + ); + } + } + + // Deep copy to prevent external modification + this._meshData = data.map((row) => [...row]); + } + + /** + * Load mesh data from a parsed MeshData object. + * + * @param meshData - Parsed mesh data from config file + */ + loadFromMeshData(meshData: MeshData): void { + (this.config as BedConfig).meshPointsX = meshData.pointsX; + (this.config as BedConfig).meshPointsY = meshData.pointsY; + (this.config as BedConfig).sizeX = meshData.maxX - meshData.minX; + (this.config as BedConfig).sizeY = meshData.maxY - meshData.minY; + + this.cornerIndices.frontRight = [0, meshData.pointsX - 1]; + this.cornerIndices.rearLeft = [meshData.pointsY - 1, 0]; + this.cornerIndices.rearRight = [meshData.pointsY - 1, meshData.pointsX - 1]; + + this._meshData = meshData.matrix.map((row) => [...row]); + } + + /** + * Clear mesh data. + */ + clearMeshData(): void { + this._meshData = null; + } + + /** + * Get the height value at a specific corner. + * + * @param corner - Which corner to get + * @param averagingSize - Number of points to average around corner (0 = just corner point) + * @returns Height value at corner + * @throws Error if no mesh data loaded + */ + getCornerHeight(corner: BedCorner, averagingSize = 0): number { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + const indices = this.cornerIndices[corner]; + const [row, col] = indices; + + if (averagingSize <= 0) { + return this._meshData[row][col]; + } + + const rowStart = Math.max(0, row - averagingSize); + const rowEnd = Math.min(this.config.meshPointsY, row + averagingSize + 1); + const colStart = Math.max(0, col - averagingSize); + const colEnd = Math.min(this.config.meshPointsX, col + averagingSize + 1); + + let sum = 0; + let count = 0; + + for (let r = rowStart; r < rowEnd; r++) { + for (let c = colStart; c < colEnd; c++) { + sum += this._meshData[r][c]; + count++; + } + } + + return sum / count; + } + + /** + * Get all corner heights. + * + * @param averagingSize - Number of points to average around each corner + * @returns Object with height values for all corners + */ + getCornerValues(averagingSize = 0): BedCorners { + return { + frontLeft: this.getCornerHeight(BedCorner.FRONT_LEFT, averagingSize), + frontRight: this.getCornerHeight(BedCorner.FRONT_RIGHT, averagingSize), + rearLeft: this.getCornerHeight(BedCorner.REAR_LEFT, averagingSize), + rearRight: this.getCornerHeight(BedCorner.REAR_RIGHT, averagingSize), + }; + } + + /** + * Get height at a specific mesh point. + * + * @param row - Row index (Y) + * @param col - Column index (X) + * @returns Height value at point + * @throws Error if coordinates out of range or no mesh data + */ + getPointHeight(row: number, col: number): number { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + if (row < 0 || row >= this.config.meshPointsY || col < 0 || col >= this.config.meshPointsX) { + throw new Error( + `Coordinates (${row}, ${col}) out of range. Mesh size: ${this.config.meshPointsY}x${this.config.meshPointsX}` + ); + } + + return this._meshData[row][col]; + } + + /** + * Get mesh statistics. + * + * @returns Statistics about the mesh (mean, min, max, range, std dev) + * @throws Error if no mesh data loaded + */ + getMeshStats(): MeshStats { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + // Flatten mesh to single array for calculations + const values: number[] = []; + for (const row of this._meshData) { + values.push(...row); + } + + const n = values.length; + const sum = values.reduce((a, b) => a + b, 0); + const mean = sum / n; + + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + + const squaredDiffs = values.map((v) => (v - mean) ** 2); + const variance = squaredDiffs.reduce((a, b) => a + b, 0) / n; + const standardDeviation = Math.sqrt(variance); + + return { mean, min, max, range, standardDeviation }; + } + + /** + * Get minimum height value in mesh. + */ + getMinValue(): number { + return this.getMeshStats().min; + } + + /** + * Get maximum height value in mesh. + */ + getMaxValue(): number { + return this.getMeshStats().max; + } + + /** + * Get height range (max - min) in mesh. + */ + getRange(): number { + return this.getMeshStats().range; + } + + /** + * Get mean height value in mesh. + */ + getMeanValue(): number { + return this.getMeshStats().mean; + } + + /** + * Get physical distance between mesh points. + * + * @returns Object with mm per point in X and Y directions + */ + getMmPerPoint(): { x: number; y: number } { + return { + x: this.config.sizeX / (this.config.meshPointsX - 1), + y: this.config.sizeY / (this.config.meshPointsY - 1), + }; + } + + /** + * Generate an ideal (flat) reference plane at mean height. + * + * @returns 2D array filled with mean height value + * @throws Error if no mesh data loaded + */ + generateIdealPlane(): number[][] { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + const meanHeight = this.getMeanValue(); + const plane: number[][] = []; + + for (let row = 0; row < this.config.meshPointsY; row++) { + plane.push(new Array(this.config.meshPointsX).fill(meanHeight)); + } + + return plane; + } + + /** + * Calculate deviation map from ideal (flat) plane. + * + * @returns 2D array of deviations (positive = above ideal, negative = below) + * @throws Error if no mesh data loaded + */ + calculateDeviationMap(): number[][] { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + const idealPlane = this.generateIdealPlane(); + const deviationMap: number[][] = []; + + for (let row = 0; row < this.config.meshPointsY; row++) { + const deviationRow: number[] = []; + for (let col = 0; col < this.config.meshPointsX; col++) { + deviationRow.push(this._meshData[row][col] - idealPlane[row][col]); + } + deviationMap.push(deviationRow); + } + + return deviationMap; + } + + /** + * Simulate the result of corner adjustments on the mesh. + * Adjustments at corners influence nearby points with decreasing effect. + * + * @param cornerAdjustments - Partial object with corner adjustments in mm + * @returns Simulated mesh after adjustments + * @throws Error if no mesh data loaded + */ + simulateAdjustment(cornerAdjustments: Partial>): number[][] { + if (!this._meshData) { + throw new Error('No mesh data loaded'); + } + + // Deep copy current mesh + const adjustedMesh = this._meshData.map((row) => [...row]); + + // Maximum diagonal distance for influence calculation + const maxDistance = Math.sqrt((this.config.meshPointsY - 1) ** 2 + (this.config.meshPointsX - 1) ** 2); + + for (const [corner, adjustment] of Object.entries(cornerAdjustments)) { + if (adjustment === undefined || adjustment === 0) { + continue; + } + + const indices = this.cornerIndices[corner as BedCorner]; + if (!indices) { + continue; + } + + const [cornerRow, cornerCol] = indices; + + for (let row = 0; row < this.config.meshPointsY; row++) { + for (let col = 0; col < this.config.meshPointsX; col++) { + const distance = Math.sqrt((row - cornerRow) ** 2 + (col - cornerCol) ** 2); + const influence = Math.max(0, 1 - distance / maxDistance); + adjustedMesh[row][col] += adjustment * influence; + } + } + } + + return adjustedMesh; + } + + /** + * Create a deep copy of this Bed instance. + * + * @returns New Bed instance with copied data + */ + clone(): Bed { + const cloned = new Bed({ ...this.config }); + if (this._meshData) { + cloned.setMeshData(this._meshData.map((row) => [...row])); + } + return cloned; + } +} diff --git a/src/services/calibration/engine/DeviationAnalyzer.ts b/src/services/calibration/engine/DeviationAnalyzer.ts new file mode 100644 index 0000000..718fff8 --- /dev/null +++ b/src/services/calibration/engine/DeviationAnalyzer.ts @@ -0,0 +1,341 @@ +/** + * @fileoverview Analyzer for bed mesh deviations and calibration strategy determination. + * Analyzes mesh data to identify problems and recommend calibration approaches. + * + * @module services/calibration/engine/DeviationAnalyzer + */ + +import type { AnalysisResult, BedCorners, ScrewConfig } from '../../../types/calibration'; +import { BedCorner, DEFAULT_SCREW_CONFIG } from '../../../types/calibration'; +import type { Bed } from './Bed'; +import { ScrewSolver } from './ScrewSolver'; + +/** + * Statistics about bed mesh deviations. + */ +export interface DeviationStats { + /** Mean height of the mesh */ + meanHeight: number; + /** Maximum deviation from mean */ + maxDeviation: number; + /** Corner deviations from mean */ + cornerDeviations: BedCorners; + /** Whether there are critical deviations */ + hasCriticalDeviation: boolean; +} + +/** + * Leveling stage determination result. + */ +export interface LevelingStage { + /** Whether screw adjustment is needed */ + needsScrewAdjustment: boolean; + /** Whether screws can fix the problem (within max adjustment) */ + canUseScrews: boolean; + /** Whether tape compensation is needed */ + needsTape: boolean; + /** Maximum height difference between corners */ + maxCornerDiff: number; + /** List of corners with problems */ + problematicCorners: BedCorner[]; +} + +/** + * Options for the deviation analyzer. + */ +export interface DeviationAnalyzerOptions { + /** Number of grid points to average around corners */ + cornerAveragingSize: number; + /** Threshold for recommending screw adjustment (mm) */ + screwThreshold: number; + /** Threshold for recommending tape compensation (mm) */ + tapeThreshold: number; + /** Threshold for recommending belt sync (mm) */ + beltThreshold: number; + /** Screw configuration */ + screwConfig: ScrewConfig; +} + +/** + * Default analyzer options. + */ +const DEFAULT_OPTIONS: DeviationAnalyzerOptions = { + cornerAveragingSize: 1, + screwThreshold: 0.02, + tapeThreshold: 0.02, + beltThreshold: 0.1, + screwConfig: DEFAULT_SCREW_CONFIG, +}; + +/** + * Analyzer for bed mesh deviations and calibration recommendations. + */ +export class DeviationAnalyzer { + /** Reference to bed model */ + private readonly bed: Bed; + + /** Analyzer options */ + private options: DeviationAnalyzerOptions; + + /** Screw solver for adjustment calculations */ + private readonly screwSolver: ScrewSolver; + + /** + * Create a new DeviationAnalyzer. + * + * @param bed - Bed model with mesh data + * @param options - Analyzer options + */ + constructor(bed: Bed, options: Partial = {}) { + this.bed = bed; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.screwSolver = new ScrewSolver(bed, this.options.screwConfig); + } + + /** + * Update analyzer options. + * + * @param options - New options (partial) + */ + setOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + if (options.screwConfig) { + this.screwSolver.setConfig(options.screwConfig); + } + } + + /** + * Set corner averaging size. + * + * @param size - Number of grid points to average + */ + setCornerAveragingSize(size: number): void { + this.options.cornerAveragingSize = Math.max(0, Math.floor(size)); + } + + /** + * Set screw adjustment threshold. + * + * @param threshold - Threshold in mm + */ + setScrewThreshold(threshold: number): void { + this.options.screwThreshold = threshold; + } + + /** + * Set tape compensation threshold. + * + * @param threshold - Threshold in mm + */ + setTapeThreshold(threshold: number): void { + this.options.tapeThreshold = threshold; + } + + /** + * Get deviation statistics from the mesh. + * + * @returns DeviationStats object + */ + getStats(): DeviationStats { + const stats = this.bed.getMeshStats(); + const meanHeight = stats.mean; + + const corners = this.bed.getCornerValues(this.options.cornerAveragingSize); + const cornerDeviations: BedCorners = { + frontLeft: Math.abs(corners.frontLeft - meanHeight), + frontRight: Math.abs(corners.frontRight - meanHeight), + rearLeft: Math.abs(corners.rearLeft - meanHeight), + rearRight: Math.abs(corners.rearRight - meanHeight), + }; + + const maxDeviation = Math.max( + cornerDeviations.frontLeft, + cornerDeviations.frontRight, + cornerDeviations.rearLeft, + cornerDeviations.rearRight + ); + + return { + meanHeight, + maxDeviation, + cornerDeviations, + hasCriticalDeviation: maxDeviation > this.options.screwThreshold, + }; + } + + /** + * Analyze and determine the required leveling stages. + * + * @returns LevelingStage object + */ + analyzeLevelingStage(): LevelingStage { + const stats = this.getStats(); + const corners = this.bed.getCornerValues(this.options.cornerAveragingSize); + + const heights = [corners.frontLeft, corners.frontRight, corners.rearLeft, corners.rearRight]; + const maxCornerDiff = Math.max(...heights) - Math.min(...heights); + + // Identify problematic corners + const problematicCorners: BedCorner[] = []; + for (const [corner, deviation] of Object.entries(stats.cornerDeviations)) { + if (deviation > this.options.tapeThreshold) { + problematicCorners.push(corner as BedCorner); + } + } + + // Determine if screws can fix the problem + const canUseScrews = maxCornerDiff <= this.options.screwConfig.maxAdjust; + + // Determine required stages + const needsScrewAdjustment = stats.maxDeviation > this.options.screwThreshold; + const needsTape = stats.maxDeviation > this.options.tapeThreshold; + + return { + needsScrewAdjustment, + canUseScrews, + needsTape, + maxCornerDiff, + problematicCorners, + }; + } + + /** + * Perform full analysis and return comprehensive results. + * + * @returns AnalysisResult object + */ + analyze(): AnalysisResult { + const meshStats = this.bed.getMeshStats(); + const deviationStats = this.getStats(); + const levelingStage = this.analyzeLevelingStage(); + const corners = this.bed.getCornerValues(this.options.cornerAveragingSize); + + let referenceCorner = BedCorner.FRONT_LEFT; + let lowestValue = corners.frontLeft; + + if (corners.frontRight < lowestValue) { + referenceCorner = BedCorner.FRONT_RIGHT; + lowestValue = corners.frontRight; + } + if (corners.rearLeft < lowestValue) { + referenceCorner = BedCorner.REAR_LEFT; + lowestValue = corners.rearLeft; + } + if (corners.rearRight < lowestValue) { + referenceCorner = BedCorner.REAR_RIGHT; + } + + // Check if belt sync is needed (significant X-axis tilt) + // Compare front-left to front-right and rear-left to rear-right + const frontTilt = Math.abs(corners.frontLeft - corners.frontRight); + const rearTilt = Math.abs(corners.rearLeft - corners.rearRight); + const needsBeltSync = frontTilt > this.options.beltThreshold || rearTilt > this.options.beltThreshold; + + return { + meshRange: meshStats.range, + maxDeviation: deviationStats.maxDeviation, + minDeviation: 0, // Min deviation from mean is always 0 at the mean point + averageDeviation: meshStats.mean, + standardDeviation: meshStats.standardDeviation, + cornerDeviations: corners, + referenceCorner, + recommendations: { + needsBeltSync, + needsScrewAdjust: levelingStage.needsScrewAdjustment, + needsTapeCompensation: levelingStage.needsTape && !levelingStage.canUseScrews, + }, + }; + } + + /** + * Get the ideal (flat) reference plane. + * + * @returns 2D array representing the ideal plane at mean height + */ + getIdealPlane(): number[][] { + return this.bed.generateIdealPlane(); + } + + /** + * Estimate bed state after screw adjustments. + * + * @returns Simulated mesh after applying screw adjustments + */ + estimateBedAfterScrewAdjustment(): number[][] { + const meanHeight = this.bed.getMeanValue(); + + // Auto-select reference corner + this.screwSolver.autoSelectReferenceCorner(this.options.cornerAveragingSize); + + const adjustments = this.screwSolver.calculateAdjustmentsToTarget(meanHeight, this.options.cornerAveragingSize); + + const cornerAdjustments: Partial> = {}; + for (const adj of adjustments) { + if (adj.requiresAdjustment) { + const heightChange = this.screwSolver.heightChangeFromMinutes(adj.minutes, adj.direction); + cornerAdjustments[adj.corner] = heightChange; + } + } + + // Simulate the adjustment + return this.bed.simulateAdjustment(cornerAdjustments); + } + + /** + * Find the optimal calibration strategy. + * + * @returns Strategy recommendation object + */ + findOptimalStrategy(): { + originalDeviation: number; + deviationAfterScrews: number; + needsScrews: boolean; + needsTape: boolean; + expectedFinalDeviation: number; + simulatedBedAfterScrews: number[][]; + } { + const meshStats = this.bed.getMeshStats(); + const originalDeviation = meshStats.range / 2; // Half of range as "deviation" + + // Simulate screw adjustment + const bedAfterScrews = this.estimateBedAfterScrewAdjustment(); + + let sum = 0; + let count = 0; + for (const row of bedAfterScrews) { + for (const val of row) { + sum += val; + count++; + } + } + const meanAfter = sum / count; + + let maxDeviationAfter = 0; + for (const row of bedAfterScrews) { + for (const val of row) { + maxDeviationAfter = Math.max(maxDeviationAfter, Math.abs(val - meanAfter)); + } + } + + const needsScrews = originalDeviation > this.options.screwThreshold; + const needsTape = maxDeviationAfter > this.options.tapeThreshold; + + return { + originalDeviation, + deviationAfterScrews: maxDeviationAfter, + needsScrews, + needsTape, + expectedFinalDeviation: needsTape ? this.options.tapeThreshold : maxDeviationAfter, + simulatedBedAfterScrews: bedAfterScrews, + }; + } + + /** + * Get the screw solver instance for direct adjustment calculations. + * + * @returns ScrewSolver instance + */ + getScrewSolver(): ScrewSolver { + return this.screwSolver; + } +} diff --git a/src/services/calibration/engine/ScrewSolver.ts b/src/services/calibration/engine/ScrewSolver.ts new file mode 100644 index 0000000..929a7fe --- /dev/null +++ b/src/services/calibration/engine/ScrewSolver.ts @@ -0,0 +1,256 @@ +/** + * @fileoverview Calculator for bed leveling screw adjustments. + * Converts height deviations to rotation amounts (minutes, degrees, turns) + * and determines rotation direction for each corner screw. + * + * @module services/calibration/engine/ScrewSolver + */ + +import type { ScrewAdjustment, ScrewConfig } from '../../../types/calibration'; +import { BedCorner, DEFAULT_SCREW_CONFIG, RotationDirection } from '../../../types/calibration'; +import type { Bed } from './Bed'; + +/** + * Calculator for screw adjustments based on corner height deviations. + */ +export class ScrewSolver { + /** Screw configuration */ + private config: ScrewConfig; + + /** Reference to the bed model */ + private readonly bed: Bed; + + /** Which corner is used as reference (typically the lowest) */ + private referenceCorner: BedCorner = BedCorner.FRONT_LEFT; + + /** Calculated mm per minute of rotation */ + private readonly mmPerMinute: number; + + /** + * Create a new ScrewSolver. + * + * @param bed - Bed model with mesh data + * @param config - Screw configuration, defaults to standard config + */ + constructor(bed: Bed, config: ScrewConfig = DEFAULT_SCREW_CONFIG) { + this.bed = bed; + this.config = { ...config }; + + // Pre-calculate conversion factors + // Full rotation (360 degrees) = pitch mm of height change + // 1 minute = 1/60 of a degree + this.mmPerMinute = this.config.pitch / (360 * 60); // mm per arc minute + } + + /** + * Update the screw configuration. + * + * @param config - New screw configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Set which corner to use as the reference point. + * The reference corner won't need adjustment. + * + * @param corner - Corner to use as reference + */ + setReferenceCorner(corner: BedCorner): void { + this.referenceCorner = corner; + } + + /** + * Automatically select the lowest corner as reference. + * + * @param averagingSize - Size of area to average for corner height + * @returns The selected reference corner + */ + autoSelectReferenceCorner(averagingSize = 0): BedCorner { + const corners = this.bed.getCornerValues(averagingSize); + + let lowestCorner = BedCorner.FRONT_LEFT; + let lowestValue = corners.frontLeft; + + if (corners.frontRight < lowestValue) { + lowestCorner = BedCorner.FRONT_RIGHT; + lowestValue = corners.frontRight; + } + if (corners.rearLeft < lowestValue) { + lowestCorner = BedCorner.REAR_LEFT; + lowestValue = corners.rearLeft; + } + if (corners.rearRight < lowestValue) { + lowestCorner = BedCorner.REAR_RIGHT; + } + + this.referenceCorner = lowestCorner; + return lowestCorner; + } + + /** + * Convert height deviation to rotation minutes. + * + * @param deviation - Height deviation in mm + * @returns Rotation in minutes (absolute value) + */ + deviationToMinutes(deviation: number): number { + // minutes = deviation / (pitch / (360 * 60)) + // Simplified: minutes = deviation * 360 * 60 / pitch + return (Math.abs(deviation) * 360 * 60) / this.config.pitch; + } + + /** + * Convert height deviation to rotation degrees. + * + * @param deviation - Height deviation in mm + * @returns Rotation in degrees (absolute value) + */ + deviationToDegrees(deviation: number): number { + return (Math.abs(deviation) * 360) / this.config.pitch; + } + + /** + * Convert height deviation to full turns. + * + * @param deviation - Height deviation in mm + * @returns Number of full rotations (absolute value) + */ + deviationToTurns(deviation: number): number { + return Math.abs(deviation) / this.config.pitch; + } + + /** + * Determine rotation direction based on deviation. + * + * @param deviation - Height deviation in mm (positive = too high, negative = too low) + * @returns Rotation direction + */ + getDirection(deviation: number): RotationDirection { + // Positive deviation = bed too low at this corner = turn CCW to raise it + // Negative deviation = bed too high at this corner = turn CW to lower it + return deviation > 0 ? RotationDirection.COUNTERCLOCKWISE : RotationDirection.CLOCKWISE; + } + + /** + * Calculate height change from rotation in minutes. + * + * @param minutes - Rotation amount in minutes + * @param direction - Rotation direction + * @returns Height change in mm + */ + heightChangeFromMinutes(minutes: number, direction: RotationDirection): number { + const heightChange = minutes * this.mmPerMinute; + // CW lowers the bed (negative change), CCW raises it (positive change) + return direction === RotationDirection.CLOCKWISE ? -heightChange : heightChange; + } + + /** + * Calculate adjustment for a single corner. + * + * @param corner - Which corner to calculate + * @param targetHeight - Target height to adjust to + * @param averagingSize - Size of area to average for corner height + * @returns ScrewAdjustment object + */ + calculateCornerAdjustment(corner: BedCorner, targetHeight: number, averagingSize = 0): ScrewAdjustment { + const currentHeight = this.bed.getCornerHeight(corner, averagingSize); + const deviation = targetHeight - currentHeight; + + const requiresAdjustment = Math.abs(deviation) >= this.config.minAdjust; + + // Clamp deviation to max adjustment + const clampedDeviation = Math.sign(deviation) * Math.min(Math.abs(deviation), this.config.maxAdjust); + + const direction = this.getDirection(clampedDeviation); + const minutes = this.deviationToMinutes(clampedDeviation); + const degrees = this.deviationToDegrees(clampedDeviation); + const turns = this.deviationToTurns(clampedDeviation); + + let formattedAmount: string; + if (!requiresAdjustment) { + formattedAmount = 'No adjustment needed'; + } else if (turns >= 1) { + formattedAmount = `${turns.toFixed(1)} turns ${direction}`; + } else if (degrees >= 30) { + formattedAmount = `${Math.round(degrees)}° ${direction}`; + } else { + formattedAmount = `${Math.round(minutes)} minutes ${direction}`; + } + + return { + corner, + deviation, + direction, + minutes, + degrees, + turns, + formattedAmount, + requiresAdjustment, + }; + } + + /** + * Calculate adjustments for all corners relative to reference. + * + * @param averagingSize - Size of area to average for corner heights + * @returns Array of ScrewAdjustment objects for all corners + */ + calculateAdjustments(averagingSize = 0): ScrewAdjustment[] { + const corners = this.bed.getCornerValues(averagingSize); + + // Get reference height (the target all other corners should match) + const referenceHeight = corners[this.referenceCorner]; + + const adjustments: ScrewAdjustment[] = []; + + for (const corner of Object.values(BedCorner)) { + const adjustment = this.calculateCornerAdjustment(corner, referenceHeight, averagingSize); + adjustments.push(adjustment); + } + + return adjustments; + } + + /** + * Calculate adjustments to reach a target plane (e.g., mean height). + * + * @param targetHeight - Target height for all corners + * @param averagingSize - Size of area to average for corner heights + * @returns Array of ScrewAdjustment objects + */ + calculateAdjustmentsToTarget(targetHeight: number, averagingSize = 0): ScrewAdjustment[] { + const adjustments: ScrewAdjustment[] = []; + + for (const corner of Object.values(BedCorner)) { + const adjustment = this.calculateCornerAdjustment(corner, targetHeight, averagingSize); + adjustments.push(adjustment); + } + + return adjustments; + } + + /** + * Get summary of required adjustments. + * + * @param adjustments - Array of calculated adjustments + * @returns Summary object + */ + getAdjustmentSummary(adjustments: ScrewAdjustment[]): { + totalAdjustmentsNeeded: number; + maxMinutes: number; + corners: { name: string; amount: string }[]; + } { + const needing = adjustments.filter((a) => a.requiresAdjustment); + + return { + totalAdjustmentsNeeded: needing.length, + maxMinutes: Math.max(...adjustments.map((a) => a.minutes), 0), + corners: adjustments.map((a) => ({ + name: a.corner, + amount: a.formattedAmount, + })), + }; + } +} diff --git a/src/services/calibration/engine/TapeCalculator.ts b/src/services/calibration/engine/TapeCalculator.ts new file mode 100644 index 0000000..1e7851d --- /dev/null +++ b/src/services/calibration/engine/TapeCalculator.ts @@ -0,0 +1,366 @@ +/** + * @fileoverview Calculator for tape compensation recommendations. + * Identifies low spots on the bed that need tape shims and calculates + * the number of layers needed to compensate for height differences. + * + * @module services/calibration/engine/TapeCalculator + */ + +import type { TapeConfig, TapeRecommendation } from '../../../types/calibration'; +import { type BedCorner, DEFAULT_TAPE_CONFIG } from '../../../types/calibration'; +import type { Bed } from './Bed'; + +/** + * Detailed tape spot information for mesh-based analysis. + */ +export interface TapeSpot { + /** X coordinate in mesh grid */ + x: number; + /** Y coordinate in mesh grid */ + y: number; + /** Number of tape layers needed */ + layers: number; + /** Height difference to compensate (mm) */ + heightDiff: number; + /** Priority (1 = highest) */ + priority: number; + /** Approximate area size in mm² */ + areaSize: number; +} + +/** + * Calculator for tape compensation on the print bed. + */ +export class TapeCalculator { + /** Bed model reference */ + private readonly bed: Bed; + + /** Tape configuration */ + private config: TapeConfig; + + /** + * Create a new TapeCalculator. + * + * @param bed - Bed model with mesh data + * @param config - Tape configuration + */ + constructor(bed: Bed, config: TapeConfig = DEFAULT_TAPE_CONFIG) { + this.bed = bed; + this.config = { ...config }; + } + + /** + * Update tape configuration. + * + * @param config - New configuration (partial) + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + /** + * Calculate priority based on height difference. + * + * @param heightDiff - Height difference in mm + * @returns Priority level (1 = highest) + */ + private calculatePriority(heightDiff: number): number { + if (heightDiff > 0.3) { + return 1; + } else if (heightDiff > 0.2) { + return 2; + } + return 3; + } + + /** + * Check if a mesh point is at a screw corner position. + * + * @param row - Row index + * @param col - Column index + * @returns True if position is a screw corner + */ + private isAtScrewCorner(row: number, col: number): boolean { + const lastRow = this.bed.config.meshPointsY - 1; + const lastCol = this.bed.config.meshPointsX - 1; + + return ( + (row === 0 && col === 0) || // Front left + (row === 0 && col === lastCol) || // Front right + (row === lastRow && col === 0) || // Rear left + (row === lastRow && col === lastCol) // Rear right + ); + } + + /** + * Calculate approximate area size for a tape spot. + * + * @param heightDiff - Height difference at spot + * @returns Area size in mm² + */ + private calculateAreaSize(heightDiff: number): number { + const { x, y } = this.bed.getMmPerPoint(); + const baseArea = x * y; + + // Increase area for larger deviations + if (heightDiff > 0.3) { + return baseArea * 1.5; + } + return baseArea; + } + + /** + * Calculate tape recommendations for bed corners. + * This is the simple corner-based calculation. + * + * @param averagingSize - Size of area to average for corner heights + * @returns Array of TapeRecommendation for each corner + */ + calculateLayers(averagingSize = 0): TapeRecommendation[] { + const corners = this.bed.getCornerValues(averagingSize); + const heights = [corners.frontLeft, corners.frontRight, corners.rearLeft, corners.rearRight]; + const referenceHeight = Math.min(...heights); // Lowest corner as reference + + const recommendations: TapeRecommendation[] = []; + + for (const [cornerKey, height] of Object.entries(corners)) { + const deviation = height - referenceHeight; + + if (deviation > this.config.minHeightDiff) { + const layers = Math.ceil(deviation / this.config.tapeThickness); + + recommendations.push({ + corner: cornerKey as BedCorner, + layers, + totalThickness: layers * this.config.tapeThickness, + deviation, + }); + } + } + + return recommendations; + } + + /** + * Get total number of tape layers across all corners. + * + * @param averagingSize - Size of area to average for corner heights + * @returns Total number of layers + */ + getTotalLayers(averagingSize = 0): number { + const recommendations = this.calculateLayers(averagingSize); + return recommendations.reduce((sum, r) => sum + r.layers, 0); + } + + /** + * Find low spots in a mesh that need tape compensation. + * This performs mesh-wide analysis, not just corners. + * + * @param simulatedMesh - Mesh data (typically after screw adjustments) + * @returns Array of TapeSpot objects + */ + findLowSpots(simulatedMesh: number[][]): TapeSpot[] { + let sum = 0; + let count = 0; + for (const row of simulatedMesh) { + for (const val of row) { + sum += val; + count++; + } + } + const meanHeight = sum / count; + + const spots: TapeSpot[] = []; + + for (let row = 0; row < this.bed.config.meshPointsY; row++) { + for (let col = 0; col < this.bed.config.meshPointsX; col++) { + if (this.isAtScrewCorner(row, col)) { + continue; + } + + const height = simulatedMesh[row][col]; + const diff = meanHeight - height; + + // If point is lower than mean by more than threshold + if (diff > this.config.minHeightDiff) { + const layers = Math.max(1, Math.ceil(diff / this.config.tapeThickness)); + + spots.push({ + x: col, + y: row, + layers, + heightDiff: diff, + priority: this.calculatePriority(diff), + areaSize: this.calculateAreaSize(diff), + }); + } + } + } + + return spots.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + return b.heightDiff - a.heightDiff; + }); + } + + /** + * Optimize tape layout by merging nearby spots. + * + * @param spots - Array of tape spots + * @returns Optimized array of tape spots + */ + optimizeTapeLayout(spots: TapeSpot[]): TapeSpot[] { + const optimized: TapeSpot[] = []; + const used = new Set(); + + for (const spot of spots) { + const key = `${spot.x},${spot.y}`; + if (used.has(key)) { + continue; + } + + const nearby = spots.filter( + (s) => Math.abs(s.x - spot.x) <= 1 && Math.abs(s.y - spot.y) <= 1 && !used.has(`${s.x},${s.y}`) + ); + + if (nearby.length > 0) { + const avgDiff = nearby.reduce((sum, s) => sum + s.heightDiff, 0) / nearby.length; + const avgLayers = Math.max(1, Math.ceil(avgDiff / this.config.tapeThickness)); + const totalArea = nearby.reduce((sum, s) => sum + s.areaSize, 0); + const minPriority = Math.min(...nearby.map((s) => s.priority)); + + const avgX = nearby.reduce((sum, s) => sum + s.x, 0) / nearby.length; + const avgY = nearby.reduce((sum, s) => sum + s.y, 0) / nearby.length; + const center = nearby.reduce((best, s) => { + const bestDist = Math.abs(best.x - avgX) + Math.abs(best.y - avgY); + const sDist = Math.abs(s.x - avgX) + Math.abs(s.y - avgY); + return sDist < bestDist ? s : best; + }, nearby[0]); + + optimized.push({ + x: center.x, + y: center.y, + layers: avgLayers, + heightDiff: avgDiff, + priority: minPriority, + areaSize: totalArea, + }); + + for (const s of nearby) { + used.add(`${s.x},${s.y}`); + } + } else { + optimized.push(spot); + used.add(key); + } + } + + return optimized; + } + + /** + * Apply tape spots to a mesh to simulate the result. + * + * @param baseMesh - Original mesh data + * @param spots - Tape spots to apply + * @returns Simulated mesh after tape application + */ + applySpots(baseMesh: number[][], spots: TapeSpot[]): number[][] { + // Deep copy mesh + const simulated = baseMesh.map((row) => [...row]); + + for (const spot of spots) { + const heightIncrease = spot.layers * this.config.tapeThickness; + + const rowStart = Math.max(0, spot.y - 1); + const rowEnd = Math.min(this.bed.config.meshPointsY, spot.y + 2); + const colStart = Math.max(0, spot.x - 1); + const colEnd = Math.min(this.bed.config.meshPointsX, spot.x + 2); + + for (let row = rowStart; row < rowEnd; row++) { + for (let col = colStart; col < colEnd; col++) { + simulated[row][col] += heightIncrease; + } + } + } + + return simulated; + } + + /** + * Estimate improvement after applying tape spots. + * + * @param spots - Tape spots to apply + * @returns Estimated deviation reduction in mm + */ + estimateImprovement(spots: TapeSpot[]): number { + if (!this.bed.meshData) { + return 0; + } + + const simulatedMesh = this.applySpots(this.bed.meshData, spots); + + const currentStats = this.bed.getMeshStats(); + const currentDeviation = currentStats.range / 2; + + let _sum = 0; + let _count = 0; + let min = Infinity; + let max = -Infinity; + + for (const row of simulatedMesh) { + for (const val of row) { + _sum += val; + _count++; + min = Math.min(min, val); + max = Math.max(max, val); + } + } + + const simulatedDeviation = (max - min) / 2; + + return currentDeviation - simulatedDeviation; + } + + /** + * Generate tape instructions as text. + * + * @param spots - Tape spots for instructions + * @returns Array of instruction strings + */ + getTapeInstructions(spots: TapeSpot[]): string[] { + if (spots.length === 0) { + return ['No tape compensation required']; + } + + const instructions: string[] = []; + const totalArea = spots.reduce((sum, s) => sum + s.areaSize, 0); + const totalLayers = spots.reduce((sum, s) => sum + s.layers, 0); + + instructions.push( + `Apply tape in ${spots.length} location(s)\n` + + `Total area: ${totalArea.toFixed(1)}mm²\n` + + `Total layers: ${totalLayers}` + ); + + for (const spot of spots) { + const position = `${spot.y + 1}${String.fromCharCode(65 + spot.x)}`; + + let instruction = + `Position ${position}:\n` + + `• Deviation: ${spot.heightDiff.toFixed(3)}mm\n` + + `• Apply ${spot.layers} layer(s) of tape\n` + + `• Area: ${spot.areaSize.toFixed(1)}mm²`; + + if (spot.priority === 1) { + instruction += '\nâš  High priority'; + } + + instructions.push(instruction); + } + + return instructions; + } +} diff --git a/src/services/calibration/engine/WorkflowEngine.ts b/src/services/calibration/engine/WorkflowEngine.ts new file mode 100644 index 0000000..5717525 --- /dev/null +++ b/src/services/calibration/engine/WorkflowEngine.ts @@ -0,0 +1,652 @@ +/** + * @fileoverview Multi-stage calibration workflow orchestration engine. + * Computes sequential calibration stages (belt sync, screw adjust, tape, thermal) + * and aggregates results for UI consumption. + * + * @module services/calibration/engine/WorkflowEngine + */ + +import type { + BeltSyncResult, + CalibrationSettings, + StageResult, + ThermalPrediction, + ThermalPreset, + WorkflowData, +} from '../../../types/calibration'; +import { BedCorner, WorkflowStage } from '../../../types/calibration'; +import type { Bed } from './Bed'; +import type { ScrewSolver } from './ScrewSolver'; +import type { TapeCalculator } from './TapeCalculator'; + +/** + * Action step within a calibration stage. + */ +export interface StageAction { + /** Type of action (belt, screw, tape, info) */ + kind: 'belt' | 'screw' | 'tape' | 'info'; + /** Unique identifier */ + identifier: string; + /** Display label */ + label: string; + /** Rotation/adjustment direction */ + direction?: 'clockwise' | 'counterclockwise' | 'up' | 'down'; + /** Magnitude in mm */ + magnitudeMm?: number; + /** Belt teeth count */ + teeth?: number; + /** Rotation in minutes */ + minutes?: number; + /** Rotation in degrees */ + degrees?: number; + /** Additional metadata */ + metadata?: Record; +} + +/** + * Result from a single workflow stage. + */ +export interface WorkflowStageResult { + /** Stage key identifier */ + key: string; + /** Display label */ + label: string; + /** Stage description */ + description: string; + /** Whether stage is enabled */ + enabled: boolean; + /** Deviation after this stage */ + deviation: number; + /** Baseline deviation before stage */ + baseline: number | null; + /** Mesh state after this stage */ + mesh: number[][]; + /** Actions to take in this stage */ + actions: StageAction[]; + /** Warning messages */ + warnings: string[]; +} + +/** + * Workflow flags for enabling/disabling stages. + */ +interface WorkflowFlags { + enableBelt: boolean; + enableScrews: boolean; + enableTape: boolean; +} + +/** + * Multi-stage calibration workflow engine. + */ +export class WorkflowEngine { + private readonly bed: Bed; + private readonly screwSolver: ScrewSolver; + private readonly tapeCalculator: TapeCalculator; + private settings: CalibrationSettings; + + /** + * Create a new WorkflowEngine. + */ + constructor(bed: Bed, screwSolver: ScrewSolver, tapeCalculator: TapeCalculator, settings: CalibrationSettings) { + this.bed = bed; + this.screwSolver = screwSolver; + this.tapeCalculator = tapeCalculator; + this.settings = settings; + } + + /** + * Update settings. + */ + setSettings(settings: CalibrationSettings): void { + this.settings = settings; + } + + /** + * Compute stage deviation (max - min). + */ + private computeStageDeviation(mesh: number[][]): number { + let min = Infinity; + let max = -Infinity; + + for (const row of mesh) { + for (const val of row) { + min = Math.min(min, val); + max = Math.max(max, val); + } + } + + return max - min; + } + + /** + * Deep copy a mesh. + */ + private copyMesh(mesh: number[][]): number[][] { + return mesh.map((row) => [...row]); + } + + /** + * Compute the initial stage (before any corrections). + */ + private computeInitialStage(mesh: number[][]): WorkflowStageResult { + const deviation = this.computeStageDeviation(mesh); + + return { + key: 'initial', + label: 'Initial State', + description: 'Current bed mesh before any adjustments', + enabled: true, + deviation, + baseline: null, + mesh: this.copyMesh(mesh), + actions: [], + warnings: [], + }; + } + + /** + * Build the belt sync stage. + */ + private buildBeltStage( + meshBefore: number[][], + enabled: boolean + ): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + if (!enabled) { + return { + stage: { + key: 'after_belts', + label: 'Belt Synchronization', + description: 'Z-axis belt/lead screw synchronization', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['Stage disabled'], + }, + meshAfter: meshBefore, + }; + } + + const rows = meshBefore.length; + const cols = meshBefore[0]?.length || 0; + + if (rows === 0 || cols === 0) { + return { + stage: { + key: 'after_belts', + label: 'Belt Synchronization', + description: 'Z-axis belt/lead screw synchronization', + enabled: true, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['No mesh data'], + }, + meshAfter: meshBefore, + }; + } + + const beltThreshold = this.settings.thresholds.beltThreshold; + const toothMm = this.settings.hardware.beltToothMm; + + const leftFront = meshBefore[0][0]; + const rightFront = meshBefore[0][cols - 1]; + const backCenter = meshBefore[rows - 1][Math.floor(cols / 2)]; + const frontAvg = (leftFront + rightFront) / 2; + + const actions: StageAction[] = []; + + // Left-right tilt + const lrDiff = rightFront - leftFront; + if (Math.abs(lrDiff) > beltThreshold) { + const teeth = Math.max(1, Math.ceil(Math.abs(lrDiff) / toothMm)); + const targetCorner = lrDiff > 0 ? 'front_left' : 'front_right'; + + actions.push({ + kind: 'belt', + identifier: targetCorner, + label: targetCorner === 'front_left' ? 'Front Left' : 'Front Right', + direction: 'up', + magnitudeMm: teeth * toothMm, + teeth, + metadata: { rawDifference: lrDiff }, + }); + } + + // Front-back tilt + const fbDiff = backCenter - frontAvg; + if (Math.abs(fbDiff) > beltThreshold) { + const teeth = Math.max(1, Math.ceil(Math.abs(fbDiff) / toothMm)); + + actions.push({ + kind: 'belt', + identifier: 'back', + label: 'Back Center', + direction: fbDiff < 0 ? 'up' : 'down', + magnitudeMm: teeth * toothMm, + teeth, + metadata: { rawDifference: fbDiff }, + }); + } + + // Simulate belt adjustment effect (simplified) + const meshAfter = this.copyMesh(meshBefore); + // In a full implementation, we'd apply weighted adjustments + // Belt adjustments are assumed to reduce tilt proportionally + + const deviation = this.computeStageDeviation(meshAfter); + + return { + stage: { + key: 'after_belts', + label: 'Belt Synchronization', + description: 'Z-axis belt/lead screw synchronization', + enabled: true, + deviation, + baseline, + mesh: meshAfter, + actions, + warnings: actions.length === 0 ? ['No belt adjustments needed'] : [], + }, + meshAfter, + }; + } + + /** + * Build the screw adjustment stage. + */ + private buildScrewStage( + meshBefore: number[][], + enabled: boolean + ): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + if (!enabled) { + return { + stage: { + key: 'after_screws', + label: 'Screw Adjustments', + description: 'Corner screw leveling adjustments', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['Stage disabled'], + }, + meshAfter: meshBefore, + }; + } + + // Auto-select reference corner and calculate adjustments + this.screwSolver.autoSelectReferenceCorner(this.settings.hardware.cornerAveraging); + const adjustments = this.screwSolver.calculateAdjustments(this.settings.hardware.cornerAveraging); + + const actions: StageAction[] = adjustments + .filter((adj) => adj.requiresAdjustment) + .map((adj) => ({ + kind: 'screw' as const, + identifier: adj.corner, + label: this.formatCornerName(adj.corner), + direction: adj.direction === 'CW' ? ('clockwise' as const) : ('counterclockwise' as const), + minutes: adj.minutes, + degrees: adj.degrees, + magnitudeMm: Math.abs(adj.deviation), + metadata: { turns: adj.turns }, + })); + + // Simulate screw adjustments + const cornerAdjustments: Partial> = {}; + for (const adj of adjustments) { + if (adj.requiresAdjustment) { + const change = this.screwSolver.heightChangeFromMinutes(adj.minutes, adj.direction); + cornerAdjustments[adj.corner] = change; + } + } + + const meshAfter = this.bed.simulateAdjustment(cornerAdjustments); + const deviation = this.computeStageDeviation(meshAfter); + + return { + stage: { + key: 'after_screws', + label: 'Screw Adjustments', + description: 'Corner screw leveling adjustments', + enabled: true, + deviation, + baseline, + mesh: meshAfter, + actions, + warnings: actions.length === 0 ? ['No screw adjustments needed'] : [], + }, + meshAfter, + }; + } + + /** + * Build the tape compensation stage. + */ + private buildTapeStage( + meshBefore: number[][], + enabled: boolean + ): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + if (!enabled) { + return { + stage: { + key: 'after_tape', + label: 'Tape Compensation', + description: 'Fine adjustment using tape shims', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['Stage disabled'], + }, + meshAfter: meshBefore, + }; + } + + const spots = this.tapeCalculator.findLowSpots(meshBefore); + const optimizedSpots = this.tapeCalculator.optimizeTapeLayout(spots); + + const actions: StageAction[] = optimizedSpots.map((spot) => ({ + kind: 'tape' as const, + identifier: `${spot.y + 1}${String.fromCharCode(65 + spot.x)}`, + label: `Position ${spot.y + 1}${String.fromCharCode(65 + spot.x)}`, + magnitudeMm: spot.heightDiff, + metadata: { + layers: spot.layers, + thickness: spot.layers * this.settings.hardware.tapeThickness, + }, + })); + + const meshAfter = + optimizedSpots.length > 0 + ? this.tapeCalculator.applySpots(meshBefore, optimizedSpots) + : this.copyMesh(meshBefore); + + const deviation = this.computeStageDeviation(meshAfter); + + return { + stage: { + key: 'after_tape', + label: 'Tape Compensation', + description: 'Fine adjustment using tape shims', + enabled: true, + deviation, + baseline, + mesh: meshAfter, + actions, + warnings: actions.length === 0 ? ['No tape compensation needed'] : [], + }, + meshAfter, + }; + } + + /** + * Build the thermal prediction stage. + */ + private buildTemperatureStage(meshBefore: number[][]): { stage: WorkflowStageResult; meshAfter: number[][] } { + const baseline = this.computeStageDeviation(meshBefore); + + const activePreset = this.settings.thermalPresets.find((p) => p.name === this.settings.activeThermalPreset); + + if (!activePreset) { + return { + stage: { + key: 'after_temperature', + label: 'Temperature Prediction', + description: 'Thermal expansion effects at operating temperature', + enabled: false, + deviation: baseline, + baseline, + mesh: this.copyMesh(meshBefore), + actions: [], + warnings: ['No thermal preset selected'], + }, + meshAfter: meshBefore, + }; + } + + const meshAfter = this.applyThermalEffect(meshBefore, activePreset); + const deviation = this.computeStageDeviation(meshAfter); + + const hasEffect = Math.abs(deviation - baseline) > 0.001; + + return { + stage: { + key: 'after_temperature', + label: 'Temperature Prediction', + description: `Thermal expansion at ${activePreset.targetTemp}°C`, + enabled: hasEffect, + deviation, + baseline, + mesh: meshAfter, + actions: [], + warnings: hasEffect ? [] : ['Minimal thermal effect'], + }, + meshAfter, + }; + } + + /** + * Apply thermal expansion effect to mesh. + */ + private applyThermalEffect(mesh: number[][], preset: ThermalPreset): number[][] { + const rows = mesh.length; + const cols = mesh[0]?.length || 0; + + if (rows === 0 || cols === 0) { + return this.copyMesh(mesh); + } + + const deltaTemp = preset.targetTemp - preset.measurementTemp; + if (Math.abs(deltaTemp) < 0.1) { + return this.copyMesh(mesh); + } + + const { x: xStep, y: yStep } = this.bed.getMmPerPoint(); + const centerX = this.bed.config.sizeX / 2; + const centerY = this.bed.config.sizeY / 2; + + const result = this.copyMesh(mesh); + + // Simple radial thermal expansion model + const expansionCoeff = preset.alphaSteel || this.settings.environment.thermalExpansionCoeff; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * xStep - centerX; + const y = row * yStep - centerY; + const radiusSq = x * x + y * y; + const maxRadiusSq = centerX * centerX + centerY * centerY; + + // Warp increases with distance from center + const warp = expansionCoeff * deltaTemp * (radiusSq / maxRadiusSq); + result[row][col] += warp; + } + } + + // Normalize to zero mean offset + let sum = 0; + for (const row of result) { + for (const val of row) { + sum += val; + } + } + const mean = sum / (rows * cols); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + result[row][col] -= mean; + } + } + + return result; + } + + /** + * Format corner name for display. + */ + private formatCornerName(corner: BedCorner | string): string { + const mapping: Record = { + [BedCorner.FRONT_LEFT]: 'Front Left', + [BedCorner.FRONT_RIGHT]: 'Front Right', + [BedCorner.REAR_LEFT]: 'Rear Left', + [BedCorner.REAR_RIGHT]: 'Rear Right', + }; + return mapping[corner] || corner; + } + + /** + * Compute the complete calibration workflow. + */ + computeWorkflow(): WorkflowData { + if (!this.bed.meshData) { + throw new Error('No mesh data loaded'); + } + + const startTime = Date.now(); + const meshState = this.copyMesh(this.bed.meshData); + const stages = new Map(); + const completedStages: WorkflowStage[] = []; + + const flags: WorkflowFlags = { + enableBelt: this.settings.workflow.enableBelt, + enableScrews: this.settings.workflow.enableScrews, + enableTape: this.settings.workflow.enableTape, + }; + + // Initial stage + const initialResult = this.computeInitialStage(meshState); + const initialRange = initialResult.deviation; + + // Belt stage + const { stage: beltStage, meshAfter: meshAfterBelt } = this.buildBeltStage(meshState, flags.enableBelt); + + // Screw stage + const { stage: screwStage, meshAfter: meshAfterScrew } = this.buildScrewStage(meshAfterBelt, flags.enableScrews); + + // Tape stage + const { stage: tapeStage, meshAfter: meshAfterTape } = this.buildTapeStage(meshAfterScrew, flags.enableTape); + + // Temperature stage + const { stage: tempStage, meshAfter: meshAfterTemp } = this.buildTemperatureStage(meshAfterTape); + + const beltSyncResult: BeltSyncResult = { + xAxisTilt: 0, // Calculated from belt stage + yAxisTilt: 0, + needsSync: beltStage.actions.length > 0, + recommendation: + beltStage.actions.length > 0 ? `Adjust ${beltStage.actions.length} belt position(s)` : 'Belts are synchronized', + }; + + const screwAdjustments = this.screwSolver.calculateAdjustments(this.settings.hardware.cornerAveraging); + + const tapeRecommendations = this.tapeCalculator.calculateLayers(this.settings.hardware.cornerAveraging); + + const thermalPrediction: ThermalPrediction = { + predictedMesh: meshAfterTemp, + rangeChange: tempStage.deviation - tapeStage.deviation, + tempDelta: this.settings.activeThermalPreset + ? (this.settings.thermalPresets.find((p) => p.name === this.settings.activeThermalPreset)?.targetTemp || 60) - + (this.settings.thermalPresets.find((p) => p.name === this.settings.activeThermalPreset)?.measurementTemp || + 25) + : 0, + expansionCoeff: this.settings.environment.thermalExpansionCoeff, + }; + + stages.set(WorkflowStage.INITIAL, { + stage: WorkflowStage.INITIAL, + success: true, + data: initialResult, + metrics: { deviation: initialResult.deviation }, + nextStage: WorkflowStage.BELT_SYNC, + }); + completedStages.push(WorkflowStage.INITIAL); + + stages.set(WorkflowStage.BELT_SYNC, { + stage: WorkflowStage.BELT_SYNC, + success: true, + data: beltStage, + metrics: { deviation: beltStage.deviation }, + nextStage: WorkflowStage.SCREW_ADJUST, + }); + completedStages.push(WorkflowStage.BELT_SYNC); + + stages.set(WorkflowStage.SCREW_ADJUST, { + stage: WorkflowStage.SCREW_ADJUST, + success: true, + data: screwStage, + metrics: { deviation: screwStage.deviation }, + nextStage: WorkflowStage.TAPE_COMPENSATE, + }); + completedStages.push(WorkflowStage.SCREW_ADJUST); + + stages.set(WorkflowStage.TAPE_COMPENSATE, { + stage: WorkflowStage.TAPE_COMPENSATE, + success: true, + data: tapeStage, + metrics: { deviation: tapeStage.deviation }, + nextStage: WorkflowStage.THERMAL_PREDICT, + }); + completedStages.push(WorkflowStage.TAPE_COMPENSATE); + + stages.set(WorkflowStage.THERMAL_PREDICT, { + stage: WorkflowStage.THERMAL_PREDICT, + success: true, + data: tempStage, + metrics: { deviation: tempStage.deviation }, + nextStage: WorkflowStage.COMPLETE, + }); + completedStages.push(WorkflowStage.THERMAL_PREDICT); + + const finalRange = tempStage.deviation; + const improvementPercent = initialRange > 0 ? ((initialRange - finalRange) / initialRange) * 100 : 0; + + return { + currentStage: WorkflowStage.COMPLETE, + stages, + startTime, + completedStages, + beltSyncResult, + screwAdjustments, + tapeRecommendations, + thermalPrediction, + initialRange, + finalRange, + improvementPercent, + }; + } + + /** + * Get result for a specific stage. + */ + getStageResult(workflow: WorkflowData, stage: WorkflowStage): StageResult | undefined { + return workflow.stages.get(stage); + } + + /** + * Check if a stage can be skipped. + */ + canSkipStage(stage: WorkflowStage): boolean { + switch (stage) { + case WorkflowStage.BELT_SYNC: + return !this.settings.workflow.enableBelt; + case WorkflowStage.SCREW_ADJUST: + return !this.settings.workflow.enableScrews; + case WorkflowStage.TAPE_COMPENSATE: + return !this.settings.workflow.enableTape; + default: + return false; + } + } +} diff --git a/src/services/calibration/engine/index.ts b/src/services/calibration/engine/index.ts new file mode 100644 index 0000000..94c8188 --- /dev/null +++ b/src/services/calibration/engine/index.ts @@ -0,0 +1,18 @@ +/** + * @fileoverview Exports for the calibration engine module. + * Provides core calibration functionality including bed modeling, + * deviation analysis, screw/tape calculations, and workflow orchestration. + * + * @module services/calibration/engine + */ + +export type { MeshStats } from './Bed'; +export { Bed } from './Bed'; +export type { DeviationAnalyzerOptions, DeviationStats, LevelingStage } from './DeviationAnalyzer'; +export { DeviationAnalyzer } from './DeviationAnalyzer'; + +export { ScrewSolver } from './ScrewSolver'; +export type { TapeSpot } from './TapeCalculator'; +export { TapeCalculator } from './TapeCalculator'; +export type { StageAction, WorkflowStageResult } from './WorkflowEngine'; +export { WorkflowEngine } from './WorkflowEngine'; diff --git a/src/services/calibration/index.ts b/src/services/calibration/index.ts new file mode 100644 index 0000000..bf33809 --- /dev/null +++ b/src/services/calibration/index.ts @@ -0,0 +1,35 @@ +/** + * @fileoverview Main exports for the calibration module. + * Provides access to all calibration services including engine components, + * parsers, SSH connectivity, and input shaper analysis. + * + * @module services/calibration + */ + +// Engine exports +export * from './engine'; +export type { ParseResult } from './parsers/KlipperConfigParser'; +// Parser exports +export { KlipperConfigParser, klipperConfigParser } from './parsers/KlipperConfigParser'; +export type { FFTOptions } from './shaper'; +// Shaper exports +export { + DEFAULT_FFT_OPTIONS, + FFTProcessor, + getAllShaperDefinitions, + getShaperDefinition, + SHAPER_DEFINITIONS, + ShaperAnalyzer, +} from './shaper'; +export type { + CommandResult, + SSHConnection, + SSHConnectionManagerEvents, +} from './ssh'; +// SSH exports +export { + DEFAULT_REMOTE_PATHS, + getSSHConnectionManager, + SCPFileTransfer, + SSHConnectionManager, +} from './ssh'; diff --git a/src/services/calibration/parsers/KlipperConfigParser.ts b/src/services/calibration/parsers/KlipperConfigParser.ts new file mode 100644 index 0000000..ec78c23 --- /dev/null +++ b/src/services/calibration/parsers/KlipperConfigParser.ts @@ -0,0 +1,292 @@ +/** + * @fileoverview Parser for Klipper printer.cfg files to extract bed mesh data. + * Handles the #*# format used by Klipper to store bed mesh calibration data. + * + * The bed mesh section in printer.cfg looks like: + * ``` + * #*# [bed_mesh default] + * #*# version = 1 + * #*# points = + * #*# 0.025, 0.012, -0.003, ... + * #*# 0.018, 0.005, -0.010, ... + * #*# x_count = 7 + * #*# y_count = 7 + * #*# min_x = 15.0 + * #*# max_x = 205.0 + * #*# min_y = 15.0 + * #*# max_y = 205.0 + * ``` + * + * @module services/calibration/parsers/KlipperConfigParser + */ + +import type { BedConfig, MeshData } from '../../../types/calibration'; + +/** + * Result of a parse operation. + */ +export interface ParseResult { + success: boolean; + data?: MeshData; + error?: string; +} + +/** + * Parser for Klipper printer configuration files. + * Extracts bed mesh data from the #*# formatted sections. + */ +export class KlipperConfigParser { + /** + * Regex pattern to match mesh point lines. + * Matches lines like: #*# 0.025, 0.012, -0.003 + * Also supports scientific notation: #*# 1e-2, 0.0, -1e-2 + */ + private readonly pointPattern = /^#\*#\s+(-?\d+\.?\d*(?:[eE][+-]?\d+)?,\s*)*-?\d+\.?\d*(?:[eE][+-]?\d+)?\s*$/; + + /** + * Regex pattern to match parameter lines. + * Matches lines like: #*# x_count = 7 or #*# points = + */ + private readonly paramPattern = /^#\*#\s+(\w+)\s*=\s*(.*)$/; + + /** + * Regex pattern to match bed mesh section header. + * Matches lines like: #*# [bed_mesh default] + */ + private readonly sectionPattern = /^#\*#\s+\[bed_mesh\s+(\w+)\]$/; + + /** + * Required parameters for a valid mesh. + */ + private readonly requiredParams = ['x_count', 'y_count', 'min_x', 'max_x', 'min_y', 'max_y']; + + /** + * Parse a Klipper config file to extract bed mesh data. + * + * @param content - The full content of the printer.cfg file + * @param profileName - Optional specific profile name to extract (default: 'default') + * @returns ParseResult with mesh data or error + */ + parseConfigFile(content: string, profileName = 'default'): ParseResult { + try { + const lines = content.split('\n'); + const pointsData: number[][] = []; + const params: Record = {}; + let foundProfile = ''; + let inMeshSection = false; + let inPointsSection = false; + + for (const line of lines) { + const sectionMatch = this.sectionPattern.exec(line); + if (sectionMatch) { + foundProfile = sectionMatch[1]; + inMeshSection = foundProfile === profileName; + inPointsSection = false; + continue; + } + + if (!inMeshSection) { + continue; + } + + const paramMatch = this.paramPattern.exec(line); + if (paramMatch) { + const [, key, value] = paramMatch; + if (key === 'points') { + inPointsSection = true; + } else { + params[key] = value.trim(); + inPointsSection = false; + } + continue; + } + + if (inPointsSection && this.pointPattern.test(line)) { + const cleanLine = line.replace(/^#\*#\s*/, '').trim(); + if (cleanLine) { + const points = cleanLine.split(',').map((p) => { + const parsed = parseFloat(p.trim()); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid point value: ${p}`); + } + return parsed; + }); + pointsData.push(points); + } + continue; + } + + // If we hit a non-mesh line while in mesh section, we're done + if (line.startsWith('#*#')) { + // Still in #*# block, could be another section + if (line.match(/^#\*#\s+\[/)) { + inMeshSection = false; + inPointsSection = false; + } + } + } + + if (pointsData.length === 0) { + return { + success: false, + error: `No mesh data found for profile '${profileName}'`, + }; + } + + for (const param of this.requiredParams) { + if (!(param in params)) { + return { + success: false, + error: `Missing required parameter: ${param}`, + }; + } + } + + const xCount = parseInt(params.x_count, 10); + const yCount = parseInt(params.y_count, 10); + const minX = parseFloat(params.min_x); + const maxX = parseFloat(params.max_x); + const minY = parseFloat(params.min_y); + const maxY = parseFloat(params.max_y); + + if (pointsData.length !== yCount) { + return { + success: false, + error: `Row count mismatch: expected ${yCount}, got ${pointsData.length}`, + }; + } + + for (let i = 0; i < pointsData.length; i++) { + if (pointsData[i].length !== xCount) { + return { + success: false, + error: `Column count mismatch in row ${i}: expected ${xCount}, got ${pointsData[i].length}`, + }; + } + } + + const meshData: MeshData = { + matrix: pointsData, + minX, + maxX, + minY, + maxY, + pointsX: xCount, + pointsY: yCount, + profileName: foundProfile || profileName, + }; + + return { + success: true, + data: meshData, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown parsing error', + }; + } + } + + /** + * Extract all available mesh profile names from a config file. + * + * @param content - The full content of the printer.cfg file + * @returns Array of profile names found + */ + getAvailableProfiles(content: string): string[] { + const profiles: string[] = []; + const lines = content.split('\n'); + + for (const line of lines) { + const match = this.sectionPattern.exec(line); + if (match) { + profiles.push(match[1]); + } + } + + return profiles; + } + + /** + * Validate mesh data for reasonable values. + * + * @param meshData - The mesh data to validate + * @returns Object with isValid flag and optional error message + */ + validateMeshData(meshData: MeshData): { isValid: boolean; error?: string } { + if (meshData.matrix.length !== meshData.pointsY) { + return { + isValid: false, + error: `Matrix row count (${meshData.matrix.length}) doesn't match pointsY (${meshData.pointsY})`, + }; + } + + for (let i = 0; i < meshData.matrix.length; i++) { + if (meshData.matrix[i].length !== meshData.pointsX) { + return { + isValid: false, + error: `Row ${i} column count (${meshData.matrix[i].length}) doesn't match pointsX (${meshData.pointsX})`, + }; + } + } + + // Check for reasonable Z values (typical bed deviation range) + const maxReasonableDeviation = 10; // mm + for (let y = 0; y < meshData.matrix.length; y++) { + for (let x = 0; x < meshData.matrix[y].length; x++) { + const value = meshData.matrix[y][x]; + + if (Number.isNaN(value) || !Number.isFinite(value)) { + return { + isValid: false, + error: `Invalid value at position [${y}][${x}]: ${value}`, + }; + } + + if (Math.abs(value) > maxReasonableDeviation) { + return { + isValid: false, + error: `Unreasonable deviation at [${y}][${x}]: ${value}mm (max: ±${maxReasonableDeviation}mm)`, + }; + } + } + } + + if (meshData.minX >= meshData.maxX) { + return { + isValid: false, + error: `Invalid X bounds: minX (${meshData.minX}) >= maxX (${meshData.maxX})`, + }; + } + + if (meshData.minY >= meshData.maxY) { + return { + isValid: false, + error: `Invalid Y bounds: minY (${meshData.minY}) >= maxY (${meshData.maxY})`, + }; + } + + return { isValid: true }; + } + + /** + * Create a BedConfig from parsed mesh data. + * + * @param meshData - The parsed mesh data + * @returns BedConfig derived from mesh dimensions + */ + createBedConfigFromMesh(meshData: MeshData): BedConfig { + return { + sizeX: meshData.maxX - meshData.minX, + sizeY: meshData.maxY - meshData.minY, + meshPointsX: meshData.pointsX, + meshPointsY: meshData.pointsY, + }; + } +} + +/** + * Singleton instance for convenience. + */ +export const klipperConfigParser = new KlipperConfigParser(); diff --git a/src/services/calibration/report/ReportRenderer.ts b/src/services/calibration/report/ReportRenderer.ts new file mode 100644 index 0000000..08f1e3b --- /dev/null +++ b/src/services/calibration/report/ReportRenderer.ts @@ -0,0 +1,261 @@ +/** + * @fileoverview Utilities for rendering calibration reports to binary formats. + * Generates heatmap PNGs and PDF summaries from mesh data and analysis. + * + * @module services/calibration/report/ReportRenderer + */ + +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import { PNG } from 'pngjs'; +import type { AnalysisResult, CalibrationSettings, MeshData, WorkflowData } from '../../../types/calibration'; + +type ColorStop = { position: number; color: [number, number, number] }; + +const VIRIDIS_STOPS: ColorStop[] = [ + { position: 0.0, color: [68, 1, 84] }, + { position: 0.1, color: [72, 40, 120] }, + { position: 0.2, color: [62, 74, 137] }, + { position: 0.3, color: [49, 104, 142] }, + { position: 0.4, color: [38, 130, 142] }, + { position: 0.5, color: [31, 158, 137] }, + { position: 0.6, color: [53, 183, 121] }, + { position: 0.7, color: [109, 205, 89] }, + { position: 0.8, color: [180, 222, 44] }, + { position: 0.9, color: [223, 227, 24] }, + { position: 1.0, color: [253, 231, 37] }, +]; + +const PLASMA_STOPS: ColorStop[] = [ + { position: 0.0, color: [13, 8, 135] }, + { position: 0.1, color: [75, 3, 161] }, + { position: 0.2, color: [125, 3, 168] }, + { position: 0.3, color: [168, 34, 150] }, + { position: 0.4, color: [203, 70, 121] }, + { position: 0.5, color: [229, 107, 93] }, + { position: 0.6, color: [248, 148, 65] }, + { position: 0.7, color: [253, 195, 40] }, + { position: 0.8, color: [240, 249, 33] }, + { position: 1.0, color: [240, 249, 33] }, +]; + +const INFERNO_STOPS: ColorStop[] = [ + { position: 0.0, color: [0, 0, 4] }, + { position: 0.1, color: [40, 11, 84] }, + { position: 0.2, color: [89, 13, 115] }, + { position: 0.3, color: [137, 27, 100] }, + { position: 0.4, color: [181, 50, 64] }, + { position: 0.5, color: [219, 87, 26] }, + { position: 0.6, color: [244, 130, 7] }, + { position: 0.7, color: [252, 180, 31] }, + { position: 0.8, color: [250, 230, 102] }, + { position: 1.0, color: [252, 255, 164] }, +]; + +const COOLWARM_STOPS: ColorStop[] = [ + { position: 0.0, color: [59, 76, 192] }, + { position: 0.1, color: [98, 130, 234] }, + { position: 0.2, color: [141, 176, 254] }, + { position: 0.3, color: [184, 208, 249] }, + { position: 0.4, color: [221, 221, 221] }, + { position: 0.5, color: [245, 245, 245] }, + { position: 0.6, color: [249, 196, 178] }, + { position: 0.7, color: [244, 154, 123] }, + { position: 0.8, color: [221, 96, 73] }, + { position: 0.9, color: [192, 40, 40] }, + { position: 1.0, color: [180, 4, 38] }, +]; + +const COLOR_SCALES: Record = { + viridis: VIRIDIS_STOPS, + plasma: PLASMA_STOPS, + inferno: INFERNO_STOPS, + coolwarm: COOLWARM_STOPS, +}; + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function getColor(scheme: string, t: number): [number, number, number] { + const stops = COLOR_SCALES[scheme] || COLOR_SCALES.viridis; + const normalized = clamp01(t); + let lower = stops[0]; + let upper = stops[stops.length - 1]; + + for (let i = 0; i < stops.length - 1; i++) { + if (normalized >= stops[i].position && normalized <= stops[i + 1].position) { + lower = stops[i]; + upper = stops[i + 1]; + break; + } + } + + const range = upper.position - lower.position; + const localT = range > 0 ? (normalized - lower.position) / range : 0; + + return [ + Math.round(lerp(lower.color[0], upper.color[0], localT)), + Math.round(lerp(lower.color[1], upper.color[1], localT)), + Math.round(lerp(lower.color[2], upper.color[2], localT)), + ]; +} + +function setPixel(data: Buffer, width: number, x: number, y: number, r: number, g: number, b: number, a = 255) { + const idx = (width * y + x) * 4; + data[idx] = r; + data[idx + 1] = g; + data[idx + 2] = b; + data[idx + 3] = a; +} + +export interface HeatmapRenderOptions { + cellSize?: number; + padding?: number; + colorScheme?: CalibrationSettings['visualization']['colorScheme']; + showGrid?: boolean; +} + +export function renderHeatmapPNG(meshData: MeshData, options: HeatmapRenderOptions = {}): Buffer { + const matrix = meshData.matrix; + const rows = matrix.length; + const cols = matrix[0]?.length || 0; + if (rows === 0 || cols === 0) { + throw new Error('Mesh data is empty'); + } + + const cellSize = options.cellSize ?? 24; + const padding = options.padding ?? 20; + const showGrid = options.showGrid ?? true; + const scheme = options.colorScheme ?? 'viridis'; + + const width = cols * cellSize + padding * 2; + const height = rows * cellSize + padding * 2; + + const png = new PNG({ width, height }); + png.data.fill(255); + + const values = matrix.flat(); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const value = matrix[row][col]; + const t = range > 0 ? (value - min) / range : 0.5; + const [r, g, b] = getColor(scheme, t); + + const startX = padding + col * cellSize; + const startY = padding + row * cellSize; + + for (let y = startY; y < startY + cellSize; y++) { + for (let x = startX; x < startX + cellSize; x++) { + setPixel(png.data, width, x, y, r, g, b, 255); + } + } + } + } + + if (showGrid && cellSize >= 6) { + const gridColor = [32, 32, 32]; + for (let row = 0; row <= rows; row++) { + const y = padding + row * cellSize; + if (y >= 0 && y < height) { + for (let x = padding; x < padding + cols * cellSize; x++) { + setPixel(png.data, width, x, y, gridColor[0], gridColor[1], gridColor[2], 255); + } + } + } + for (let col = 0; col <= cols; col++) { + const x = padding + col * cellSize; + if (x >= 0 && x < width) { + for (let y = padding; y < padding + rows * cellSize; y++) { + setPixel(png.data, width, x, y, gridColor[0], gridColor[1], gridColor[2], 255); + } + } + } + } + + return PNG.sync.write(png); +} + +export interface ReportPDFOptions { + contextId: string; + meshData: MeshData; + analysis: AnalysisResult | null; + workflow: WorkflowData | null; + settings: CalibrationSettings; +} + +export async function renderReportPDF(options: ReportPDFOptions): Promise { + const pdfDoc = await PDFDocument.create(); + const page = pdfDoc.addPage([612, 792]); + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + const margin = 40; + let cursorY = page.getHeight() - margin; + + page.drawText('Calibration Report', { + x: margin, + y: cursorY, + size: 20, + font: fontBold, + color: rgb(0.12, 0.12, 0.12), + }); + cursorY -= 26; + + const timestamp = new Date().toISOString(); + page.drawText(`Context: ${options.contextId}`, { x: margin, y: cursorY, size: 11, font, color: rgb(0.2, 0.2, 0.2) }); + cursorY -= 16; + page.drawText(`Generated: ${timestamp}`, { x: margin, y: cursorY, size: 11, font, color: rgb(0.2, 0.2, 0.2) }); + cursorY -= 22; + + const summaryLines: string[] = []; + if (options.analysis) { + summaryLines.push(`Mesh range: ${options.analysis.meshRange.toFixed(4)} mm`); + summaryLines.push(`Max deviation: ${options.analysis.maxDeviation.toFixed(4)} mm`); + summaryLines.push(`Std dev: ${options.analysis.standardDeviation.toFixed(4)} mm`); + } + if (options.workflow) { + summaryLines.push(`Improvement: ${options.workflow.improvementPercent.toFixed(1)}%`); + } + + if (summaryLines.length > 0) { + page.drawText('Summary', { x: margin, y: cursorY, size: 13, font: fontBold, color: rgb(0.15, 0.15, 0.15) }); + cursorY -= 18; + for (const line of summaryLines) { + page.drawText(`• ${line}`, { x: margin + 4, y: cursorY, size: 11, font, color: rgb(0.25, 0.25, 0.25) }); + cursorY -= 14; + } + cursorY -= 6; + } + + const heatmapPng = renderHeatmapPNG(options.meshData, { + colorScheme: options.settings.visualization.colorScheme, + cellSize: 24, + padding: 16, + }); + const image = await pdfDoc.embedPng(heatmapPng); + const maxWidth = page.getWidth() - margin * 2; + const maxHeight = cursorY - margin; + const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1); + const imageWidth = image.width * scale; + const imageHeight = image.height * scale; + const imageX = margin; + const imageY = Math.max(margin, cursorY - imageHeight); + + page.drawImage(image, { + x: imageX, + y: imageY, + width: imageWidth, + height: imageHeight, + }); + + const pdfBytes = await pdfDoc.save(); + return Buffer.from(pdfBytes); +} diff --git a/src/services/calibration/shaper/FFTProcessor.ts b/src/services/calibration/shaper/FFTProcessor.ts new file mode 100644 index 0000000..4b2bfda --- /dev/null +++ b/src/services/calibration/shaper/FFTProcessor.ts @@ -0,0 +1,360 @@ +/** + * @fileoverview Fast Fourier Transform processor for accelerometer data. + * Provides frequency domain analysis of vibration data for input shaper calibration. + * Uses a pure TypeScript implementation suitable for Web Worker execution. + * + * @module services/calibration/shaper/FFTProcessor + */ + +import type { AccelerometerData, PowerSpectrum } from '../../../types/calibration'; + +/** + * FFT processing options. + */ +export interface FFTOptions { + /** Window function to apply ('hanning', 'hamming', 'blackman', 'none') */ + windowFunction: 'hanning' | 'hamming' | 'blackman' | 'none'; + /** Minimum frequency to include in output (Hz) */ + minFreq: number; + /** Maximum frequency to include in output (Hz) */ + maxFreq: number; + /** Whether to normalize the power spectrum */ + normalize: boolean; +} + +/** + * Default FFT options. + */ +export const DEFAULT_FFT_OPTIONS: FFTOptions = { + windowFunction: 'hanning', + minFreq: 5, + maxFreq: 200, + normalize: true, +}; + +/** + * Complex number representation. + */ +interface Complex { + re: number; + im: number; +} + +/** + * Multiply two complex numbers. + */ +function complexMultiply(a: Complex, b: Complex): Complex { + return { + re: a.re * b.re - a.im * b.im, + im: a.re * b.im + a.im * b.re, + }; +} + +/** + * Add two complex numbers. + */ +function complexAdd(a: Complex, b: Complex): Complex { + return { + re: a.re + b.re, + im: a.im + b.im, + }; +} + +/** + * Subtract two complex numbers. + */ +function complexSubtract(a: Complex, b: Complex): Complex { + return { + re: a.re - b.re, + im: a.im - b.im, + }; +} + +/** + * Compute the magnitude of a complex number. + */ +function complexMagnitude(c: Complex): number { + return Math.sqrt(c.re * c.re + c.im * c.im); +} + +/** + * Generate a Hanning window. + */ +function hanningWindow(n: number): number[] { + const window: number[] = []; + for (let i = 0; i < n; i++) { + window.push(0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1)))); + } + return window; +} + +/** + * Generate a Hamming window. + */ +function hammingWindow(n: number): number[] { + const window: number[] = []; + for (let i = 0; i < n; i++) { + window.push(0.54 - 0.46 * Math.cos((2 * Math.PI * i) / (n - 1))); + } + return window; +} + +/** + * Generate a Blackman window. + */ +function blackmanWindow(n: number): number[] { + const window: number[] = []; + const a0 = 0.42; + const a1 = 0.5; + const a2 = 0.08; + for (let i = 0; i < n; i++) { + window.push(a0 - a1 * Math.cos((2 * Math.PI * i) / (n - 1)) + a2 * Math.cos((4 * Math.PI * i) / (n - 1))); + } + return window; +} + +/** + * Apply a window function to data. + */ +function applyWindow(data: number[], windowType: FFTOptions['windowFunction']): number[] { + const n = data.length; + let window: number[]; + + switch (windowType) { + case 'hanning': + window = hanningWindow(n); + break; + case 'hamming': + window = hammingWindow(n); + break; + case 'blackman': + window = blackmanWindow(n); + break; + default: + return [...data]; + } + + return data.map((v, i) => v * window[i]); +} + +/** + * Pad array to next power of 2. + */ +function padToPowerOf2(data: number[]): number[] { + const n = data.length; + const nextPow2 = 2 ** Math.ceil(Math.log2(n)); + if (nextPow2 === n) return data; + + const padded = new Array(nextPow2).fill(0); + for (let i = 0; i < n; i++) { + padded[i] = data[i]; + } + return padded; +} + +/** + * Cooley-Tukey FFT implementation. + * Computes the discrete Fourier transform of the input array. + */ +function fft(input: Complex[]): Complex[] { + const n = input.length; + + // Base case + if (n <= 1) return input; + + // Ensure n is power of 2 + if (n & (n - 1)) { + throw new Error('FFT input length must be a power of 2'); + } + + const even: Complex[] = []; + const odd: Complex[] = []; + for (let i = 0; i < n; i++) { + if (i % 2 === 0) { + even.push(input[i]); + } else { + odd.push(input[i]); + } + } + + // Recursive FFT + const fftEven = fft(even); + const fftOdd = fft(odd); + + const result: Complex[] = new Array(n); + for (let k = 0; k < n / 2; k++) { + const angle = (-2 * Math.PI * k) / n; + const w: Complex = { re: Math.cos(angle), im: Math.sin(angle) }; + const t = complexMultiply(w, fftOdd[k]); + result[k] = complexAdd(fftEven[k], t); + result[k + n / 2] = complexSubtract(fftEven[k], t); + } + + return result; +} + +/** + * Compute power spectral density from FFT result. + */ +function computePSD(fftResult: Complex[], sampleRate: number): { frequencies: number[]; power: number[] } { + const n = fftResult.length; + const halfN = Math.floor(n / 2); + + const frequencies: number[] = []; + const power: number[] = []; + + for (let i = 0; i < halfN; i++) { + frequencies.push((i * sampleRate) / n); + const mag = complexMagnitude(fftResult[i]); + power.push((mag * mag) / n); + } + + return { frequencies, power }; +} + +/** + * FFT processor for accelerometer data. + */ +export class FFTProcessor { + private options: FFTOptions; + + constructor(options: Partial = {}) { + this.options = { ...DEFAULT_FFT_OPTIONS, ...options }; + } + + /** + * Update processing options. + */ + setOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + } + + /** + * Parse accelerometer CSV data. + * Expected format: time,accel_x,accel_y,accel_z + */ + parseCSV(csvContent: string): AccelerometerData { + const lines = csvContent.trim().split('\n'); + const time: number[] = []; + const accelX: number[] = []; + const accelY: number[] = []; + const accelZ: number[] = []; + + const startLine = lines[0].includes('time') || lines[0].includes('#') ? 1 : 0; + + for (let i = startLine; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) continue; + + const parts = line.split(',').map((s) => parseFloat(s.trim())); + if (parts.length >= 4 && !Number.isNaN(parts[0])) { + time.push(parts[0]); + accelX.push(parts[1]); + accelY.push(parts[2]); + accelZ.push(parts[3]); + } + } + + let sampleRate = 1000; // Default + if (time.length >= 2) { + const avgDelta = (time[time.length - 1] - time[0]) / (time.length - 1); + sampleRate = 1 / avgDelta; + } + + return { + time, + accelX, + accelY, + accelZ, + sampleRate, + }; + } + + /** + * Process accelerometer data for a single axis. + */ + processAxis(data: number[], sampleRate: number): PowerSpectrum { + const windowed = applyWindow(data, this.options.windowFunction); + + // Pad to power of 2 + const padded = padToPowerOf2(windowed); + + const complex: Complex[] = padded.map((v) => ({ re: v, im: 0 })); + + const fftResult = fft(complex); + + const { frequencies, power } = computePSD(fftResult, sampleRate); + + const filteredFrequencies: number[] = []; + const filteredPower: number[] = []; + + for (let i = 0; i < frequencies.length; i++) { + if (frequencies[i] >= this.options.minFreq && frequencies[i] <= this.options.maxFreq) { + filteredFrequencies.push(frequencies[i]); + filteredPower.push(power[i]); + } + } + + // Normalize if requested + let normalizedPower = filteredPower; + if (this.options.normalize && filteredPower.length > 0) { + const maxPower = Math.max(...filteredPower); + if (maxPower > 0) { + normalizedPower = filteredPower.map((p) => p / maxPower); + } + } + + let peakIndex = 0; + let peakPower = normalizedPower[0] || 0; + for (let i = 1; i < normalizedPower.length; i++) { + if (normalizedPower[i] > peakPower) { + peakPower = normalizedPower[i]; + peakIndex = i; + } + } + + return { + frequencies: filteredFrequencies, + power: normalizedPower, + peakFrequency: filteredFrequencies[peakIndex] || 0, + peakPower, + }; + } + + /** + * Process full accelerometer data and return power spectrums for each axis. + */ + processData(data: AccelerometerData): { + x: PowerSpectrum; + y: PowerSpectrum; + z: PowerSpectrum; + } { + return { + x: this.processAxis(data.accelX, data.sampleRate), + y: this.processAxis(data.accelY, data.sampleRate), + z: this.processAxis(data.accelZ, data.sampleRate), + }; + } + + /** + * Find resonance peaks in power spectrum. + * Returns frequencies above the threshold relative to mean power. + */ + findPeaks(spectrum: PowerSpectrum, threshold = 0.3): number[] { + const peaks: number[] = []; + const { frequencies, power } = spectrum; + + if (power.length < 3) return peaks; + + const meanPower = power.reduce((a, b) => a + b, 0) / power.length; + const peakThreshold = meanPower + threshold * (Math.max(...power) - meanPower); + + for (let i = 1; i < power.length - 1; i++) { + if (power[i] > power[i - 1] && power[i] > power[i + 1] && power[i] > peakThreshold) { + peaks.push(frequencies[i]); + } + } + + return peaks; + } +} diff --git a/src/services/calibration/shaper/ShaperAnalyzer.ts b/src/services/calibration/shaper/ShaperAnalyzer.ts new file mode 100644 index 0000000..60ab071 --- /dev/null +++ b/src/services/calibration/shaper/ShaperAnalyzer.ts @@ -0,0 +1,368 @@ +/** + * @fileoverview Input shaper analyzer for Klipper resonance compensation. + * Evaluates different shaper types and recommends optimal configurations. + * Based on Klipper's input shaping algorithms. + * + * @module services/calibration/shaper/ShaperAnalyzer + */ + +import type { + AxisCalibration, + PowerSpectrum, + ShaperDefinition, + ShaperResult, + ShaperType, +} from '../../../types/calibration'; +import { type FFTOptions, FFTProcessor } from './FFTProcessor'; + +/** + * Shaper definitions with their mathematical properties. + * Based on Klipper's input_shaper.py + */ +export const SHAPER_DEFINITIONS: ShaperDefinition[] = [ + { + type: 'zv' as ShaperType, + name: 'ZV (Zero Vibration)', + minFreq: 10, + initFreq: 35, + maxFreq: 100, + }, + { + type: 'mzv' as ShaperType, + name: 'MZV (Modified ZV)', + minFreq: 15, + initFreq: 45, + maxFreq: 100, + }, + { + type: 'ei' as ShaperType, + name: 'EI (Extra Insensitive)', + minFreq: 10, + initFreq: 40, + maxFreq: 100, + }, + { + type: '2hump_ei' as ShaperType, + name: '2-Hump EI', + minFreq: 15, + initFreq: 50, + maxFreq: 100, + }, + { + type: '3hump_ei' as ShaperType, + name: '3-Hump EI', + minFreq: 20, + initFreq: 55, + maxFreq: 100, + }, +]; + +/** + * Shaper pulse coefficients. + * These define the impulse response for each shaper type. + */ +const SHAPER_PULSES: Record { a: number[]; t: number[] }> = { + zv: (freq: number) => { + const df = 1 / (2 * freq); + const K = Math.exp(-0.05 / (1 - 0.05 * 0.05)); + const a1 = 1 / (1 + K); + const a2 = K / (1 + K); + return { a: [a1, a2], t: [0, df] }; + }, + mzv: (freq: number) => { + const df = 1 / (2 * freq); + const K = Math.exp(-0.045 / Math.sqrt(1 - 0.045 * 0.045)); + const K2 = K * K; + const a1 = 1 / (1 + K + K2); + const a2 = K / (1 + K + K2); + const a3 = K2 / (1 + K + K2); + return { a: [a1, a2, a3], t: [0, df * 0.5, df] }; + }, + ei: (freq: number) => { + const df = 1 / (2 * freq); + const a1 = 0.25 * (1 + 1 / Math.E); + const a2 = 0.5 * (1 - 1 / Math.E); + const a3 = a1; + return { a: [a1, a2, a3], t: [0, df, 2 * df] }; + }, + '2hump_ei': (freq: number) => { + const df = 1 / (2 * freq); + const e = 1 / Math.E; + const e2 = e * e; + const denom = 1 + 3 * e + e2; + return { + a: [(1 + e) / denom, (2 * e) / denom, e2 / denom + e / denom, e2 / denom], + t: [0, df, 2 * df, 3 * df], + }; + }, + '3hump_ei': (freq: number) => { + const df = 1 / (2 * freq); + const e = 1 / Math.E; + const e2 = e * e; + const e3 = e2 * e; + const denom = 1 + 4 * e + 4 * e2 + e3; + return { + a: [ + (1 + 2 * e + e2) / denom, + (2 * (e + e2)) / denom, + (e2 + 2 * e + 1) / denom, + (e2 + 2 * e3) / denom, + e3 / denom, + ], + t: [0, df, 2 * df, 3 * df, 4 * df], + }; + }, +}; + +/** + * Get smoothing time for a shaper at a given frequency. + */ +function getSmoothingTime(shaperType: ShaperType, frequency: number): number { + const pulses = SHAPER_PULSES[shaperType](frequency); + if (!pulses || pulses.t.length === 0) return 0; + return pulses.t[pulses.t.length - 1] * 1000; // Convert to ms +} + +/** + * Estimate maximum recommended acceleration for a shaper. + * Based on Klipper's formula: max_accel = shaper_freq^2 * max_tolerable_vibration_ratio / smoothing + */ +function estimateMaxAcceleration(shaperType: ShaperType, frequency: number, vibrationReduction: number): number { + const smoothingMs = getSmoothingTime(shaperType, frequency); + if (smoothingMs <= 0) return 10000; + + // Base formula adjusted for typical use + const smoothingSeconds = smoothingMs / 1000; + const maxAccel = (frequency * frequency * vibrationReduction) / (100 * smoothingSeconds); + + // Clamp to reasonable range + return Math.min(20000, Math.max(500, Math.round(maxAccel / 100) * 100)); +} + +/** + * Calculate vibration reduction for a shaper at a given frequency. + * Estimates how well the shaper suppresses vibrations at the resonance frequency. + */ +function calculateVibrationReduction(shaperType: ShaperType, shaperFreq: number, resonanceFreq: number): number { + const pulses = SHAPER_PULSES[shaperType](shaperFreq); + if (!pulses) return 0; + + const { a, t } = pulses; + const omega = 2 * Math.PI * resonanceFreq; + + let realSum = 0; + let imagSum = 0; + + for (let i = 0; i < a.length; i++) { + realSum += a[i] * Math.cos(omega * t[i]); + imagSum += a[i] * Math.sin(omega * t[i]); + } + + const magnitude = Math.sqrt(realSum * realSum + imagSum * imagSum); + + // Vibration reduction is 1 - magnitude (clamped to [0, 1]) + return Math.max(0, Math.min(1, 1 - magnitude)); +} + +/** + * Score a shaper configuration. + * Higher score is better. + */ +function scoreShaperConfig(vibrationReduction: number, smoothingTime: number, maxAccel: number): number { + // Prioritize vibration reduction, penalize excessive smoothing + const reductionScore = vibrationReduction * 100; + const smoothingPenalty = Math.min(smoothingTime / 100, 0.5); // Penalize high smoothing + const accelBonus = Math.min(maxAccel / 10000, 0.3); // Bonus for high max accel + + return reductionScore * (1 - smoothingPenalty) + accelBonus * 10; +} + +/** + * Input shaper analyzer. + */ +export class ShaperAnalyzer { + private readonly fftProcessor: FFTProcessor; + + constructor(fftOptions: Partial = {}) { + this.fftProcessor = new FFTProcessor(fftOptions); + } + + /** + * Analyze accelerometer data for a single axis. + */ + analyzeAxis(csvContent: string, axis: 'x' | 'y'): AxisCalibration { + const data = this.fftProcessor.parseCSV(csvContent); + + const accelData = axis === 'x' ? data.accelX : data.accelY; + + const spectrum = this.fftProcessor.processAxis(accelData, data.sampleRate); + + const peakFrequencies = this.fftProcessor.findPeaks(spectrum); + + const primaryResonance = spectrum.peakFrequency; + + // Evaluate all shapers + const allResults = this.evaluateShapers(primaryResonance, spectrum); + + const recommendedShaper = this.findBestShaper(allResults); + + return { + axis, + frequencyBins: spectrum.frequencies, + powerSpectralDensity: spectrum.power, + peakFrequencies, + recommendedShaper, + allShaperResults: allResults, + }; + } + + /** + * Evaluate all shaper types for a given resonance frequency. + */ + evaluateShapers(resonanceFreq: number, spectrum: PowerSpectrum): ShaperResult[] { + const results: ShaperResult[] = []; + + for (const definition of SHAPER_DEFINITIONS) { + const optimalFreq = this.findOptimalFrequency( + definition.type, + resonanceFreq, + spectrum, + definition.minFreq, + definition.maxFreq + ); + + const vibrationReduction = calculateVibrationReduction(definition.type, optimalFreq, resonanceFreq); + + const smoothingTime = getSmoothingTime(definition.type, optimalFreq); + const maxAcceleration = estimateMaxAcceleration(definition.type, optimalFreq, vibrationReduction); + + const score = scoreShaperConfig(vibrationReduction, smoothingTime, maxAcceleration); + + results.push({ + type: definition.type, + frequency: optimalFreq, + vibrationReduction, + smoothingTime, + maxAcceleration, + score, + }); + } + + return results.sort((a, b) => b.score - a.score); + } + + /** + * Find the optimal shaper frequency for a given shaper type. + */ + findOptimalFrequency( + shaperType: ShaperType, + resonanceFreq: number, + spectrum: PowerSpectrum, + minFreq: number, + maxFreq: number + ): number { + // Search for frequency that gives best vibration reduction + let bestFreq = resonanceFreq; + let bestReduction = 0; + + // Search in steps around the resonance frequency + const step = 0.5; + const searchMin = Math.max(minFreq, resonanceFreq * 0.5); + const searchMax = Math.min(maxFreq, resonanceFreq * 2); + + for (let freq = searchMin; freq <= searchMax; freq += step) { + let totalReduction = 0; + let totalWeight = 0; + + for (let i = 0; i < spectrum.frequencies.length; i++) { + const power = spectrum.power[i]; + const reduction = calculateVibrationReduction(shaperType, freq, spectrum.frequencies[i]); + totalReduction += reduction * power; + totalWeight += power; + } + + const avgReduction = totalWeight > 0 ? totalReduction / totalWeight : 0; + + if (avgReduction > bestReduction) { + bestReduction = avgReduction; + bestFreq = freq; + } + } + + return Math.round(bestFreq * 10) / 10; // Round to 0.1 Hz + } + + /** + * Find the best shaper from evaluation results. + */ + findBestShaper(results: ShaperResult[]): ShaperResult { + // Results are already sorted by score + return results[0]; + } + + /** + * Generate Klipper configuration lines for a shaper result. + */ + generateKlipperConfig(axis: 'x' | 'y', result: ShaperResult): string[] { + const lines: string[] = []; + lines.push(`[input_shaper]`); + lines.push(`shaper_freq_${axis}: ${result.frequency.toFixed(1)}`); + lines.push(`shaper_type_${axis}: ${result.type}`); + return lines; + } + + /** + * Generate a full report for axis calibration. + */ + generateReport(calibration: AxisCalibration): string { + const lines: string[] = []; + + lines.push(`Input Shaper Calibration Report - ${calibration.axis.toUpperCase()} Axis`); + lines.push('='.repeat(50)); + lines.push(''); + + lines.push(`Peak Resonance Frequency: ${calibration.peakFrequencies[0]?.toFixed(1) || 'N/A'} Hz`); + lines.push(''); + + lines.push('Recommended Shaper:'); + lines.push(` Type: ${calibration.recommendedShaper.type.toUpperCase()}`); + lines.push(` Frequency: ${calibration.recommendedShaper.frequency.toFixed(1)} Hz`); + lines.push(` Vibration Reduction: ${(calibration.recommendedShaper.vibrationReduction * 100).toFixed(1)}%`); + lines.push(` Smoothing: ${calibration.recommendedShaper.smoothingTime.toFixed(2)} ms`); + lines.push(` Max Accel: ${calibration.recommendedShaper.maxAcceleration} mm/s²`); + lines.push(''); + + lines.push('All Shapers (sorted by score):'); + for (const result of calibration.allShaperResults) { + lines.push(` ${result.type.padEnd(10)} @ ${result.frequency.toFixed(1).padStart(5)} Hz`); + lines.push( + ` - Vibration: ${(result.vibrationReduction * 100).toFixed(1)}%, ` + + `Smooth: ${result.smoothingTime.toFixed(2)}ms, ` + + `Score: ${result.score.toFixed(1)}` + ); + } + lines.push(''); + + lines.push('Klipper Configuration:'); + const config = this.generateKlipperConfig(calibration.axis, calibration.recommendedShaper); + for (const line of config) { + lines.push(` ${line}`); + } + + return lines.join('\n'); + } +} + +/** + * Get shaper definition by type. + */ +export function getShaperDefinition(type: ShaperType): ShaperDefinition | undefined { + return SHAPER_DEFINITIONS.find((d) => d.type === type); +} + +/** + * Get all shaper definitions. + */ +export function getAllShaperDefinitions(): ShaperDefinition[] { + return [...SHAPER_DEFINITIONS]; +} diff --git a/src/services/calibration/shaper/index.ts b/src/services/calibration/shaper/index.ts new file mode 100644 index 0000000..e7eb2e3 --- /dev/null +++ b/src/services/calibration/shaper/index.ts @@ -0,0 +1,16 @@ +/** + * @fileoverview Exports for the input shaper calibration module. + * Provides FFT processing and shaper analysis for resonance compensation. + * + * @module services/calibration/shaper + */ + +export type { FFTOptions } from './FFTProcessor'; +export { DEFAULT_FFT_OPTIONS, FFTProcessor } from './FFTProcessor'; + +export { + getAllShaperDefinitions, + getShaperDefinition, + SHAPER_DEFINITIONS, + ShaperAnalyzer, +} from './ShaperAnalyzer'; diff --git a/src/services/calibration/ssh/SCPFileTransfer.ts b/src/services/calibration/ssh/SCPFileTransfer.ts new file mode 100644 index 0000000..0c690fb --- /dev/null +++ b/src/services/calibration/ssh/SCPFileTransfer.ts @@ -0,0 +1,824 @@ +/** + * @fileoverview SCP file transfer for printer configuration and calibration files. + * Provides download and upload functionality using the SCP protocol over SSH exec channels. + * + * @module services/calibration/ssh/SCPFileTransfer + */ + +import { getEnvironmentService } from '../../EnvironmentService'; +import fs from 'fs/promises'; +import path from 'path'; +import type { TransferProgress, TransferResult } from '../../../types/calibration'; +import type { SSHConnectionManager } from './SSHConnectionManager'; + +const SCP_ACK = Buffer.from([0]); +const SCP_CHUNK_SIZE = 16 * 1024; + +interface SCPFileHeader { + mode: string; + size: number; + filename: string; +} + +interface SCPChannel { + write(data: string | Buffer): boolean; + end(): void; + on(event: 'data', listener: (chunk: Buffer | string) => void): this; + on(event: 'error', listener: (err: Error) => void): this; + on(event: 'end', listener: () => void): this; + on(event: 'close', listener: (code?: number | null) => void): this; + once(event: 'drain', listener: () => void): this; + once(event: 'error', listener: (err: Error) => void): this; + once(event: 'close', listener: (code?: number | null) => void): this; + off(event: 'drain', listener: () => void): this; + off(event: 'error', listener: (err: Error) => void): this; + off(event: 'close', listener: () => void): this; + stderr?: { + on(event: 'data', listener: (chunk: Buffer | string) => void): void; + }; +} + +interface SCPChannelState { + buffer: Buffer; + ended: boolean; + error: Error | null; + stderr: string; + waiters: Array<() => void>; + closePromise: Promise; +} + +/** + * Default remote paths for FlashForge printers. + * Based on Stone-Time SSH method and Klipper configuration. + */ +export const DEFAULT_REMOTE_PATHS = { + /** Primary printer configuration file path used by Flashforge Calibration Assistant v2 */ + printerConfig: '/opt/config/printer.cfg', + /** Alternative printer config location */ + printerConfigAlt: '/root/printer_data/config/printer.cfg', + /** Additional known printer config location */ + printerConfigAlt2: '/usr/data/config/printer.cfg', + /** Legacy Klipper location observed on some setups */ + printerConfigLegacy: '/home/klipper/printer_data/config/printer.cfg', + /** Input shaper X-axis calibration data */ + shaperX: '/tmp/calibration_data_x_*.csv', + /** Input shaper Y-axis calibration data */ + shaperY: '/tmp/calibration_data_y_*.csv', + /** Resonance test results directory */ + resonanceDir: '/tmp/resonances/', +}; + +/** + * File transfer service using SCP over SSH. + */ +export class SCPFileTransfer { + /** SSH connection manager reference */ + private readonly connectionManager: SSHConnectionManager; + + /** Local cache directory for downloaded files */ + private readonly cacheDir: string; + + constructor(connectionManager: SSHConnectionManager) { + this.connectionManager = connectionManager; + this.cacheDir = path.join(getEnvironmentService().getDataPath(), 'calibration', 'cache'); + } + + /** + * Ensure cache directory exists. + */ + private async ensureCacheDir(): Promise { + await fs.mkdir(this.cacheDir, { recursive: true }); + } + + /** + * Build ordered candidate paths for printer.cfg. + * Custom path is always tried first when provided. + */ + private getPrinterConfigCandidates(preferredPath?: string): string[] { + const ordered = [ + preferredPath?.trim() || '', + DEFAULT_REMOTE_PATHS.printerConfig, + DEFAULT_REMOTE_PATHS.printerConfigAlt, + DEFAULT_REMOTE_PATHS.printerConfigAlt2, + DEFAULT_REMOTE_PATHS.printerConfigLegacy, + ]; + + const seen = new Set(); + const result: string[] = []; + for (const candidate of ordered) { + if (!candidate || seen.has(candidate)) { + continue; + } + seen.add(candidate); + result.push(candidate); + } + return result; + } + + /** + * Shell-quote a string for safe usage in remote commands. + */ + private shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; + } + + /** + * Open an exec channel and initialize SCP stream state. + */ + private async openScpChannel( + contextId: string, + command: string + ): Promise<{ stream: SCPChannel; state: SCPChannelState }> { + const connection = this.connectionManager.getConnection(contextId); + + if (!connection) { + throw new Error('Not connected'); + } + + if (connection.status !== 'connected') { + throw new Error(`Connection status: ${connection.status}`); + } + + this.connectionManager.touch(contextId); + + const stream = await new Promise((resolve, reject) => { + connection.client.exec(command, (err, channel) => { + if (err) { + reject(err); + return; + } + resolve(channel as SCPChannel); + }); + }); + + const state: SCPChannelState = { + buffer: Buffer.alloc(0), + ended: false, + error: null, + stderr: '', + waiters: [], + closePromise: Promise.resolve(), + }; + + const notify = (): void => { + const pending = state.waiters.splice(0, state.waiters.length); + for (const resolve of pending) { + resolve(); + } + }; + + state.closePromise = new Promise((resolve, reject) => { + stream.once('close', (code: number | null | undefined) => { + state.ended = true; + notify(); + + if (code === 0 || code == null) { + resolve(); + return; + } + + const stderrText = state.stderr.trim(); + if (stderrText.length > 0) { + reject(new Error(`Received exit code ${code} while executing SCP command: ${stderrText}`)); + } else { + reject(new Error(`Received exit code ${code} while executing SCP command`)); + } + }); + + stream.once('error', (err: Error) => { + state.error = err; + notify(); + reject(err); + }); + }); + + stream.on('data', (chunk: Buffer | string) => { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + state.buffer = state.buffer.length === 0 ? data : Buffer.concat([state.buffer, data]); + notify(); + }); + + if (stream.stderr && typeof stream.stderr.on === 'function') { + stream.stderr.on('data', (chunk: Buffer | string) => { + state.stderr += Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : chunk; + }); + } + + stream.on('error', (err: Error) => { + state.error = err; + notify(); + }); + + stream.on('end', () => { + state.ended = true; + notify(); + }); + + return { stream, state }; + } + + /** + * Wait until new channel data or state is available. + */ + private waitForStateChange(state: SCPChannelState): Promise { + return new Promise((resolve) => { + state.waiters.push(resolve); + }); + } + + /** + * Build a consistent unexpected EOF error message. + */ + private unexpectedEOF(state: SCPChannelState, operation: string): Error { + const stderrText = state.stderr.trim(); + if (stderrText.length > 0) { + return new Error(`Unexpected end of SCP stream while ${operation}: ${stderrText}`); + } + return new Error(`Unexpected end of SCP stream while ${operation}`); + } + + /** + * Read a single byte from the SCP channel state buffer. + */ + private async readByte(state: SCPChannelState): Promise { + while (state.buffer.length === 0) { + if (state.error) { + throw state.error; + } + + if (state.ended) { + throw this.unexpectedEOF(state, 'reading control byte'); + } + + await this.waitForStateChange(state); + } + + const byte = state.buffer[0]; + state.buffer = state.buffer.subarray(1); + return byte; + } + + /** + * Read a newline-terminated control line from the SCP stream. + */ + private async readLine(state: SCPChannelState): Promise { + while (true) { + if (state.error) { + throw state.error; + } + + const newlineIndex = state.buffer.indexOf(0x0a); + if (newlineIndex >= 0) { + const lineBuffer = state.buffer.subarray(0, newlineIndex); + state.buffer = state.buffer.subarray(newlineIndex + 1); + return lineBuffer.toString('utf-8').replace(/\r$/, ''); + } + + if (state.ended) { + throw this.unexpectedEOF(state, 'reading control line'); + } + + await this.waitForStateChange(state); + } + } + + /** + * Read a fixed number of bytes from the SCP stream. + */ + private async readExact(state: SCPChannelState, length: number, onChunk?: (chunk: Buffer) => void): Promise { + const chunks: Buffer[] = []; + let remaining = length; + + while (remaining > 0) { + if (state.error) { + throw state.error; + } + + if (state.buffer.length === 0) { + if (state.ended) { + throw this.unexpectedEOF(state, `reading ${length} bytes of file data`); + } + + await this.waitForStateChange(state); + continue; + } + + const take = Math.min(remaining, state.buffer.length); + const chunk = Buffer.from(state.buffer.subarray(0, take)); + state.buffer = state.buffer.subarray(take); + remaining -= take; + + if (onChunk) { + onChunk(chunk); + } else { + chunks.push(chunk); + } + } + + if (onChunk) { + return Buffer.alloc(0); + } + + return Buffer.concat(chunks, length); + } + + /** + * Read and validate an SCP ACK/error response. + */ + private async readAck(state: SCPChannelState): Promise { + const response = await this.readByte(state); + + if (response === 0) { + return; + } + + if (response === 1 || response === 2) { + const message = (await this.readLine(state)).trim(); + throw new Error(message || (response === 1 ? 'SCP error' : 'SCP fatal error')); + } + + throw new Error(`Unexpected SCP response byte: ${response}`); + } + + /** + * Parse an SCP file header line (without leading "C"). + */ + private parseFileHeader(line: string): SCPFileHeader { + const match = line.match(/^([0-7]{4})\s+(\d+)\s+(.+)$/); + if (!match) { + throw new Error(`Invalid SCP file header: ${line}`); + } + + const size = Number.parseInt(match[2], 10); + if (!Number.isFinite(size) || size < 0) { + throw new Error(`Invalid SCP file size in header: ${line}`); + } + + return { + mode: match[1], + size, + filename: match[3], + }; + } + + /** + * Read the next file header from an SCP download stream. + * Handles optional timestamp records and end markers. + */ + private async readNextFileHeader(state: SCPChannelState, stream: SCPChannel): Promise { + while (true) { + const control = await this.readByte(state); + + // Timestamp record (T 0 0) + if (control === 0x54) { + await this.readLine(state); + stream.write(SCP_ACK); + continue; + } + + // File record (C ) + if (control === 0x43) { + const headerLine = await this.readLine(state); + return this.parseFileHeader(headerLine); + } + + // End-of-directory marker + if (control === 0x45) { + await this.readLine(state); + return null; + } + + // Remote reported error/fatal + if (control === 0x01 || control === 0x02) { + const message = (await this.readLine(state)).trim(); + throw new Error(message || (control === 0x01 ? 'SCP error' : 'SCP fatal error')); + } + + throw new Error(`Unexpected SCP control byte: ${control}`); + } + } + + /** + * Wait for channel drain, surfacing channel close/error as failures. + */ + private async waitForDrain(stream: SCPChannel, state: SCPChannelState): Promise { + if (state.error) { + throw state.error; + } + + await new Promise((resolve, reject) => { + const cleanup = (): void => { + stream.off('drain', onDrain); + stream.off('error', onError); + stream.off('close', onClose); + }; + + const onDrain = (): void => { + cleanup(); + resolve(); + }; + + const onError = (err: Error): void => { + cleanup(); + reject(err); + }; + + const onClose = (): void => { + cleanup(); + reject(this.unexpectedEOF(state, 'writing file data')); + }; + + stream.once('drain', onDrain); + stream.once('error', onError); + stream.once('close', onClose); + }); + } + + /** + * Download a file from the remote printer. + * + * @param contextId - Context ID + * @param remotePath - Path on the remote printer + * @param localPath - Local destination path (optional, uses cache if not provided) + * @param onProgress - Progress callback + * @returns Transfer result + */ + async downloadFile( + contextId: string, + remotePath: string, + localPath?: string, + onProgress?: (progress: TransferProgress) => void + ): Promise { + await this.ensureCacheDir(); + + const requestedFilename = path.posix.basename(remotePath) || path.basename(remotePath); + const destination = localPath || path.join(this.cacheDir, contextId, requestedFilename); + + await fs.mkdir(path.dirname(destination), { recursive: true }); + + let bytesTransferred = 0; + let stream: SCPChannel | null = null; + let state: SCPChannelState | null = null; + + try { + ({ stream, state } = await this.openScpChannel(contextId, `scp -f ${this.shellQuote(remotePath)}`)); + + stream.write(SCP_ACK); + + const header = await this.readNextFileHeader(state, stream); + if (!header) { + throw new Error(`No file returned for remote path: ${remotePath}`); + } + + // Ack file header before content transfer + stream.write(SCP_ACK); + + const totalBytes = header.size; + const filename = header.filename || requestedFilename; + const chunks: Buffer[] = []; + + await this.readExact(state, totalBytes, (chunk) => { + chunks.push(chunk); + bytesTransferred += chunk.length; + + if (onProgress && totalBytes > 0) { + onProgress({ + filename, + bytesTransferred, + totalBytes, + percentage: Math.round((bytesTransferred / totalBytes) * 100), + }); + } + }); + + await fs.writeFile(destination, Buffer.concat(chunks, totalBytes)); + + // Read sender trailing ACK and finish transfer + await this.readAck(state); + stream.write(SCP_ACK); + stream.end(); + await state.closePromise; + + return { + success: true, + localPath: destination, + remotePath, + bytesTransferred, + }; + } catch (err) { + if (stream) { + try { + stream.end(); + } catch { + // Ignore cleanup errors + } + } + + if (state) { + try { + await state.closePromise; + } catch { + // Ignore close errors in failure path + } + } + + return { + success: false, + localPath: destination, + remotePath, + bytesTransferred, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + } + + /** + * Upload a file to the remote printer. + * + * @param contextId - Context ID + * @param localPath - Local source path + * @param remotePath - Destination path on printer + * @param onProgress - Progress callback + * @returns Transfer result + */ + async uploadFile( + contextId: string, + localPath: string, + remotePath: string, + onProgress?: (progress: TransferProgress) => void + ): Promise { + const normalizedRemotePath = remotePath.replace(/\\/g, '/'); + const remoteIsDirectory = normalizedRemotePath.endsWith('/'); + const targetDirectory = remoteIsDirectory + ? normalizedRemotePath.replace(/\/+$/, '') || '/' + : path.posix.dirname(normalizedRemotePath); + const targetFilename = remoteIsDirectory ? path.basename(localPath) : path.posix.basename(normalizedRemotePath); + + if (!targetFilename || targetFilename === '.' || targetFilename === '..') { + return { + success: false, + localPath, + remotePath, + bytesTransferred: 0, + error: `Invalid remote file path: ${remotePath}`, + }; + } + + let stream: SCPChannel | null = null; + let state: SCPChannelState | null = null; + + try { + const content = await fs.readFile(localPath); + const totalBytes = content.length; + let bytesTransferred = 0; + const progressFilename = path.basename(localPath); + + ({ stream, state } = await this.openScpChannel(contextId, `scp -t ${this.shellQuote(targetDirectory)}`)); + + await this.readAck(state); + + // Announce file metadata to receiver + stream.write(`C0644 ${totalBytes} ${targetFilename}\n`); + await this.readAck(state); + + let offset = 0; + while (offset < totalBytes) { + const end = Math.min(offset + SCP_CHUNK_SIZE, totalBytes); + const chunk = content.subarray(offset, end); + const canContinue = stream.write(chunk); + + bytesTransferred = end; + if (onProgress) { + onProgress({ + filename: progressFilename, + bytesTransferred, + totalBytes, + percentage: Math.round((bytesTransferred / totalBytes) * 100), + }); + } + + offset = end; + + if (!canContinue) { + await this.waitForDrain(stream, state); + } + } + + // Signal end-of-file and wait for remote ACK + stream.write(SCP_ACK); + await this.readAck(state); + stream.end(); + await state.closePromise; + + return { + success: true, + localPath, + remotePath, + bytesTransferred: totalBytes, + }; + } catch (err) { + if (stream) { + try { + stream.end(); + } catch { + // Ignore cleanup errors + } + } + + if (state) { + try { + await state.closePromise; + } catch { + // Ignore close errors in failure path + } + } + + return { + success: false, + localPath, + remotePath, + bytesTransferred: 0, + error: err instanceof Error ? err.message : 'Unknown error', + }; + } + } + + /** + * Read a remote file and return its content as a string. + * + * @param contextId - Context ID + * @param remotePath - Path on the remote printer + * @returns File content as string + */ + async readRemoteFile(contextId: string, remotePath: string): Promise { + const result = await this.downloadFile(contextId, remotePath); + + if (!result.success) { + throw new Error(result.error || 'Download failed'); + } + + return fs.readFile(result.localPath, 'utf-8'); + } + + /** + * Fetch printer.cfg from the remote printer. + * Tries multiple known locations. + * + * @param contextId - Context ID + * @returns Config file content + */ + async fetchPrinterConfig(contextId: string, preferredPath?: string): Promise { + const candidates = this.getPrinterConfigCandidates(preferredPath); + let lastError: Error | null = null; + + for (const remotePath of candidates) { + try { + return await this.readRemoteFile(contextId, remotePath); + } catch (err) { + lastError = err instanceof Error ? err : new Error('Unknown error'); + } + } + + throw new Error( + `Could not find printer.cfg. Tried:\n` + + candidates.map((candidate) => ` - ${candidate}`).join('\n') + + `\nError: ${lastError?.message || 'Unknown error'}` + ); + } + + /** + * Fetch input shaper CSV file for an axis. + * + * @param contextId - Context ID + * @param axis - Which axis ('x' or 'y') + * @returns CSV file content + */ + async fetchShaperCSV(contextId: string, axis: 'x' | 'y'): Promise { + const locateResult = await this.connectionManager.executeCommand( + contextId, + `ls -1t /tmp/calibration_data_${axis}_*.csv 2>/dev/null | head -n 1` + ); + + if (locateResult.exitCode === -1) { + throw new Error(locateResult.error || locateResult.stderr.trim() || 'Failed to query calibration CSV files'); + } + + const latestPath = locateResult.stdout.trim().split(/\r?\n/)[0]; + + if (!latestPath) { + throw new Error(`No ${axis}-axis calibration data found in /tmp`); + } + + return this.readRemoteFile(contextId, latestPath); + } + + /** + * List files in a remote directory. + * + * @param contextId - Context ID + * @param remotePath - Directory path + * @returns Array of file names + */ + async listDirectory(contextId: string, remotePath: string): Promise { + const result = await this.connectionManager.executeCommand( + contextId, + `cd ${this.shellQuote(remotePath)} && ls -1A` + ); + + if (!result.success) { + throw new Error(result.stderr.trim() || result.error || `Could not list directory: ${remotePath}`); + } + + return result.stdout + .split(/\r?\n/) + .map((line) => line.replace(/\r$/, '')) + .filter((line) => line.length > 0); + } + + /** + * Check if a remote file exists. + * + * @param contextId - Context ID + * @param remotePath - File path to check + * @returns True if file exists + */ + async fileExists(contextId: string, remotePath: string): Promise { + const result = await this.connectionManager.executeCommand(contextId, `test -e ${this.shellQuote(remotePath)}`); + + return result.exitCode === 0; + } + + /** + * Upload config content to the printer. + * + * @param contextId - Context ID + * @param content - Config file content + * @param remotePath - Destination path (defaults to printer.cfg location) + * @returns Transfer result + */ + async uploadConfig(contextId: string, content: string, remotePath?: string): Promise { + await this.ensureCacheDir(); + + const configuredPath = remotePath?.trim(); + let targetRemotePath = configuredPath; + + if (targetRemotePath && !(await this.fileExists(contextId, targetRemotePath))) { + targetRemotePath = undefined; + } + + if (!targetRemotePath) { + const candidates = this.getPrinterConfigCandidates(); + for (const candidate of candidates) { + if (await this.fileExists(contextId, candidate)) { + targetRemotePath = candidate; + break; + } + } + if (!targetRemotePath) { + targetRemotePath = DEFAULT_REMOTE_PATHS.printerConfig; + } + } + + // Write content to temp file first + const tempPath = path.join(this.cacheDir, contextId, 'upload-temp.cfg'); + await fs.mkdir(path.dirname(tempPath), { recursive: true }); + await fs.writeFile(tempPath, content, 'utf-8'); + + // Upload the file + const result = await this.uploadFile(contextId, tempPath, targetRemotePath); + + try { + await fs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + + return result; + } + + /** + * Clear the local cache for a context. + * + * @param contextId - Context ID + */ + async clearCache(contextId: string): Promise { + const contextCacheDir = path.join(this.cacheDir, contextId); + try { + await fs.rm(contextCacheDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + } + + /** + * Clear entire cache. + */ + async clearAllCache(): Promise { + try { + await fs.rm(this.cacheDir, { recursive: true, force: true }); + await this.ensureCacheDir(); + } catch { + // Ignore errors + } + } +} diff --git a/src/services/calibration/ssh/SSHConnectionManager.ts b/src/services/calibration/ssh/SSHConnectionManager.ts new file mode 100644 index 0000000..b0b7fcf --- /dev/null +++ b/src/services/calibration/ssh/SSHConnectionManager.ts @@ -0,0 +1,387 @@ +/** + * @fileoverview SSH connection management for remote printer access. + * Provides connection pooling, automatic reconnection, and timeout handling. + * Uses the ssh2 library for native SSH connectivity. + * + * @module services/calibration/ssh/SSHConnectionManager + */ + +import { EventEmitter } from 'events'; +import { Client, type ConnectConfig } from 'ssh2'; +import { type SSHConnectionConfig, SSHConnectionStatus } from '../../../types/calibration'; + +/** + * Active SSH connection state. + */ +export interface SSHConnection { + /** Unique connection ID (typically printer context ID) */ + id: string; + /** Connection configuration */ + config: SSHConnectionConfig; + /** SSH2 client instance */ + client: Client; + /** Current connection status */ + status: SSHConnectionStatus; + /** Timestamp of last activity */ + lastActivity: number; + /** Error message if status is 'error' */ + error?: string; +} + +/** + * Result of executing a command over SSH. + */ +export interface CommandResult { + /** Whether the command succeeded */ + success: boolean; + /** Exit code from the command */ + exitCode: number; + /** Standard output */ + stdout: string; + /** Standard error */ + stderr: string; + /** Error message if execution failed */ + error?: string; +} + +/** + * Events emitted by SSHConnectionManager. + */ +export interface SSHConnectionManagerEvents { + 'connection-status': (id: string, status: SSHConnectionStatus, error?: string) => void; + 'connection-error': (id: string, error: Error) => void; + 'connection-closed': (id: string) => void; +} + +/** + * Default SSH configuration values. + */ +const DEFAULT_CONFIG: Partial = { + port: 22, + timeout: 10000, + keepaliveInterval: 10000, + username: 'root', +}; + +/** + * Manager for SSH connections to printers. + * Maintains a pool of connections keyed by printer context ID. + */ +export class SSHConnectionManager extends EventEmitter { + /** Active connections */ + private readonly connections: Map = new Map(); + + /** Cleanup interval handle */ + private cleanupInterval: NodeJS.Timeout | null = null; + + /** Stale connection timeout (5 minutes) */ + private readonly staleTimeout = 5 * 60 * 1000; + + constructor() { + super(); + this.startCleanupInterval(); + } + + /** + * Start the periodic cleanup of stale connections. + */ + private startCleanupInterval(): void { + this.cleanupInterval = setInterval(() => { + this.cleanupStaleConnections(); + }, 60000); // Check every minute + } + + /** + * Stop the cleanup interval. + */ + private stopCleanupInterval(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + /** + * Connect to a printer via SSH. + * + * @param contextId - Context ID + * @param config - SSH connection configuration + * @returns Promise that resolves when connected + */ + async connect(contextId: string, config: SSHConnectionConfig): Promise { + if (this.connections.has(contextId)) { + await this.disconnect(contextId); + } + + const fullConfig: SSHConnectionConfig = { + ...DEFAULT_CONFIG, + ...config, + } as SSHConnectionConfig; + + const client = new Client(); + + const connection: SSHConnection = { + id: contextId, + config: fullConfig, + client, + status: SSHConnectionStatus.CONNECTING, + lastActivity: Date.now(), + }; + + this.connections.set(contextId, connection); + this.emit('connection-status', contextId, SSHConnectionStatus.CONNECTING); + + return new Promise((resolve, reject) => { + const connectConfig: ConnectConfig = { + host: fullConfig.host, + port: fullConfig.port, + username: fullConfig.username, + readyTimeout: fullConfig.timeout, + keepaliveInterval: fullConfig.keepaliveInterval, + }; + + if (fullConfig.privateKey) { + connectConfig.privateKey = fullConfig.privateKey; + } else if (fullConfig.password) { + connectConfig.password = fullConfig.password; + } + + // Connection event handlers + client.on('ready', () => { + connection.status = SSHConnectionStatus.CONNECTED; + connection.lastActivity = Date.now(); + connection.error = undefined; + this.emit('connection-status', contextId, SSHConnectionStatus.CONNECTED); + resolve(); + }); + + client.on('error', (err: Error) => { + connection.status = SSHConnectionStatus.ERROR; + connection.error = err.message; + this.emit('connection-status', contextId, SSHConnectionStatus.ERROR, err.message); + this.emit('connection-error', contextId, err); + reject(err); + }); + + client.on('close', () => { + if (connection.status !== SSHConnectionStatus.DISCONNECTED) { + connection.status = SSHConnectionStatus.DISCONNECTED; + this.emit('connection-status', contextId, SSHConnectionStatus.DISCONNECTED); + this.emit('connection-closed', contextId); + } + }); + + client.on('end', () => { + connection.status = SSHConnectionStatus.DISCONNECTED; + }); + + // Initiate connection + client.connect(connectConfig); + }); + } + + /** + * Disconnect from a printer. + * + * @param contextId - Context ID + */ + async disconnect(contextId: string): Promise { + const connection = this.connections.get(contextId); + if (!connection) { + return; + } + + return new Promise((resolve) => { + connection.status = SSHConnectionStatus.DISCONNECTED; + connection.client.end(); + + // Give it a moment to close gracefully + setTimeout(() => { + this.connections.delete(contextId); + this.emit('connection-status', contextId, SSHConnectionStatus.DISCONNECTED); + resolve(); + }, 100); + }); + } + + /** + * Check if a connection is active. + * + * @param contextId - Context ID + * @returns True if connected + */ + isConnected(contextId: string): boolean { + const connection = this.connections.get(contextId); + return connection?.status === SSHConnectionStatus.CONNECTED; + } + + /** + * Get connection status. + * + * @param contextId - Context ID + * @returns Connection status or 'disconnected' if not found + */ + getStatus(contextId: string): SSHConnectionStatus { + const connection = this.connections.get(contextId); + return connection?.status || SSHConnectionStatus.DISCONNECTED; + } + + /** + * Get connection details. + * + * @param contextId - Context ID + * @returns Connection object or undefined + */ + getConnection(contextId: string): SSHConnection | undefined { + return this.connections.get(contextId); + } + + /** + * Execute a command on the remote printer. + * + * @param contextId - Context ID + * @param command - Command to execute + * @returns Command result + */ + async executeCommand(contextId: string, command: string): Promise { + const connection = this.connections.get(contextId); + + if (!connection) { + return { + success: false, + exitCode: -1, + stdout: '', + stderr: '', + error: 'Not connected', + }; + } + + if (connection.status !== SSHConnectionStatus.CONNECTED) { + return { + success: false, + exitCode: -1, + stdout: '', + stderr: '', + error: `Connection status: ${connection.status}`, + }; + } + + connection.lastActivity = Date.now(); + + return new Promise((resolve) => { + connection.client.exec(command, (err, stream) => { + if (err) { + resolve({ + success: false, + exitCode: -1, + stdout: '', + stderr: '', + error: err.message, + }); + return; + } + + let stdout = ''; + let stderr = ''; + let exitCode = 0; + + stream.on('close', (code: number) => { + exitCode = code; + resolve({ + success: code === 0, + exitCode, + stdout, + stderr, + }); + }); + + stream.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + stream.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + }); + }); + } + + /** + * Refresh a connection by reconnecting. + * + * @param contextId - Context ID + */ + async refreshConnection(contextId: string): Promise { + const connection = this.connections.get(contextId); + if (!connection) { + throw new Error('No connection to refresh'); + } + + await this.connect(contextId, connection.config); + } + + /** + * Clean up stale connections that haven't been used recently. + */ + cleanupStaleConnections(): void { + const now = Date.now(); + + for (const [contextId, connection] of this.connections) { + if (now - connection.lastActivity > this.staleTimeout) { + this.disconnect(contextId).catch(() => { + // Ignore errors during cleanup + }); + } + } + } + + /** + * Disconnect all connections and cleanup. + */ + async shutdown(): Promise { + this.stopCleanupInterval(); + + const disconnectPromises: Promise[] = []; + for (const contextId of this.connections.keys()) { + disconnectPromises.push(this.disconnect(contextId)); + } + + await Promise.all(disconnectPromises); + } + + /** + * Get all active connection IDs. + */ + getActiveConnectionIds(): string[] { + return Array.from(this.connections.keys()).filter( + (id) => this.connections.get(id)?.status === SSHConnectionStatus.CONNECTED + ); + } + + /** + * Update last activity timestamp for a connection. + * + * @param contextId - Context ID + */ + touch(contextId: string): void { + const connection = this.connections.get(contextId); + if (connection) { + connection.lastActivity = Date.now(); + } + } +} + +/** + * Singleton instance. + */ +let sshManagerInstance: SSHConnectionManager | null = null; + +/** + * Get the SSHConnectionManager singleton. + */ +export function getSSHConnectionManager(): SSHConnectionManager { + if (!sshManagerInstance) { + sshManagerInstance = new SSHConnectionManager(); + } + return sshManagerInstance; +} diff --git a/src/services/calibration/ssh/index.ts b/src/services/calibration/ssh/index.ts new file mode 100644 index 0000000..c270ace --- /dev/null +++ b/src/services/calibration/ssh/index.ts @@ -0,0 +1,13 @@ +/** + * @fileoverview Exports for the SSH module. + * Provides SSH connectivity and file transfer functionality for remote printer access. + * + * @module services/calibration/ssh + */ + +export { DEFAULT_REMOTE_PATHS, SCPFileTransfer } from './SCPFileTransfer'; +export type { CommandResult, SSHConnection, SSHConnectionManagerEvents } from './SSHConnectionManager'; +export { + getSSHConnectionManager, + SSHConnectionManager, +} from './SSHConnectionManager'; diff --git a/src/services/discord/DiscordNotificationService.ts b/src/services/discord/DiscordNotificationService.ts index ac9a212..41166c1 100644 --- a/src/services/discord/DiscordNotificationService.ts +++ b/src/services/discord/DiscordNotificationService.ts @@ -560,24 +560,12 @@ export class DiscordNotificationService extends EventEmitter { }; } + /** + * Returns the library-sourced completion time, or null when the print is not + * advancing (the library sets completionTime to null in that case). + */ private resolveEtaDate(status: PrinterStatus): Date | null { - const progress = status.currentJob?.progress; - if (!progress) { - return null; - } - - if (progress.formattedEta && progress.formattedEta !== '--:--') { - const [hours, minutes] = progress.formattedEta.split(':').map(Number); - if (Number.isFinite(hours) && Number.isFinite(minutes)) { - return new Date(Date.now() + (hours * 60 + minutes) * 60_000); - } - } - - if (progress.timeRemaining != null) { - return new Date(Date.now() + progress.timeRemaining * 60_000); - } - - return null; + return status.currentJob?.progress?.completionTime ?? null; } private getStatusColor(state: PrinterState): number { diff --git a/src/services/discord/__tests__/DiscordNotificationService.test.ts b/src/services/discord/__tests__/DiscordNotificationService.test.ts index 84c8d83..4983179 100644 --- a/src/services/discord/__tests__/DiscordNotificationService.test.ts +++ b/src/services/discord/__tests__/DiscordNotificationService.test.ts @@ -160,6 +160,7 @@ function createStatus(fileName: string): PrinterStatus { weightUsed: 10, lengthUsed: 2, formattedEta: '01:30', + completionTime: new Date('2026-01-01T01:30:00.000Z'), }, isActive: true, }, diff --git a/src/services/job-tracking.test.ts b/src/services/job-tracking.test.ts new file mode 100644 index 0000000..b1e5fbf --- /dev/null +++ b/src/services/job-tracking.test.ts @@ -0,0 +1,222 @@ +/** + * @fileoverview Unit tests for job-tracking: tool ids from 3MF filament ids, + * joining mappings with spool choices and estimates, and arming uploaded and + * stored-file jobs (skip reasons, estimate fallbacks, profile failures). + */ + +import { describe, expect, it } from '@jest/globals'; +import type { ParseResult } from '@parallel-7/slicer-meta'; +import type { AD5XJobInfo } from '../types/printer-backend/backend-operations'; +import type { TrackedJob } from '../types/spoolman-tracking'; +import { + armUploadedJob, + buildTrackedTools, + clearTrackedJob, + commitStoredFileJob, + estimatesFromParse, + type JobTrackingDeps, + prepareStoredFileJob, + toolIdForFilament, +} from './job-tracking'; + +function makeDeps(overrides: Partial = {}) { + const jobs = new Map(); + const deps: JobTrackingDeps = { + isSpoolmanEnabled: () => true, + isStationContext: () => true, + resolveStoreKey: () => 'SERIAL', + setJob: (key, job) => jobs.set(key, job), + takeJob: (key) => { + const job = jobs.get(key) ?? null; + jobs.delete(key); + return job; + }, + buildProfile: async () => ({ sampleCount: 1, perTool: { '0': [0, 1] } }), + listJobs: async () => [], + now: () => new Date('2026-09-23T00:00:00Z'), + ...overrides, + }; + return { deps, jobs }; +} + +function parsed(filaments: Array<{ id?: string; usedG?: string; usedM?: string }>): ParseResult { + return { threeMf: { filaments } } as unknown as ParseResult; +} + +const MAPPINGS = [ + { toolId: 0, slotId: 1 }, + { toolId: 2, slotId: 4 }, +]; + +describe('toolIdForFilament', () => { + it('uses the 1-based filament id, not the list position', () => { + expect(toolIdForFilament({ id: '1' }, 0)).toBe(0); + expect(toolIdForFilament({ id: '3' }, 1)).toBe(2); + }); + + it('falls back to the list position without a usable id', () => { + expect(toolIdForFilament({ id: null }, 1)).toBe(1); + expect(toolIdForFilament({ id: 'x' }, 2)).toBe(2); + expect(toolIdForFilament({ id: '0' }, 3)).toBe(3); + }); +}); + +describe('estimatesFromParse', () => { + it('keys estimates by tool id', () => { + expect(estimatesFromParse(parsed([{ id: '1', usedG: '6.9' }, { id: '3', usedG: '4.4', usedM: '1.5' }]))).toEqual([ + { toolId: 0, usedG: 6.9, usedM: null }, + { toolId: 2, usedG: 4.4, usedM: 1.5 }, + ]); + }); +}); + +describe('buildTrackedTools', () => { + it('keeps only tools with a spool choice', () => { + const tools = buildTrackedTools( + MAPPINGS, + [ + { toolId: 0, spoolId: 7 }, + { toolId: 2, spoolId: null }, + ], + [{ toolId: 0, usedG: 5, usedM: 2 }] + ); + expect(tools).toEqual([{ toolId: 0, slotId: 1, spoolId: 7, usedG: 5, usedM: 2 }]); + }); +}); + +describe('armUploadedJob', () => { + const upload = { + fileName: 'part.3mf', + filePath: '/tmp/part.3mf', + parsed: parsed([{ id: '1', usedG: '6.94' }, { id: '3', usedG: '4.38' }]), + mappings: MAPPINGS, + spoolAssignments: [ + { toolId: 0, spoolId: 11 }, + { toolId: 2, spoolId: 22 }, + ], + }; + + it('stores a tracked job with estimates and a usage profile', async () => { + const { deps, jobs } = makeDeps(); + const result = await armUploadedJob('ctx', upload, deps); + expect(result.armed).toBe(true); + const job = jobs.get('SERIAL'); + expect(job?.tools.map((tool) => [tool.toolId, tool.spoolId, tool.usedG])).toEqual([ + [0, 11, 6.94], + [2, 22, 4.38], + ]); + expect(job?.usageProfile).not.toBeNull(); + expect(job?.source).toBe('upload-3mf'); + expect(job?.startedAt).toBeNull(); + }); + + it('builds the profile with the lowest mapped tool as the first tool', async () => { + let initialTool = -1; + const { deps } = makeDeps({ + buildProfile: async (_path, tool) => { + initialTool = tool; + return null; + }, + }); + await armUploadedJob('ctx', { ...upload, mappings: [{ toolId: 2, slotId: 1 }, { toolId: 1, slotId: 2 }] }, deps); + expect(initialTool).toBe(1); + }); + + it('still tracks the job when the gcode cannot be read', async () => { + const { deps, jobs } = makeDeps({ + buildProfile: async () => { + throw new Error('corrupt'); + }, + }); + const result = await armUploadedJob('ctx', upload, deps); + expect(result.armed).toBe(true); + expect(jobs.get('SERIAL')?.usageProfile).toBeNull(); + }); + + it('falls back to AD5X per-tool weights when the file has no estimates', async () => { + const listed: AD5XJobInfo = { + _type: 'ad5x', + fileName: 'part.3mf', + toolDatas: [ + { toolId: 0, filamentWeight: 3, materialName: 'PLA', materialColor: '#fff', slotId: 0 }, + { toolId: 2, filamentWeight: 4, materialName: 'PLA', materialColor: '#fff', slotId: 0 }, + ], + } as unknown as AD5XJobInfo; + const { deps, jobs } = makeDeps({ listJobs: async () => [listed] }); + await armUploadedJob('ctx', { ...upload, parsed: parsed([{ id: '1' }, { id: '3' }]) }, deps); + expect(jobs.get('SERIAL')?.tools.map((tool) => tool.usedG)).toEqual([3, 4]); + }); + + it('skips when Spoolman is off, the printer has no station, or no spool was chosen', async () => { + expect(await armUploadedJob('ctx', upload, makeDeps({ isSpoolmanEnabled: () => false }).deps)).toEqual({ + armed: false, + reason: 'spoolman-disabled', + }); + expect(await armUploadedJob('ctx', upload, makeDeps({ isStationContext: () => false }).deps)).toEqual({ + armed: false, + reason: 'not-a-station-context', + }); + expect( + await armUploadedJob( + 'ctx', + { ...upload, spoolAssignments: [{ toolId: 0, spoolId: null }] }, + makeDeps().deps + ) + ).toEqual({ armed: false, reason: 'no-spools-chosen' }); + }); +}); + +describe('stored-file jobs', () => { + const listed = { + _type: 'ad5x', + fileName: 'stored.3mf', + toolDatas: [ + { toolId: 0, filamentWeight: 12, materialName: 'PLA', materialColor: '#fff', slotId: 0 }, + { toolId: 1, filamentWeight: 8, materialName: 'PETG', materialColor: '#000', slotId: 0 }, + ], + } as unknown as AD5XJobInfo; + + it('prepares tools from the printer file list and commits them without a profile', async () => { + const { deps, jobs } = makeDeps({ listJobs: async () => [listed] }); + const prepared = await prepareStoredFileJob( + 'ctx', + 'stored.3mf', + [ + { toolId: 0, slotId: 2 }, + { toolId: 1, slotId: 1 }, + ], + [ + { toolId: 0, spoolId: 5 }, + { toolId: 1, spoolId: 6 }, + ], + deps + ); + expect('tools' in prepared).toBe(true); + if ('tools' in prepared) { + commitStoredFileJob('ctx', 'stored.3mf', prepared.tools, deps); + } + const job = jobs.get('SERIAL'); + expect(job?.source).toBe('stored-file'); + expect(job?.usageProfile).toBeNull(); + expect(job?.tools.map((tool) => [tool.slotId, tool.spoolId, tool.usedG])).toEqual([ + [2, 5, 12], + [1, 6, 8], + ]); + }); + + it('reports a missing file', async () => { + const { deps } = makeDeps(); + expect(await prepareStoredFileJob('ctx', 'gone.3mf', [{ toolId: 0, slotId: 1 }], [{ toolId: 0, spoolId: 5 }], deps)).toEqual({ + reason: 'metadata-unavailable', + }); + }); +}); + +describe('clearTrackedJob', () => { + it('removes the earlier tracked job of a station printer', async () => { + const { deps, jobs } = makeDeps(); + commitStoredFileJob('ctx', 'old.3mf', [{ toolId: 0, slotId: 1, spoolId: 1, usedG: 1, usedM: null }], deps); + clearTrackedJob('ctx', deps); + expect(jobs.size).toBe(0); + }); +}); diff --git a/src/services/job-tracking.ts b/src/services/job-tracking.ts new file mode 100644 index 0000000..fe96c4e --- /dev/null +++ b/src/services/job-tracking.ts @@ -0,0 +1,331 @@ +/** + * @fileoverview Creates the per-job Spoolman tracking record when the app + * starts a job on a material-station printer (Creator 5 series, AD5X with + * station). + * + * The matching dialog sends a spool choice for each tool together with the + * tool→slot mappings. This module turns those choices into a + * {@link TrackedJob} and stores it as the printer's only tracked job: + * + * - Uploaded 3MF: estimates come from the file's per-filament data, and a + * per-tool usage profile is read from the embedded gcode so a cancelled + * print is charged per tool. + * - File already on the printer (AD5X): estimates come from the printer's + * per-tool file data; a cancel is charged linearly by progress. + * + * Every app-started job on a station printer first removes the earlier + * record, so a spool choice never carries over to another job. Failures are + * logged and never block the upload or the job start. + */ + +import type { FilamentInfo, ParseResult } from '@parallel-7/slicer-meta'; +import { getPrinterBackendManager } from '../managers/PrinterBackendManager'; +import type { AD5XJobInfo, BasicJobInfo, JobListResult } from '../types/printer-backend/backend-operations'; +import type { + ToolSpoolAssignment, + ToolUsageProfile, + TrackedJob, + TrackedJobSource, + TrackedTool, +} from '../types/spoolman-tracking'; +import { resolveStationStoreKey } from './station-store-key'; +import { getSpoolmanIntegrationService } from './SpoolmanIntegrationService'; +import { buildToolUsageProfile } from './tool-usage-profile'; +import { getTrackedJobStore } from './TrackedJobStore'; + +/** Tool→slot mapping as sent with a job start. */ +export interface ToolSlotMapping { + readonly toolId: number; + readonly slotId: number; +} + +/** Per-tool estimate before a spool is attached. */ +export interface ToolEstimate { + readonly toolId: number; + readonly usedG: number | null; + readonly usedM: number | null; +} + +/** Parse a nullable numeric string ("11.28") into a finite positive number. */ +function parsePositiveNumber(value: string | number | null | undefined): number | null { + if (typeof value === 'number') { + return Number.isFinite(value) && value > 0 ? value : null; + } + if (typeof value !== 'string' || value.trim() === '') { + return null; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +/** + * Tool index (0-based, the `Tn` number in the gcode) for a 3MF filament entry. + * + * A 3MF lists only the filaments a plate uses, each with its 1-based slicer + * id. A plate that uses filaments 1 and 3 prints with T0 and T2, so the tool + * index is `id - 1`, not the position in the list. The list position is the + * fallback when the id is missing or not a number. + * + * @param filament - Filament entry from the parsed 3MF + * @param index - Position of the entry in the filament list + */ +export function toolIdForFilament(filament: Pick, index: number): number { + const id = Number.parseInt(String(filament.id ?? ''), 10); + return Number.isInteger(id) && id >= 1 ? id - 1 : index; +} + +/** Per-filament list of a parse result (3MF first, gcode header second). */ +function filamentsFromParse(parsed: ParseResult): FilamentInfo[] { + return parsed.threeMf?.filaments ?? parsed.file?.filaments ?? []; +} + +/** + * Per-tool estimates from the slicer's per-filament data. + * + * @returns One estimate per filament, keyed by tool index + */ +export function estimatesFromParse(parsed: ParseResult): ToolEstimate[] { + return filamentsFromParse(parsed).map((filament, index) => ({ + toolId: toolIdForFilament(filament, index), + usedG: parsePositiveNumber(filament.usedG), + usedM: parsePositiveNumber(filament.usedM), + })); +} + +/** Per-tool estimates from AD5X file-list tool data (grams only). */ +export function estimatesFromAd5xJob(job: AD5XJobInfo): ToolEstimate[] { + return (job.toolDatas ?? []).map((tool) => ({ + toolId: tool.toolId, + usedG: parsePositiveNumber(tool.filamentWeight), + usedM: null, + })); +} + +/** + * Join mappings, spool choices and estimates into tracked tools. Tools + * without a spool choice are left out; they are not tracked. + */ +export function buildTrackedTools( + mappings: readonly ToolSlotMapping[], + spoolAssignments: readonly ToolSpoolAssignment[], + estimates: readonly ToolEstimate[] +): TrackedTool[] { + const tools: TrackedTool[] = []; + for (const mapping of mappings) { + const spoolId = spoolAssignments.find((entry) => entry.toolId === mapping.toolId)?.spoolId; + if (typeof spoolId !== 'number' || !Number.isInteger(spoolId) || spoolId <= 0) { + continue; + } + const estimate = estimates.find((entry) => entry.toolId === mapping.toolId); + tools.push({ + toolId: mapping.toolId, + slotId: mapping.slotId, + spoolId, + usedG: estimate?.usedG ?? null, + usedM: estimate?.usedM ?? null, + }); + } + return tools; +} + +/** Injectable surface so the arming logic is unit-testable without singletons. */ +export interface JobTrackingDeps { + readonly isSpoolmanEnabled: () => boolean; + readonly isStationContext: (contextId: string) => boolean; + readonly resolveStoreKey: (contextId: string) => string; + readonly setJob: (storeKey: string, job: TrackedJob) => void; + readonly takeJob: (storeKey: string) => TrackedJob | null; + readonly buildProfile: (filePath: string, initialTool: number) => Promise; + readonly listJobs: (contextId: string) => Promise>; + readonly now: () => Date; +} + +async function listStationJobs(contextId: string): Promise> { + const manager = getPrinterBackendManager(); + const results: JobListResult[] = []; + for (const load of [() => manager.getRecentJobs(contextId), () => manager.getLocalJobs(contextId)]) { + try { + results.push(await load()); + } catch (error) { + console.warn( + '[job-tracking] Printer file list lookup failed:', + error instanceof Error ? error.message : error + ); + } + } + return results.filter((result) => result.success).flatMap((result) => result.jobs); +} + +const defaultDeps: JobTrackingDeps = { + isSpoolmanEnabled: () => getSpoolmanIntegrationService().isGloballyEnabled(), + isStationContext: (contextId) => + getPrinterBackendManager().isFeatureAvailable(contextId, 'material-station'), + resolveStoreKey: (contextId) => resolveStationStoreKey(contextId), + setJob: (storeKey, job) => getTrackedJobStore().setJob(storeKey, job), + takeJob: (storeKey) => getTrackedJobStore().takeJob(storeKey), + buildProfile: (filePath, initialTool) => buildToolUsageProfile(filePath, initialTool), + listJobs: listStationJobs, + now: () => new Date(), +}; + +/** Result of an arming attempt, for logs and tests. */ +export type ArmResult = + | { readonly armed: true; readonly job: TrackedJob } + | { readonly armed: false; readonly reason: string }; + +/** + * Forget the tracked job of a printer because the app starts another job. + * Call this for every app-started job on a station printer. + */ +export function clearTrackedJob(contextId: string, deps: JobTrackingDeps = defaultDeps): void { + if (!deps.isStationContext(contextId)) { + return; + } + const previous = deps.takeJob(deps.resolveStoreKey(contextId)); + if (previous) { + console.log( + `[job-tracking] Dropped the tracked job ${previous.fileName}: the app started another job.` + ); + } +} + +function store( + contextId: string, + fileName: string, + source: TrackedJobSource, + tools: TrackedTool[], + usageProfile: ToolUsageProfile | null, + deps: JobTrackingDeps +): ArmResult { + const job: TrackedJob = { + fileName, + source, + tools, + usageProfile, + armedAt: deps.now().toISOString(), + startedAt: null, + lastProgress: null, + lastProgressAt: null, + }; + deps.setJob(deps.resolveStoreKey(contextId), job); + console.log( + `[job-tracking] Tracking ${fileName} on context ${contextId}: ` + + tools + .map((tool) => `T${tool.toolId}→slot ${tool.slotId}→spool ${tool.spoolId} (${tool.usedG ?? '?'} g)`) + .join(', ') + + (usageProfile ? ' with a per-tool usage profile.' : ' without a usage profile.') + ); + return { armed: true, job }; +} + +function precheck( + contextId: string, + spoolAssignments: readonly ToolSpoolAssignment[] | undefined, + deps: JobTrackingDeps +): string | null { + if (!deps.isSpoolmanEnabled()) { + return 'spoolman-disabled'; + } + if (!deps.isStationContext(contextId)) { + return 'not-a-station-context'; + } + if (!spoolAssignments?.some((entry) => typeof entry.spoolId === 'number')) { + return 'no-spools-chosen'; + } + return null; +} + +/** + * Track a 3MF that the app uploaded and started. + * + * @param contextId - Printer context + * @param upload - File name on the printer, local path of the staged 3MF, + * its parse result, and the mappings and spool choices from the dialog + */ +export async function armUploadedJob( + contextId: string, + upload: { + readonly fileName: string; + readonly filePath: string; + readonly parsed: ParseResult; + readonly mappings: readonly ToolSlotMapping[]; + readonly spoolAssignments: readonly ToolSpoolAssignment[] | undefined; + }, + deps: JobTrackingDeps = defaultDeps +): Promise { + const skip = precheck(contextId, upload.spoolAssignments, deps); + if (skip) { + return { armed: false, reason: skip }; + } + + let estimates = estimatesFromParse(upload.parsed); + if (estimates.every((estimate) => estimate.usedG === null && estimate.usedM === null)) { + // Fallback for files without per-filament data: the AD5X file list has + // per-tool weights for the file it just received. + const job = (await deps.listJobs(contextId)).find( + (candidate): candidate is AD5XJobInfo => + candidate._type === 'ad5x' && candidate.fileName === upload.fileName + ); + if (job) { + estimates = estimatesFromAd5xJob(job); + } + } + + const tools = buildTrackedTools(upload.mappings, upload.spoolAssignments ?? [], estimates); + if (tools.length === 0) { + return { armed: false, reason: 'no-spools-chosen' }; + } + + let usageProfile: ToolUsageProfile | null = null; + try { + const initialTool = Math.min(...upload.mappings.map((mapping) => mapping.toolId)); + usageProfile = await deps.buildProfile(upload.filePath, initialTool); + } catch (error) { + console.warn( + `[job-tracking] Could not read the gcode of ${upload.fileName}; a cancel is charged by progress only:`, + error instanceof Error ? error.message : error + ); + } + + return store(contextId, upload.fileName, 'upload-3mf', tools, usageProfile, deps); +} + +/** + * Track a file already on the printer that the app started (AD5X + * multi-material start through the matching dialog). Look up the per-tool + * data before the start command, while the file list is stable. + * + * @returns The tools to track, or a skip reason; pass the tools to + * {@link commitStoredFileJob} after the printer accepts the start. + */ +export async function prepareStoredFileJob( + contextId: string, + fileName: string, + mappings: readonly ToolSlotMapping[], + spoolAssignments: readonly ToolSpoolAssignment[] | undefined, + deps: JobTrackingDeps = defaultDeps +): Promise<{ readonly tools: TrackedTool[] } | { readonly reason: string }> { + const skip = precheck(contextId, spoolAssignments, deps); + if (skip) { + return { reason: skip }; + } + const job = (await deps.listJobs(contextId)).find( + (candidate): candidate is AD5XJobInfo => + candidate._type === 'ad5x' && candidate.fileName === fileName + ); + if (!job) { + return { reason: 'metadata-unavailable' }; + } + const tools = buildTrackedTools(mappings, spoolAssignments ?? [], estimatesFromAd5xJob(job)); + return tools.length > 0 ? { tools } : { reason: 'no-spools-chosen' }; +} + +/** Store the tracked job prepared by {@link prepareStoredFileJob}. */ +export function commitStoredFileJob( + contextId: string, + fileName: string, + tools: TrackedTool[], + deps: JobTrackingDeps = defaultDeps +): ArmResult { + return store(contextId, fileName, 'stored-file', tools, null, deps); +} diff --git a/src/services/station-store-key.test.ts b/src/services/station-store-key.test.ts new file mode 100644 index 0000000..41242d5 --- /dev/null +++ b/src/services/station-store-key.test.ts @@ -0,0 +1,112 @@ +/** + * @fileoverview Unit tests for station-store-key: serial-first store-key + * resolution, context-id fallback warning behavior, key stability across + * reconnects, and forgetting a removed context. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { getPrinterContextManager } from '../managers/PrinterContextManager'; +import { + forgetStationContext, + resolveStationStoreKey, + stationStoreKeysForContext, +} from './station-store-key'; +import type { PrinterDetails } from '../types/printer'; + +const SERIAL = 'FFSN-A17'; + +function makePrinterDetails(serial: string): PrinterDetails { + return { + Name: 'Creator 5', + IPAddress: '192.168.1.50', + SerialNumber: serial, + CheckCode: '1234', + ClientType: 'new', + printerModel: 'creator-5', + }; +} + +describe('station-store-key', () => { + beforeEach(() => { + forgetStationContext('ctx-live'); + forgetStationContext('ctx-fallback'); + forgetStationContext('ctx-gone'); + }); + + describe('resolveStationStoreKey', () => { + it('prefers the printer serial resolved from the context', () => { + expect(resolveStationStoreKey('ctx-live', () => SERIAL)).toBe(SERIAL); + }); + + it('falls back to the context id and warns only once per context id', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(resolveStationStoreKey('ctx-fallback', () => undefined)).toBe('ctx-fallback'); + expect(resolveStationStoreKey('ctx-fallback', () => undefined)).toBe('ctx-fallback'); + + const fallbackWarnings = warn.mock.calls.filter((call) => + String(call[0]).includes('[station-store-key]') + ); + expect(fallbackWarnings).toHaveLength(1); + } finally { + warn.mockRestore(); + } + }); + + it('treats a throwing lookup like an unresolved serial', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + resolveStationStoreKey('ctx-gone', () => { + throw new Error('boom'); + }) + ).toBe('ctx-gone'); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('keeps the same store key across a reconnect (new context id, same serial)', () => { + const manager = getPrinterContextManager(); + const first = manager.createContext(makePrinterDetails(SERIAL)); + expect(resolveStationStoreKey(first)).toBe(SERIAL); + + // A reconnect removes and recreates the context with a NEW id but the + // same physical printer (same serial). + manager.removeContext(first); + const second = manager.createContext(makePrinterDetails(SERIAL)); + expect(second).not.toBe(first); + expect(resolveStationStoreKey(second)).toBe(SERIAL); + }); + + it('falls back to the context id for an unknown context', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(resolveStationStoreKey('ctx-never-created')).toBe('ctx-never-created'); + } finally { + warn.mockRestore(); + } + }); + }); + + describe('stationStoreKeysForContext', () => { + it('lists the resolved serial and the context-id fallback', () => { + resolveStationStoreKey('ctx-live', () => SERIAL); + expect(stationStoreKeysForContext('ctx-live')).toEqual([SERIAL, 'ctx-live']); + }); + + it('lists only the context id when no serial was ever resolved', () => { + expect(stationStoreKeysForContext('ctx-fallback')).toEqual(['ctx-fallback']); + }); + }); + + describe('forgetStationContext', () => { + it('drops the cached serial of a removed context', () => { + resolveStationStoreKey('ctx-forget', () => 'SERIAL-F'); + expect(stationStoreKeysForContext('ctx-forget')).toEqual(['SERIAL-F', 'ctx-forget']); + forgetStationContext('ctx-forget'); + expect(stationStoreKeysForContext('ctx-forget')).toEqual(['ctx-forget']); + }); + }); +}); diff --git a/src/services/station-store-key.ts b/src/services/station-store-key.ts new file mode 100644 index 0000000..1924473 --- /dev/null +++ b/src/services/station-store-key.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview Storage-key resolution for the tracked job store. + * + * The TrackedJobStore is keyed by PRINTER SERIAL, not by context id. Context + * ids (`context--`) are regenerated on every server + * restart and whenever a printer is re-added, while the serial is stable for + * the physical printer, so a job that is printing stays tracked across an app + * restart or a printer reconnect. + * + * The serial is resolved from `context.printerDetails` at call time. When it + * is unavailable (context already gone, headless oddities), the context id is + * used as a fallback key with a one-time warning; fallback-keyed data does + * NOT survive restarts or reconnects. + * + * @module services/station-store-key + */ + +import { getPrinterContextManager } from '../managers/PrinterContextManager'; + +/** Resolves a context id to its printer serial, or undefined when unknown. */ +export type ContextSerialLookup = (contextId: string) => string | undefined; + +const defaultSerialLookup: ContextSerialLookup = (contextId) => { + const context = getPrinterContextManager().getContext(contextId); + const serial = context?.printerDetails.SerialNumber?.trim(); + return serial ? serial : undefined; +}; + +/** Context ids already warned about for falling back to context-id keys. */ +const warnedFallbackContexts = new Set(); + +/** context id → serial resolved while that context was alive (for pruning). */ +const resolvedSerials = new Map(); + +/** + * Storage key for a context's station tracking data: the printer serial when + * resolvable, else the context id (warns once per context id). + * + * @param contextId - Printer context id + * @param lookup - Serial lookup (injectable for tests; defaults to the + * PrinterContextManager-backed lookup) + * @returns Store key for estimates, slot assignments and the ledger + */ +export function resolveStationStoreKey( + contextId: string, + lookup: ContextSerialLookup = defaultSerialLookup +): string { + let serial: string | undefined; + try { + serial = lookup(contextId); + } catch (error: unknown) { + console.warn( + `[station-store-key] Serial lookup failed for context ${contextId}:`, + error + ); + } + + if (serial) { + resolvedSerials.set(contextId, serial); + return serial; + } + + if (!warnedFallbackContexts.has(contextId)) { + warnedFallbackContexts.add(contextId); + console.warn( + `[station-store-key] No printer serial for context ${contextId}; keying station ` + + 'tracking by context id (records will NOT survive restarts/reconnects).' + ); + } + return contextId; +} + +/** + * Every store key a context may have written: the serial resolved while it + * was alive (when one was seen) plus the context-id fallback. Used to prune + * the stores when the context is removed. + * + * @param contextId - Printer context id + * @returns De-duplicated candidate store keys + */ +export function stationStoreKeysForContext(contextId: string): string[] { + const keys = [resolvedSerials.get(contextId), contextId].filter( + (key): key is string => typeof key === 'string' && key.length > 0 + ); + return [...new Set(keys)]; +} + +/** Forget a removed context's cached serial resolution and warning state. */ +export function forgetStationContext(contextId: string): void { + resolvedSerials.delete(contextId); + warnedFallbackContexts.delete(contextId); +} diff --git a/src/services/tool-usage-profile.test.ts b/src/services/tool-usage-profile.test.ts new file mode 100644 index 0000000..5211bb4 --- /dev/null +++ b/src/services/tool-usage-profile.test.ts @@ -0,0 +1,121 @@ +/** + * @fileoverview Unit tests for the per-tool usage profile: gcode extrusion + * sums (relative and absolute extrusion, G92 resets, retractions, comments, + * tool changes, chunk boundaries), curve interpolation, and a full read of a + * real sliced 3MF whose expected values come from the fixture generator. + */ + +import { describe, expect, it } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + buildToolUsageProfile, + ToolExtrusionAccumulator, + usedFractionAt, +} from './tool-usage-profile'; + +const FIXTURES = path.resolve(__dirname, '../../tests/fixtures/print-files'); + +function profileOf(gcode: string, sampleCount = 4, chunkSize = 7) { + const bytes = Buffer.from(gcode, 'latin1'); + const accumulator = new ToolExtrusionAccumulator(bytes.length, 0, sampleCount); + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + accumulator.push(bytes.subarray(offset, offset + chunkSize)); + } + return accumulator.finish(); +} + +/** Fixed-width lines so byte positions are easy to reason about. */ +const LINE = (e: string) => `G1 X1 E${e}\n`.padEnd(16, ' '); + +describe('ToolExtrusionAccumulator', () => { + it('splits relative extrusion between tools by byte position', () => { + const gcode = `${LINE('1')}${LINE('1')}T1\n${LINE('1')}${LINE('1')}`; + const profile = profileOf(gcode); + expect(Object.keys(profile.perTool).sort()).toEqual(['0', '1']); + expect(profile.perTool['0'][4]).toBe(1); + expect(profile.perTool['1'][4]).toBe(1); + expect(profile.perTool['0'][2]).toBe(1); + expect(profile.perTool['1'][1]).toBe(0); + }); + + it('handles absolute extrusion with G92 resets', () => { + const gcode = ['M82', 'G92 E0', 'G1 X1 E5', 'G1 X2 E10', 'G92 E0', 'T3', 'G1 X3 E4', ''].join('\n'); + const profile = profileOf(gcode, 2); + expect(profile.perTool['0'][2]).toBe(1); + expect(profile.perTool['3'][2]).toBe(1); + }); + + it('nets out retractions and ignores comments and other commands', () => { + const gcode = [ + 'M83 ; relative', + 'G1 E-0.8 ; retract', + 'G1 E0.8 ; unretract', + 'G1 X5 E2.5 ; print', + '; G1 E100 commented out', + 'M104 S200', + 'G28', + '', + ].join('\n'); + const profile = profileOf(gcode, 2); + expect(profile.perTool['0'][2]).toBe(1); + }); + + it('leaves out tools that never extrude', () => { + const profile = profileOf(['T1', 'G1 X1 Y1', 'T0', 'G1 E3', ''].join('\n'), 2); + expect(Object.keys(profile.perTool)).toEqual(['0']); + }); +}); + +describe('usedFractionAt', () => { + const profile = { sampleCount: 4, perTool: { '0': [0, 0.5, 1, 1, 1], '2': [0, 0, 0, 0.5, 1] } }; + + it('interpolates between samples', () => { + expect(usedFractionAt(profile, 0, 40)).toBeCloseTo(0.8); + expect(usedFractionAt(profile, 2, 40)).toBe(0); + expect(usedFractionAt(profile, 2, 87.5)).toBeCloseTo(0.75); + expect(usedFractionAt(profile, 0, 100)).toBe(1); + }); + + it('falls back to the plain progress fraction without a curve', () => { + expect(usedFractionAt(null, 0, 40)).toBeCloseTo(0.4); + expect(usedFractionAt(profile, 1, 40)).toBeCloseTo(0.4); + }); + + it('clamps progress to 0-100', () => { + expect(usedFractionAt(null, 0, 140)).toBe(1); + expect(usedFractionAt(null, 0, -5)).toBe(0); + }); +}); + +describe('buildToolUsageProfile', () => { + it('reads the embedded gcode of a real 3MF', async () => { + const expected = JSON.parse( + fs.readFileSync(path.join(FIXTURES, 'two-tool-toolchange.expected.json'), 'utf8') + ) as { tools: Record }; + + const profile = await buildToolUsageProfile(path.join(FIXTURES, 'two-tool-toolchange.3mf'), 0); + expect(profile).not.toBeNull(); + expect(Object.keys(profile?.perTool ?? {}).sort()).toEqual(['0', '2']); + + for (const [toolId, tool] of Object.entries(expected.tools)) { + for (const percent of [40, 75] as const) { + const grams = tool.usedG * usedFractionAt(profile, Number(toolId), percent); + const expectedGrams = percent === 40 ? tool.gramsAt40 : tool.gramsAt75; + expect(Math.abs(grams - expectedGrams)).toBeLessThan(0.05); + } + } + }); + + it('returns null for a 3MF without gcode', async () => { + const profile = await buildToolUsageProfile(path.join(FIXTURES, 'creator5-two-tool.3mf'), 0); + // This fixture has gcode but no tool change: everything is tool 0. + expect(Object.keys(profile?.perTool ?? {})).toEqual(['0']); + }); + + it('rejects a file that is not a ZIP archive', async () => { + await expect( + buildToolUsageProfile(path.join(FIXTURES, 'adventurer5m-single-color.gcode'), 0) + ).rejects.toThrow(/ZIP/); + }); +}); diff --git a/src/services/tool-usage-profile.ts b/src/services/tool-usage-profile.ts new file mode 100644 index 0000000..3152c08 --- /dev/null +++ b/src/services/tool-usage-profile.ts @@ -0,0 +1,367 @@ +/** + * @fileoverview Per-tool filament usage profile for a sliced 3MF. + * + * The printer reports print progress as the byte position of its gcode reader + * divided by the gcode file size (Klipper `virtual_sdcard.progress`). The 3MF + * carries that same gcode as `Metadata/plate_N.gcode`, so the gcode can be read + * once at upload time to find how much of each tool's filament has been used at + * any byte position. A cancelled print can then be charged per tool from the + * last progress value: a tool that only prints the top half of the model is + * charged nothing if the print stops at 40%. + * + * The profile stores, for each tool, the fraction of that tool's total + * extrusion completed at evenly spaced byte positions. Absolute amounts come + * from the slicer's per-filament estimates, so no filament density is needed. + * + * The 3MF is read with a small ZIP reader on top of `zlib`, and the gcode is + * streamed, so a large file never needs to fit in memory at once. + */ + +import * as fs from 'node:fs'; +import * as zlib from 'node:zlib'; +import type { ToolUsageProfile } from '../types/spoolman-tracking'; + +/** Number of byte intervals in a profile. Each sample covers 0.5% of the gcode. */ +export const PROFILE_SAMPLE_COUNT = 200; + +const EOCD_SIGNATURE = 0x06054b50; +const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50; +const ZIP64_EOCD_SIGNATURE = 0x06064b50; +const CENTRAL_HEADER_SIGNATURE = 0x02014b50; +const LOCAL_HEADER_SIGNATURE = 0x04034b50; +const ZIP64_EXTRA_ID = 0x0001; +const UINT32_MAX = 0xffffffff; +const UINT16_MAX = 0xffff; + +/** A file entry found in the ZIP central directory. */ +interface ZipEntry { + readonly name: string; + readonly method: number; + readonly compressedSize: number; + readonly uncompressedSize: number; + readonly localHeaderOffset: number; +} + +function readAt(fd: number, position: number, length: number): Buffer { + const buffer = Buffer.alloc(length); + const read = fs.readSync(fd, buffer, 0, length, position); + return read === length ? buffer : buffer.subarray(0, read); +} + +/** Find the end-of-central-directory record in the last 64 KiB of the file. */ +function findEndOfCentralDirectory(fd: number, fileSize: number): { offset: number; record: Buffer } { + const tailLength = Math.min(fileSize, 22 + UINT16_MAX); + const tailStart = fileSize - tailLength; + const tail = readAt(fd, tailStart, tailLength); + for (let index = tail.length - 22; index >= 0; index--) { + if (tail.readUInt32LE(index) === EOCD_SIGNATURE) { + return { offset: tailStart + index, record: tail.subarray(index) }; + } + } + throw new Error('Not a ZIP file (no end of central directory record)'); +} + +/** Read the central directory and return every entry. */ +function readZipEntries(fd: number, fileSize: number): ZipEntry[] { + const { offset: eocdOffset, record } = findEndOfCentralDirectory(fd, fileSize); + let entryCount = record.readUInt16LE(10); + let directorySize = record.readUInt32LE(12); + let directoryOffset = record.readUInt32LE(16); + + if (entryCount === UINT16_MAX || directorySize === UINT32_MAX || directoryOffset === UINT32_MAX) { + const locator = readAt(fd, eocdOffset - 20, 20); + if (locator.length === 20 && locator.readUInt32LE(0) === ZIP64_EOCD_LOCATOR_SIGNATURE) { + const zip64Offset = Number(locator.readBigUInt64LE(8)); + const zip64 = readAt(fd, zip64Offset, 56); + if (zip64.readUInt32LE(0) !== ZIP64_EOCD_SIGNATURE) { + throw new Error('Corrupt ZIP64 end of central directory record'); + } + entryCount = Number(zip64.readBigUInt64LE(32)); + directorySize = Number(zip64.readBigUInt64LE(40)); + directoryOffset = Number(zip64.readBigUInt64LE(48)); + } + } + + const directory = readAt(fd, directoryOffset, directorySize); + const entries: ZipEntry[] = []; + let position = 0; + for (let index = 0; index < entryCount && position + 46 <= directory.length; index++) { + if (directory.readUInt32LE(position) !== CENTRAL_HEADER_SIGNATURE) { + throw new Error('Corrupt ZIP central directory'); + } + const method = directory.readUInt16LE(position + 10); + let compressedSize = directory.readUInt32LE(position + 20); + let uncompressedSize = directory.readUInt32LE(position + 24); + const nameLength = directory.readUInt16LE(position + 28); + const extraLength = directory.readUInt16LE(position + 30); + const commentLength = directory.readUInt16LE(position + 32); + let localHeaderOffset = directory.readUInt32LE(position + 42); + const name = directory.toString('utf8', position + 46, position + 46 + nameLength); + + // ZIP64 extra field: the 64-bit values appear only for fields set to 0xFFFFFFFF. + let extraPosition = position + 46 + nameLength; + const extraEnd = extraPosition + extraLength; + while (extraPosition + 4 <= extraEnd) { + const headerId = directory.readUInt16LE(extraPosition); + const dataSize = directory.readUInt16LE(extraPosition + 2); + if (headerId === ZIP64_EXTRA_ID) { + let field = extraPosition + 4; + if (uncompressedSize === UINT32_MAX) { + uncompressedSize = Number(directory.readBigUInt64LE(field)); + field += 8; + } + if (compressedSize === UINT32_MAX) { + compressedSize = Number(directory.readBigUInt64LE(field)); + field += 8; + } + if (localHeaderOffset === UINT32_MAX) { + localHeaderOffset = Number(directory.readBigUInt64LE(field)); + } + } + extraPosition += 4 + dataSize; + } + + entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset }); + position += 46 + nameLength + extraLength + commentLength; + } + return entries; +} + +/** Open a readable stream of one entry's uncompressed data. */ +function openEntryStream(filePath: string, fd: number, entry: ZipEntry): NodeJS.ReadableStream { + const header = readAt(fd, entry.localHeaderOffset, 30); + if (header.length < 30 || header.readUInt32LE(0) !== LOCAL_HEADER_SIGNATURE) { + throw new Error(`Corrupt ZIP local header for ${entry.name}`); + } + const dataStart = entry.localHeaderOffset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28); + const raw = fs.createReadStream(filePath, { + start: dataStart, + end: Math.max(dataStart, dataStart + entry.compressedSize - 1), + }); + if (entry.method === 0) { + return raw; + } + if (entry.method === 8) { + return raw.pipe(zlib.createInflateRaw()); + } + throw new Error(`Unsupported ZIP compression method ${entry.method} for ${entry.name}`); +} + +/** + * Stateful gcode reader that sums net extrusion per tool and records the + * running totals at evenly spaced byte positions. + */ +export class ToolExtrusionAccumulator { + private readonly totalBytes: number; + private readonly sampleCount: number; + private readonly totals = new Map(); + private readonly samples: Array> = []; + private currentTool: number; + private absoluteExtrusion = false; + private lastAbsoluteE = 0; + private bytesSeen = 0; + private nextSample = 1; + private pendingLine = ''; + + /** + * @param totalBytes - Size of the gcode in bytes + * @param initialTool - Tool that extrudes before the first tool change + * @param sampleCount - Number of byte intervals in the profile + */ + constructor(totalBytes: number, initialTool: number, sampleCount: number = PROFILE_SAMPLE_COUNT) { + this.totalBytes = Math.max(1, totalBytes); + this.currentTool = initialTool; + this.sampleCount = sampleCount; + this.samples.push(new Map()); + } + + /** Feed the next chunk of gcode bytes. */ + public push(chunk: Buffer): void { + let start = 0; + for (let index = 0; index < chunk.length; index++) { + if (chunk[index] !== 0x0a) { + continue; + } + const piece = chunk.toString('latin1', start, index); + this.bytesSeen += index + 1 - start; + start = index + 1; + this.processLine(this.pendingLine + piece); + this.pendingLine = ''; + this.recordSamples(); + } + if (start < chunk.length) { + this.pendingLine += chunk.toString('latin1', start); + this.bytesSeen += chunk.length - start; + } + } + + /** Finish the stream and return the profile. */ + public finish(): ToolUsageProfile { + if (this.pendingLine.length > 0) { + this.processLine(this.pendingLine); + this.pendingLine = ''; + } + this.bytesSeen = this.totalBytes; + this.recordSamples(); + + const perTool: Record = {}; + for (const [toolId, total] of this.totals) { + if (!(total > 0)) { + continue; + } + perTool[String(toolId)] = this.samples.map((sample) => { + const value = (sample.get(toolId) ?? 0) / total; + return Math.round(Math.min(1, Math.max(0, value)) * 1e6) / 1e6; + }); + } + return { sampleCount: this.sampleCount, perTool }; + } + + private recordSamples(): void { + while ( + this.nextSample <= this.sampleCount && + this.bytesSeen >= (this.totalBytes * this.nextSample) / this.sampleCount + ) { + this.samples.push(new Map(this.totals)); + this.nextSample++; + } + } + + private processLine(rawLine: string): void { + const commentIndex = rawLine.indexOf(';'); + const line = (commentIndex >= 0 ? rawLine.slice(0, commentIndex) : rawLine).trim(); + if (line.length === 0) { + return; + } + + const first = line.charCodeAt(0) | 0x20; + if (first === 0x74 /* t */) { + const match = /^[Tt](\d+)\s*$/.exec(line); + if (match) { + this.currentTool = Number.parseInt(match[1], 10); + } + return; + } + if (first !== 0x67 /* g */ && first !== 0x6d /* m */) { + return; + } + + const command = line.split(/\s+/, 1)[0].toUpperCase(); + switch (command) { + case 'M82': + this.absoluteExtrusion = true; + return; + case 'M83': + this.absoluteExtrusion = false; + return; + case 'G92': { + const e = readParameter(line, 'E'); + if (e !== null) { + this.lastAbsoluteE = e; + } + return; + } + case 'G0': + case 'G1': + case 'G2': + case 'G3': { + const e = readParameter(line, 'E'); + if (e === null) { + return; + } + const delta = this.absoluteExtrusion ? e - this.lastAbsoluteE : e; + if (this.absoluteExtrusion) { + this.lastAbsoluteE = e; + } + if (delta !== 0 && Number.isFinite(delta)) { + const next = (this.totals.get(this.currentTool) ?? 0) + delta; + this.totals.set(this.currentTool, Math.max(0, next)); + } + return; + } + default: + return; + } + } +} + +const PARAMETER_PATTERNS: Record = { + E: /(?:^|\s)E(-?\d*\.?\d+)/i, +}; + +function readParameter(line: string, letter: 'E'): number | null { + const match = PARAMETER_PATTERNS[letter].exec(line); + if (!match) { + return null; + } + const value = Number.parseFloat(match[1]); + return Number.isFinite(value) ? value : null; +} + +/** + * Build the per-tool usage profile for a sliced 3MF. + * + * @param filePath - Path to the 3MF file + * @param initialTool - Tool that extrudes before the first tool change + * (normally the lowest tool the plate uses) + * @returns The profile, or null when the file has no embedded gcode or no + * extrusion could be attributed to any tool + */ +export async function buildToolUsageProfile( + filePath: string, + initialTool: number +): Promise { + const fd = fs.openSync(filePath, 'r'); + let stream: NodeJS.ReadableStream; + let entry: ZipEntry | undefined; + try { + const { size } = fs.fstatSync(fd); + entry = readZipEntries(fd, size) + .filter((candidate) => /^Metadata\/plate_\d+\.gcode$/.test(candidate.name)) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0]; + if (!entry) { + return null; + } + stream = openEntryStream(filePath, fd, entry); + } finally { + fs.closeSync(fd); + } + + const accumulator = new ToolExtrusionAccumulator(entry.uncompressedSize, initialTool); + await new Promise((resolve, reject) => { + stream.on('data', (chunk: Buffer) => accumulator.push(chunk)); + stream.on('end', () => resolve()); + stream.on('error', reject); + }); + + const profile = accumulator.finish(); + return Object.keys(profile.perTool).length > 0 ? profile : null; +} + +/** + * Fraction (0-1) of a tool's filament used at a given print progress. + * + * @param profile - Usage profile, or null when none was built + * @param toolId - Tool to look up + * @param progressPercent - Printer-reported progress, 0-100 + * @returns The interpolated fraction; the plain progress fraction when the + * profile has no curve for the tool + */ +export function usedFractionAt( + profile: ToolUsageProfile | null | undefined, + toolId: number, + progressPercent: number +): number { + const progress = Math.min(1, Math.max(0, progressPercent / 100)); + const curve = profile?.perTool[String(toolId)]; + if (!profile || !curve || curve.length !== profile.sampleCount + 1) { + return progress; + } + const position = progress * profile.sampleCount; + const lower = Math.floor(position); + if (lower >= profile.sampleCount) { + return curve[profile.sampleCount]; + } + const weight = position - lower; + return curve[lower] + (curve[lower + 1] - curve[lower]) * weight; +} diff --git a/src/types/__tests__/polling.test.ts b/src/types/__tests__/polling.test.ts new file mode 100644 index 0000000..dcf246e --- /dev/null +++ b/src/types/__tests__/polling.test.ts @@ -0,0 +1,57 @@ +/** + * @fileoverview Tests for the printer-state predicates in types/polling.ts. + * + * `isPrintAdvancing` exists because the firmware freezes `estimatedTime` + * whenever the print is not progressing. Any `now() + remaining` conversion + * therefore walks its wall-clock result forward a minute every minute while + * paused, instead of holding still. Callers gate the conversion on this + * predicate; the remaining *duration* stays correct in every state. + * + * The WebUI browser bundle keeps its own copy in webui/static/shared/formatting.ts, + * covered by its own test. The duplication is forced: tsconfig.json excludes + * src/webui/static and the bundle pins rootDir to it, so neither side can import + * the other. Change one definition, change both. + */ + +import { describe, expect, it } from '@jest/globals'; +import { isActiveState, type PrinterState, isPrintAdvancing } from '../polling'; + +const ALL_STATES: PrinterState[] = [ + 'Ready', + 'Printing', + 'Paused', + 'Completed', + 'Error', + 'Busy', + 'Calibrating', + 'Heating', + 'Pausing', + 'Cancelled', +]; + +describe('isPrintAdvancing', () => { + it('is true only for the state that counts estimatedTime down', () => { + expect(isPrintAdvancing('Printing')).toBe(true); + }); + + // 'Heating' is not advancing: the pre-print warmup does not move the job on + // either, so it drifts the same way, just for minutes instead of hours. + it('is false for paused, heating, and every other non-advancing state', () => { + for (const state of ALL_STATES.filter((s) => s !== 'Printing')) { + expect(isPrintAdvancing(state)).toBe(false); + } + }); + + // isActiveState includes Paused/Pausing/Calibrating on purpose - it answers + // "is the printer busy enough to disable controls", not "is the ETA usable". + // Reusing it for the ETA gate is exactly the bug this predicate fixes. + it('is strictly narrower than isActiveState', () => { + for (const state of ALL_STATES) { + if (isPrintAdvancing(state)) { + expect(isActiveState(state)).toBe(true); + } + } + expect(isActiveState('Paused')).toBe(true); + expect(isPrintAdvancing('Paused')).toBe(false); + }); +}); diff --git a/src/types/calibration.ts b/src/types/calibration.ts new file mode 100644 index 0000000..71502a0 --- /dev/null +++ b/src/types/calibration.ts @@ -0,0 +1,708 @@ +/** + * @fileoverview Shared type definitions for the Calibration Assistant module. + * These types are used across main process, renderer, and WebUI for bed leveling, + * input shaper analysis, and calibration workflow operations. + * + * @module shared/types/calibration + */ + +// ============================================================================ +// Enums +// ============================================================================ + +/** + * Direction of screw rotation for bed leveling adjustments. + */ +export enum RotationDirection { + CLOCKWISE = 'CW', + COUNTERCLOCKWISE = 'CCW', +} + +/** + * Corner positions on the print bed. + */ +export enum BedCorner { + FRONT_LEFT = 'frontLeft', + FRONT_RIGHT = 'frontRight', + REAR_LEFT = 'rearLeft', + REAR_RIGHT = 'rearRight', +} + +/** + * Stages in the calibration workflow. + */ +export enum WorkflowStage { + INITIAL = 'initial', + BELT_SYNC = 'belt_sync', + SCREW_ADJUST = 'screw_adjust', + TAPE_COMPENSATE = 'tape_compensate', + THERMAL_PREDICT = 'thermal_predict', + COMPLETE = 'complete', +} + +/** + * Input shaper types supported by Klipper. + */ +export enum ShaperType { + ZV = 'zv', + MZV = 'mzv', + EI = 'ei', + TWO_HUMP_EI = '2hump_ei', + THREE_HUMP_EI = '3hump_ei', +} + +/** + * SSH connection status states. + */ +export enum SSHConnectionStatus { + DISCONNECTED = 'disconnected', + CONNECTING = 'connecting', + CONNECTED = 'connected', + ERROR = 'error', +} + +// ============================================================================ +// Bed & Mesh Types +// ============================================================================ + +/** + * Configuration for the print bed hardware. + */ +export interface BedConfig { + /** Bed width in mm */ + sizeX: number; + /** Bed depth in mm */ + sizeY: number; + /** Number of mesh probe points along X axis */ + meshPointsX: number; + /** Number of mesh probe points along Y axis */ + meshPointsY: number; +} + +/** + * Height values at the four corners of the bed. + */ +export interface BedCorners { + frontLeft: number; + frontRight: number; + rearLeft: number; + rearRight: number; +} + +/** + * Parsed mesh data from printer configuration. + */ +export interface MeshData { + /** 2D array of Z-height values [row][col] */ + matrix: number[][]; + /** Minimum X coordinate of mesh */ + minX: number; + /** Maximum X coordinate of mesh */ + maxX: number; + /** Minimum Y coordinate of mesh */ + minY: number; + /** Maximum Y coordinate of mesh */ + maxY: number; + /** Number of points along X axis */ + pointsX: number; + /** Number of points along Y axis */ + pointsY: number; + /** Name of the mesh profile */ + profileName: string; +} + +// ============================================================================ +// Screw Adjustment Types +// ============================================================================ + +/** + * Configuration for screw adjustment calculations. + */ +export interface ScrewConfig { + /** Thread pitch in mm per full rotation (default: 0.5) */ + pitch: number; + /** Minimum deviation threshold for adjustment (default: 0.01) */ + minAdjust: number; + /** Maximum allowed adjustment in mm (default: 2.0) */ + maxAdjust: number; +} + +/** + * Calculated adjustment for a single bed corner screw. + */ +export interface ScrewAdjustment { + /** Which corner this adjustment is for */ + corner: BedCorner; + /** Height deviation from reference in mm */ + deviation: number; + /** Direction to turn the screw */ + direction: RotationDirection; + /** Rotation amount in minutes (1/60 degree) */ + minutes: number; + /** Rotation amount in degrees */ + degrees: number; + /** Number of full rotations */ + turns: number; + /** Human-readable adjustment string (e.g., "15 minutes CW") */ + formattedAmount: string; + /** Whether this corner needs adjustment */ + requiresAdjustment: boolean; +} + +// ============================================================================ +// Tape Compensation Types +// ============================================================================ + +/** + * Configuration for tape compensation calculations. + */ +export interface TapeConfig { + /** Thickness of a single tape layer in mm (default: 0.05 for Kapton) */ + tapeThickness: number; + /** Minimum height difference to recommend tape */ + minHeightDiff: number; +} + +/** + * Tape layer recommendation for a single corner. + */ +export interface TapeRecommendation { + /** Which corner needs tape */ + corner: BedCorner; + /** Number of tape layers recommended */ + layers: number; + /** Total thickness of tape in mm */ + totalThickness: number; + /** Original deviation that this compensates */ + deviation: number; +} + +// ============================================================================ +// Analysis Types +// ============================================================================ + +/** + * Results from analyzing mesh deviations. + */ +export interface AnalysisResult { + /** Range between highest and lowest points */ + meshRange: number; + /** Maximum deviation from mean */ + maxDeviation: number; + /** Minimum deviation from mean */ + minDeviation: number; + /** Average deviation across mesh */ + averageDeviation: number; + /** Standard deviation of mesh values */ + standardDeviation: number; + /** Corner height values */ + cornerDeviations: BedCorners; + /** Which corner is used as reference (lowest) */ + referenceCorner: BedCorner; + /** Recommended actions based on analysis */ + recommendations: { + needsBeltSync: boolean; + needsScrewAdjust: boolean; + needsTapeCompensation: boolean; + }; +} + +// ============================================================================ +// Workflow Types +// ============================================================================ + +/** + * Result from processing a single workflow stage. + */ +export interface StageResult { + /** Which stage this result is for */ + stage: WorkflowStage; + /** Whether the stage completed successfully */ + success: boolean; + /** Stage-specific result data */ + data: unknown; + /** Metrics computed during this stage */ + metrics: Record; + /** Next stage to process, or null if complete */ + nextStage: WorkflowStage | null; +} + +/** + * Belt synchronization analysis result. + */ +export interface BeltSyncResult { + /** X-axis tilt in mm */ + xAxisTilt: number; + /** Y-axis tilt in mm */ + yAxisTilt: number; + /** Whether belt sync is needed */ + needsSync: boolean; + /** Recommended adjustment */ + recommendation: string; +} + +/** + * Thermal expansion prediction result. + */ +export interface ThermalPrediction { + /** Predicted mesh at target temperature */ + predictedMesh: number[][]; + /** Expected change in mesh range */ + rangeChange: number; + /** Temperature delta used */ + tempDelta: number; + /** Expansion coefficient applied */ + expansionCoeff: number; +} + +/** + * Complete workflow computation data. + */ +export interface WorkflowData { + /** Current stage being processed */ + currentStage: WorkflowStage; + /** Results for each completed stage */ + stages: Map; + /** Timestamp when workflow started */ + startTime: number; + /** List of completed stages */ + completedStages: WorkflowStage[]; + + /** Belt sync stage result */ + beltSyncResult?: BeltSyncResult; + /** Screw adjustment recommendations */ + screwAdjustments?: ScrewAdjustment[]; + /** Tape compensation recommendations */ + tapeRecommendations?: TapeRecommendation[]; + /** Thermal prediction result */ + thermalPrediction?: ThermalPrediction; + + /** Mesh range before calibration */ + initialRange: number; + /** Predicted mesh range after calibration */ + finalRange: number; + /** Improvement percentage */ + improvementPercent: number; +} + +// ============================================================================ +// Input Shaper Types +// ============================================================================ + +/** + * Definition of an input shaper algorithm. + */ +export interface ShaperDefinition { + /** Shaper type identifier */ + type: ShaperType; + /** Human-readable name */ + name: string; + /** Minimum supported frequency */ + minFreq: number; + /** Initial/default frequency */ + initFreq: number; + /** Maximum supported frequency */ + maxFreq: number; +} + +/** + * Result of evaluating a single shaper type. + */ +export interface ShaperResult { + /** Shaper type evaluated */ + type: ShaperType; + /** Optimal frequency in Hz */ + frequency: number; + /** Vibration reduction factor (0-1) */ + vibrationReduction: number; + /** Smoothing time in ms */ + smoothingTime: number; + /** Maximum recommended acceleration in mm/s² */ + maxAcceleration: number; + /** Overall quality score */ + score: number; +} + +/** + * Calibration results for a single axis. + */ +export interface AxisCalibration { + /** Which axis was calibrated */ + axis: 'x' | 'y'; + /** Frequency bins from FFT */ + frequencyBins: number[]; + /** Power spectral density values */ + powerSpectralDensity: number[]; + /** Detected resonance frequencies */ + peakFrequencies: number[]; + /** Best shaper recommendation */ + recommendedShaper: ShaperResult; + /** Results for all evaluated shapers */ + allShaperResults: ShaperResult[]; +} + +/** + * Accelerometer data parsed from CSV. + */ +export interface AccelerometerData { + /** Time values in seconds */ + time: number[]; + /** X-axis acceleration values */ + accelX: number[]; + /** Y-axis acceleration values */ + accelY: number[]; + /** Z-axis acceleration values */ + accelZ: number[]; + /** Sample rate in Hz */ + sampleRate: number; +} + +/** + * Power spectrum from FFT analysis. + */ +export interface PowerSpectrum { + /** Frequency bins in Hz */ + frequencies: number[]; + /** Power values at each frequency */ + power: number[]; + /** Peak frequency detected */ + peakFrequency: number; + /** Peak power value */ + peakPower: number; +} + +// ============================================================================ +// SSH Types +// ============================================================================ + +/** + * SSH connection configuration. + */ +export interface SSHConnectionConfig { + /** Hostname or IP address */ + host: string; + /** SSH port (default: 22) */ + port: number; + /** Username for authentication */ + username: string; + /** Password for authentication (optional if using key) */ + password?: string; + /** Path to private key file (optional) */ + privateKey?: string; + /** Connection timeout in ms */ + timeout: number; + /** Keepalive interval in ms */ + keepaliveInterval: number; +} + +/** + * Result of a file transfer operation. + */ +export interface TransferResult { + /** Whether transfer succeeded */ + success: boolean; + /** Local file path */ + localPath: string; + /** Remote file path */ + remotePath: string; + /** Number of bytes transferred */ + bytesTransferred: number; + /** Error message if failed */ + error?: string; +} + +/** + * Progress update during file transfer. + */ +export interface TransferProgress { + /** Name of file being transferred */ + filename: string; + /** Bytes transferred so far */ + bytesTransferred: number; + /** Total file size in bytes */ + totalBytes: number; + /** Percentage complete (0-100) */ + percentage: number; +} + +// ============================================================================ +// Settings Types +// ============================================================================ + +/** + * Hardware-specific calibration settings. + */ +export interface CalibrationHardwareSettings { + /** Tape thickness in mm (default: 0.05) */ + tapeThickness: number; + /** Belt tooth spacing in mm (default: 2.0) */ + beltToothMm: number; + /** Screw thread pitch in mm (default: 0.5) */ + screwPitch: number; + /** Minimum adjustment threshold in mm (default: 0.01) */ + minAdjustment: number; + /** Maximum adjustment limit in mm (default: 2.0) */ + maxAdjustment: number; + /** Number of grid points to average for corners (default: 2) */ + cornerAveraging: number; +} + +/** + * Threshold settings for calibration decisions. + */ +export interface CalibrationThresholds { + /** Belt sync threshold in mm (default: 0.1) */ + beltThreshold: number; + /** Screw adjustment threshold in mm (default: 0.02) */ + screwThreshold: number; + /** Tape compensation threshold in mm (default: 0.02) */ + tapeThreshold: number; +} + +/** + * Visualization preferences. + */ +export interface CalibrationVisualization { + /** Mesh interpolation factor (default: 3) */ + interpolationFactor: number; + /** Color scheme for heatmaps (default: 'viridis') */ + colorScheme: 'viridis' | 'plasma' | 'inferno' | 'coolwarm'; + /** Whether to show 3D surface (default: false, deferred) */ + show3D: boolean; +} + +/** + * Workflow stage enable/disable settings. + */ +export interface CalibrationWorkflowSettings { + /** Enable belt sync stage (default: true) */ + enableBelt: boolean; + /** Enable screw adjustment stage (default: true) */ + enableScrews: boolean; + /** Enable tape compensation stage (default: true) */ + enableTape: boolean; +} + +/** + * Thermal expansion preset for a bed type. + */ +export interface ThermalPreset { + /** Preset name (e.g., "PEI Spring Steel") */ + name: string; + /** Temperature at which mesh was measured */ + measurementTemp: number; + /** Target operating temperature */ + targetTemp: number; + /** Chamber heating factor (0-1) */ + chamberFactor: number; + /** PEI layer thickness in mm */ + peiThickness: number; + /** Steel sheet thickness in mm */ + steelThickness: number; + /** PEI thermal expansion coefficient */ + alphaPei: number; + /** Steel thermal expansion coefficient */ + alphaSteel: number; + /** Uniform expansion factor */ + betaUniform: number; +} + +/** + * Complete calibration settings object. + */ +export interface CalibrationSettings { + /** Hardware configuration */ + hardware: CalibrationHardwareSettings; + /** Decision thresholds */ + thresholds: CalibrationThresholds; + /** Visualization preferences */ + visualization: CalibrationVisualization; + /** Workflow stage settings */ + workflow: CalibrationWorkflowSettings; + /** Available thermal presets */ + thermalPresets: ThermalPreset[]; + /** Currently active thermal preset name */ + activeThermalPreset: string | null; + /** Environment/temperature settings */ + environment: { + measurementTemp: number; + targetTemp: number; + thermalExpansionCoeff: number; + }; + /** History retention settings */ + history: { + /** Maximum entries to retain (default: 5) */ + maxEntries: number; + }; +} + +// ============================================================================ +// Per-Printer Calibration Data +// ============================================================================ + +/** + * Calibration history entry. + */ +export interface CalibrationHistoryEntry { + /** Timestamp of calibration */ + timestamp: number; + /** Type of calibration performed */ + type: 'bed_level' | 'input_shaper'; + /** Brief summary of results */ + summary: string; + /** Full calibration data */ + data: WorkflowData | AxisCalibration; +} + +/** + * Per-printer calibration data stored with printer context. + */ +export interface PrinterCalibrationData { + /** + * Remote printer.cfg path override (calibration-specific). SSH credentials + * (host/port/username/password/keyPath) live in the centralized + * SSHSettingsService keyed by serial number, not here. + */ + sshConfigPath?: string; + + /** Last bed mesh calibration */ + lastBedMesh?: { + timestamp: number; + matrix: number[][]; + analysis: AnalysisResult; + }; + + /** X-axis input shaper configuration */ + inputShaperX?: ShaperResult; + /** Y-axis input shaper configuration */ + inputShaperY?: ShaperResult; + + /** Calibration history */ + calibrationHistory?: CalibrationHistoryEntry[]; +} + +// ============================================================================ +// Workspace Types +// ============================================================================ + +/** + * Active calibration workspace for a printer context. + */ +export interface BedWorkspace { + /** Printer context ID */ + contextId: string; + /** Current mesh data */ + meshData: MeshData | null; + /** Bed configuration */ + bedConfig: BedConfig; + /** Latest analysis result */ + analysis: AnalysisResult | null; + /** Current workflow data */ + workflow: WorkflowData | null; + /** Whether workspace has unsaved changes */ + isDirty: boolean; +} + +// ============================================================================ +// Default Values +// ============================================================================ + +/** + * Default calibration settings. + */ +export const DEFAULT_CALIBRATION_SETTINGS: CalibrationSettings = { + hardware: { + tapeThickness: 0.05, + beltToothMm: 2.0, + screwPitch: 0.5, + minAdjustment: 0.01, + maxAdjustment: 2.0, + cornerAveraging: 2, + }, + thresholds: { + beltThreshold: 0.1, + screwThreshold: 0.02, + tapeThreshold: 0.02, + }, + visualization: { + interpolationFactor: 3, + colorScheme: 'viridis', + show3D: false, + }, + workflow: { + enableBelt: true, + enableScrews: true, + enableTape: true, + }, + thermalPresets: [ + { + name: 'PEI Spring Steel', + measurementTemp: 25, + targetTemp: 60, + chamberFactor: 0.8, + peiThickness: 0.3, + steelThickness: 0.5, + alphaPei: 50e-6, + alphaSteel: 12e-6, + betaUniform: 0.7, + }, + { + name: 'Glass Bed', + measurementTemp: 25, + targetTemp: 60, + chamberFactor: 0.6, + peiThickness: 0, + steelThickness: 0, + alphaPei: 0, + alphaSteel: 9e-6, + betaUniform: 0.5, + }, + { + name: 'Aluminum Bed', + measurementTemp: 25, + targetTemp: 60, + chamberFactor: 0.9, + peiThickness: 0, + steelThickness: 0, + alphaPei: 0, + alphaSteel: 23e-6, + betaUniform: 0.85, + }, + ], + activeThermalPreset: null, + environment: { + measurementTemp: 25, + targetTemp: 60, + thermalExpansionCoeff: 12e-6, + }, + history: { + maxEntries: 5, + }, +}; + +/** + * Default screw configuration. + */ +export const DEFAULT_SCREW_CONFIG: ScrewConfig = { + pitch: 0.5, + minAdjust: 0.01, + maxAdjust: 2.0, +}; + +/** + * Default tape configuration. + */ +export const DEFAULT_TAPE_CONFIG: TapeConfig = { + tapeThickness: 0.05, + minHeightDiff: 0.02, +}; + +/** + * Default bed configuration for FlashForge AD5M. + */ +export const DEFAULT_BED_CONFIG: BedConfig = { + sizeX: 220, + sizeY: 220, + meshPointsX: 7, + meshPointsY: 7, +}; diff --git a/src/types/config.ts b/src/types/config.ts index aee3ee0..fd417fe 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -150,7 +150,6 @@ export function isValidConfig(config: unknown): config is AppConfig { const obj = config as Record; - // Check all required keys exist and have correct types for (const [key, defaultValue] of Object.entries(DEFAULT_CONFIG)) { if (!(key in obj)) { return false; @@ -228,11 +227,9 @@ export function sanitizeConfig(config: Partial): AppConfig { if (typeof value === expectedType) { if (expectedType === 'number' && isNumberConfigKey(key)) { - // Ensure numbers are valid and within reasonable bounds const numValue = value as number; if (Number.isFinite(numValue) && numValue >= 0) { if (key === 'WebUIPort') { - // Validate port numbers if (numValue >= 1 && numValue <= 65535) { assignConfigValue(sanitized, key, numValue); } diff --git a/src/types/file-manager.ts b/src/types/file-manager.ts new file mode 100644 index 0000000..543291a --- /dev/null +++ b/src/types/file-manager.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview Shared type definitions for the SFTP-based printer file manager. + * + * Describes the IPC payloads exchanged between the file manager dialog renderer, + * its preload bridge, and the main-process FileManagerService. Covers storage + * targets (internal flash vs plugged-in USB drives), directory listings, file + * entries, capability probing, and mutation results for delete/rename flows. + * + * Key exports: + * - FileManagerStorageKind: 'internal' | 'usb' storage discriminator + * - PrinterFileEntry / FileManagerListing: listing payloads + * - FileManagerCapabilities: per-printer support + USB presence probe result + * - FileManagerDeleteResult / FileManagerRenameResult: mutation outcomes + */ + +/** Storage target on the printer. */ +export type FileManagerStorageKind = 'internal' | 'usb'; + +/** Printable file extensions surfaced by the file manager. */ +export const PRINTABLE_FILE_EXTENSIONS = ['.3mf', '.gcode', '.gx', '.g'] as const; + +/** A single file or directory entry in a listing. */ +export interface PrinterFileEntry { + /** Base name including extension (display name). */ + readonly name: string; + /** Absolute path on the printer filesystem. */ + readonly path: string; + /** File size in bytes (0 for directories). */ + readonly size: number; + /** Modification time as unix seconds (0 when unknown). */ + readonly modifiedAt: number; + /** Whether the entry is a directory (USB navigation only). */ + readonly isDirectory: boolean; +} + +/** Result of listing a storage location. */ +export interface FileManagerListing { + readonly success: boolean; + readonly storage: FileManagerStorageKind; + /** Absolute directory that was listed. Empty string for the synthetic USB mount root. */ + readonly path: string; + /** Root directory of the current storage (navigation must stay under this). */ + readonly rootPath: string; + readonly entries: readonly PrinterFileEntry[]; + readonly error?: string; +} + +/** Per-printer file manager capabilities and USB probe result. */ +export interface FileManagerCapabilities { + /** Whether the connected printer model supports SFTP file management. */ + readonly supported: boolean; + /** Human-readable reason when unsupported. */ + readonly reason?: string; + /** Printer display name (for the dialog title). */ + readonly printerName?: string; + /** Whether at least one USB drive is currently mounted. */ + readonly usbPresent: boolean; + /** Absolute mount points of detected USB drives. */ + readonly usbMounts: readonly string[]; + /** Error message when the SSH/SFTP probe itself failed. */ + readonly error?: string; +} + +/** Outcome for a single file within a batch delete. */ +export interface FileManagerDeleteOutcome { + readonly path: string; + readonly success: boolean; + readonly error?: string; +} + +/** Result of a (batch) delete operation. */ +export interface FileManagerDeleteResult { + readonly success: boolean; + readonly outcomes: readonly FileManagerDeleteOutcome[]; + readonly error?: string; +} + +/** Result of a rename operation. */ +export interface FileManagerRenameResult { + readonly success: boolean; + /** New absolute path after a successful rename. */ + readonly newPath?: string; + readonly error?: string; +} + +/** Result of a thumbnail fetch. */ +export interface FileManagerThumbnailResult { + readonly success: boolean; + /** Base64-encoded PNG (no data-URL prefix) when available. */ + readonly thumbnail?: string; + readonly error?: string; +} diff --git a/src/types/polling.ts b/src/types/polling.ts index 0addf5f..c09f67d 100644 --- a/src/types/polling.ts +++ b/src/types/polling.ts @@ -88,6 +88,7 @@ export interface JobProgress { weightUsed: number; // grams lengthUsed: number; // meters formattedEta?: string; // formatted ETA from ff-api (e.g. "14:30") + completionTime?: Date | null; // absolute completion time from ff-api (null when not advancing) } /** @@ -265,6 +266,24 @@ export function isActiveState(state: PrinterState): boolean { ); } +/** + * Check whether the print is actually advancing, i.e. whether the firmware is + * still counting `estimatedTime` down. + * + * Outside 'Printing' the field freezes at its last value while the wall clock + * keeps moving, so any `now() + remaining` conversion walks forward one minute + * per minute instead of holding still - a paused print appears to recede + * forever. Gate wall-clock ETA displays on this; the remaining *duration* stays + * correct in every state and needs no gate. + * + * 'Heating' is deliberately excluded. The pre-print warmup does not advance the + * job either, so the same drift applies - it just lasts minutes rather than + * hours, which is why it is easy to miss. + */ +export function isPrintAdvancing(state: PrinterState): boolean { + return state === 'Printing'; +} + /** * Check if printer is available for new jobs (enables file selection) */ diff --git a/src/types/printer-power.ts b/src/types/printer-power.ts new file mode 100644 index 0000000..ac669b4 --- /dev/null +++ b/src/types/printer-power.ts @@ -0,0 +1,40 @@ +/** + * @fileoverview Shared IPC types for the remote "Reboot Printer" feature. + * + * Consumed by the main-process handler (`printer-power-handlers.ts`), the + * preload bridge, the renderer's reboot overlay controller, and the WebUI + * (REBOOT_STATUS WebSocket broadcasts + the printer-power route). + * + * Key exports: + * - RebootPhase: lifecycle phase pushed over the 'printer:reboot-status' channel. + * - RebootStatusPayload: the event payload shape. + * - RebootResult: the 'printer:reboot' invoke return value. + */ + +/** + * Lifecycle phase of an in-flight reboot, pushed to the renderer. + * + * Phases progress: rebooting -> reconnecting -> reconnecting-services -> + * success (or terminal timeout / failed). The 'reconnecting-services' phase + * fires once polling resumes and waits for a streak of stable polls so the TCP + * command socket and camera stream can fully recover before declaring success. + */ +export type RebootPhase = + | 'rebooting' + | 'reconnecting' + | 'reconnecting-services' + | 'success' + | 'timeout' + | 'failed'; + +/** Payload pushed over the 'printer:reboot-status' channel. */ +export interface RebootStatusPayload { + readonly phase: RebootPhase; + readonly message?: string; + readonly printerName?: string; +} + +/** Return value of the 'printer:reboot' invoke handler. */ +export interface RebootResult { + readonly success: boolean; +} diff --git a/src/types/spoolman-tracking.ts b/src/types/spoolman-tracking.ts new file mode 100644 index 0000000..f4c6cd2 --- /dev/null +++ b/src/types/spoolman-tracking.ts @@ -0,0 +1,112 @@ +/** + * @fileoverview Types for per-job Spoolman consumption tracking on + * material-station printers (Creator 5 series, AD5X with station). + * + * The firmware reports no per-tool usage, so consumption is estimated from the + * slicer's per-filament data. The user picks a Spoolman spool for each tool + * when they match materials for a job the app starts. That choice belongs to + * that one job only: it is deleted when the job ends, so a later print never + * uses stale spool choices. Nothing is remembered per slot or per printer, + * because the loaded spools can change on the printer at any time. + * + * Invariants enforced by the consumers of these types: + * - Only jobs the app started are tracked. No record means no deduction. + * - Each job is deducted at most once: the record is removed before any + * Spoolman request is sent. + * - Spoolman API failures never affect printing (log + UI hint only). + */ + +/** Spool choice for one tool, as sent by the matching dialog. */ +export interface ToolSpoolAssignment { + readonly toolId: number; + /** Spoolman spool id, or null when the user chose not to track the tool. */ + readonly spoolId: number | null; +} + +/** + * Per-tool usage curve against gcode byte position. + * + * `perTool[toolId][i]` is the fraction (0-1) of that tool's total extrusion + * done after the first `i / sampleCount` of the gcode bytes. Each array has + * `sampleCount + 1` values. Tools without extrusion have no entry. + */ +export interface ToolUsageProfile { + readonly sampleCount: number; + readonly perTool: Readonly>; +} + +/** One tracked tool of a job. */ +export interface TrackedTool { + readonly toolId: number; + /** Material station slot (1-based) the tool prints from. */ + readonly slotId: number; + readonly spoolId: number; + /** Estimated filament for the full print, in grams. Null = unknown. */ + readonly usedG: number | null; + /** Estimated filament for the full print, in meters. Null = unknown. */ + readonly usedM: number | null; +} + +/** + * Where a {@link TrackedJob} came from. + * + * - `upload-3mf`: a 3MF uploaded and started by the app; estimates and the + * usage profile come from the file itself + * - `stored-file`: a file already on the printer (AD5X), started by the app; + * estimates come from the printer's file list and there is no usage profile + */ +export type TrackedJobSource = 'upload-3mf' | 'stored-file'; + +/** The one job being tracked on a printer. */ +export interface TrackedJob { + /** File name as the app sent it to the printer. */ + readonly fileName: string; + readonly source: TrackedJobSource; + readonly tools: readonly TrackedTool[]; + /** Per-tool usage curve; null means a cancel is charged linearly. */ + readonly usageProfile: ToolUsageProfile | null; + /** ISO 8601 time when the app started the job. */ + readonly armedAt: string; + /** ISO 8601 time when the printer was first seen printing the job. */ + startedAt: string | null; + /** Last printer-reported progress for the job, 0-100. */ + lastProgress: number | null; + /** ISO 8601 time of {@link lastProgress}. */ + lastProgressAt: string | null; +} + +/** How a tracked job ended. */ +export type DeductionTerminal = 'completed' | 'cancelled' | 'error' | 'interrupted'; + +/** Outcome for a single tool during a deduction attempt. */ +export interface ToolDeduction { + readonly toolId: number; + readonly slotId: number; + readonly spoolId: number | null; + /** Deducted amount (grams in weight mode, mm in length mode); null when skipped. */ + readonly amount: number | null; + readonly mode: SpoolDeductionMode; + /** Fraction of the tool's estimate that was charged (0-1). */ + readonly fraction: number; + readonly status: 'deducted' | 'skipped'; + /** Human-readable reason when status is 'skipped'. */ + readonly reason?: string; +} + +/** Update mode mirrored from the Spoolman config for deduction payloads. */ +export type SpoolDeductionMode = 'weight' | 'length'; + +/** Summary of one deduction attempt (for UI + logs). */ +export interface DeductionSummary { + readonly fileName: string; + readonly terminal: DeductionTerminal; + /** Printer progress used for the charge (100 for a completed job). */ + readonly progress: number; + /** True when the end state was not seen and the last progress was used. */ + readonly approximate: boolean; + readonly tools: readonly ToolDeduction[]; + readonly deductedCount: number; + readonly skippedCount: number; + /** ISO 8601 timestamp of the deduction attempt. */ + readonly at: string; +} diff --git a/src/types/ssh-settings.ts b/src/types/ssh-settings.ts new file mode 100644 index 0000000..52fed1b --- /dev/null +++ b/src/types/ssh-settings.ts @@ -0,0 +1,46 @@ +/** + * @fileoverview Shared type definitions for centralized per-printer SSH settings. + * + * SSH credentials are stored once per printer (keyed by serial number) and + * consumed by every SSH-based feature: the file manager, the calibration + * assistant, and the WebUI calibration routes. Defaults match the FlashForge + * easy-SSH provisioning script (root / flashforge on port 22), so features + * work out of the box; advanced users can override them in Settings -> SSH. + * + * Key exports: + * - SSH_DEFAULTS: default credentials provisioned by flashforge-easyssh + * - SSHSettings / SSHSettingsUpdate: resolved settings + settings-tab payloads + */ + +/** Default SSH credentials provisioned by the flashforge-easyssh script. */ +export const SSH_DEFAULTS = { + username: 'root', + password: 'flashforge', + port: 22, +} as const; + +/** Resolved SSH settings for a printer (defaults applied). */ +export interface SSHSettings { + readonly username: string; + readonly password: string; + readonly port: number; + /** Optional path to an SSH private key file (overrides password auth). */ + readonly keyPath?: string; + /** Whether any value differs from the easy-SSH defaults. */ + readonly isCustom: boolean; +} + +/** Partial update from the settings UI. Empty strings mean "use default". */ +export interface SSHSettingsUpdate { + readonly username?: string; + readonly password?: string; + readonly port?: number; + readonly keyPath?: string; +} + +/** IPC response for 'ssh-settings:get' (null when no printer is connected). */ +export interface SSHSettingsResponse { + readonly settings: SSHSettings | null; + readonly printerName?: string; + readonly error?: string; +} diff --git a/src/utils/EventEmitter.ts b/src/utils/EventEmitter.ts index e1c0d7e..2eef6d3 100644 --- a/src/utils/EventEmitter.ts +++ b/src/utils/EventEmitter.ts @@ -134,5 +134,4 @@ export class EventEmitter = DefaultE } } -// Export a convenience type for simple string-based events export type SimpleEventEmitter = EventEmitter>; diff --git a/src/utils/HeadlessArguments.test.ts b/src/utils/HeadlessArguments.test.ts new file mode 100644 index 0000000..16a04f0 --- /dev/null +++ b/src/utils/HeadlessArguments.test.ts @@ -0,0 +1,121 @@ +/** + * @fileoverview Tests for CLI printer-spec parsing and validation. + * Covers the Creator 5 series TYPE tokens and the serial-number requirement + * for HTTP-only models, which cannot be probed for their serial (issue #17). + */ + +import { afterEach, describe, expect, it } from '@jest/globals'; +import { + type HeadlessConfig, + parseHeadlessArguments, + validateHeadlessConfig, +} from './HeadlessArguments'; + +const originalArgv = process.argv; + +function parseWith(printersArg: string): HeadlessConfig { + process.argv = ['node', 'index.js', `--printers=${printersArg}`]; + return parseHeadlessArguments(); +} + +afterEach(() => { + process.argv = originalArgv; +}); + +describe('parseHeadlessArguments - printer specs', () => { + it('parses the legacy IP:TYPE:CHECKCODE format unchanged', () => { + const config = parseWith('192.168.1.100:new:12345678,192.168.1.101:legacy'); + + expect(config.mode).toBe('explicit-printers'); + expect(config.printers).toHaveLength(2); + expect(config.printers?.[0]).toMatchObject({ + ip: '192.168.1.100', + type: 'new', + checkCode: '12345678', + }); + expect(config.printers?.[0].productId).toBeUndefined(); + expect(config.printers?.[1]).toMatchObject({ ip: '192.168.1.101', type: 'legacy' }); + }); + + it('parses the optional serial number as a fourth field', () => { + const config = parseWith('192.168.1.100:new:12345678:SNMOMC1234567'); + + expect(config.printers?.[0]).toMatchObject({ + type: 'new', + checkCode: '12345678', + serialNumber: 'SNMOMC1234567', + }); + }); + + it('maps Creator tokens to the new client type with an HTTP-only product ID', () => { + const config = parseWith( + '192.168.1.184:creator-5:12345678:SN1,192.168.1.185:creator-5-pro:87654321:SN2' + ); + + expect(config.printers?.[0]).toMatchObject({ + type: 'new', + productId: 40, + serialNumber: 'SN1', + }); + expect(config.printers?.[1]).toMatchObject({ + type: 'new', + productId: 41, + serialNumber: 'SN2', + }); + }); + + it('falls back to legacy for unknown type tokens', () => { + const config = parseWith('192.168.1.100:bogus'); + expect(config.printers?.[0].type).toBe('legacy'); + }); +}); + +describe('validateHeadlessConfig', () => { + const base: HeadlessConfig = { mode: 'explicit-printers' }; + + it('requires a serial number for Creator 5 series printers', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [{ ip: '192.168.1.184', type: 'new', checkCode: '12345678', productId: 40 }], + }); + + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/serial number/i); + }); + + it('accepts a Creator 5 spec that includes a serial number', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [ + { + ip: '192.168.1.184', + type: 'new', + checkCode: '12345678', + productId: 41, + serialNumber: 'SNCRE51234567', + }, + ], + }); + + expect(result.valid).toBe(true); + }); + + it('keeps the serial optional for dual-API printers (TCP probe supplies it)', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [{ ip: '192.168.1.100', type: 'new', checkCode: '12345678' }], + }); + + expect(result.valid).toBe(true); + }); + + it('still requires a check code for modern printers', () => { + const result = validateHeadlessConfig({ + ...base, + printers: [{ ip: '192.168.1.100', type: 'new' }], + }); + + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/check code/i); + }); +}); diff --git a/src/utils/HeadlessArguments.ts b/src/utils/HeadlessArguments.ts index 4fb645b..6b4bb8e 100644 --- a/src/utils/HeadlessArguments.ts +++ b/src/utils/HeadlessArguments.ts @@ -7,7 +7,8 @@ * Examples: * node dist/index.js --last-used * node dist/index.js --all-saved-printers - * node dist/index.js --printers="192.168.1.100:new:12345678,192.168.1.101:legacy" + * node dist/index.js --printers="192.168.1.100:new:12345678:SNMOMC1234567,192.168.1.101:legacy" + * node dist/index.js --printers="192.168.1.184:creator-5-pro:12345678:SNCRE51234567" * node dist/index.js --webui-port=3001 --webui-password=mypassword */ @@ -20,6 +21,18 @@ export interface PrinterSpec { ip: string; type: PrinterClientType; checkCode?: string; + /** + * USB product ID hint for HTTP-only models (Creator 5 series). Set when the + * TYPE token is `creator-5` / `creator-5-pro` so the connection flow skips + * the legacy TCP probe these printers cannot answer. + */ + productId?: number; + /** + * Printer serial number. Required for modern printers (5M family and Creator + * 5 series), which authenticate with serial + check code. Dual-API models can + * fall back to the serial reported by the TCP probe; HTTP-only models cannot. + */ + serialNumber?: string; } /** @@ -58,7 +71,6 @@ export function parseHeadlessArguments(): HeadlessConfig { let printers: PrinterSpec[] | undefined; if (hasNoPrinters) { - // Start server without connecting to any printers (WebUI only) mode = 'no-printers'; } else if (hasLastUsed) { mode = 'last-used'; @@ -72,7 +84,6 @@ export function parseHeadlessArguments(): HeadlessConfig { mode = 'no-printers'; } - // Parse optional overrides const webUIPort = parseNumberArgument(args, '--webui-port'); const webUIPassword = parseStringArgument(args, '--webui-password'); @@ -87,7 +98,11 @@ export function parseHeadlessArguments(): HeadlessConfig { /** * Parse --printers argument into array of PrinterSpec * - * Format: --printers="192.168.1.100:new:12345678,192.168.1.101:legacy" + * Format: --printers="IP:TYPE[:CHECKCODE[:SERIAL]],..." + * e.g. --printers="192.168.1.100:new:12345678:SNMOMC1234567,192.168.1.101:legacy" + * + * TYPE is `new`, `legacy`, `creator-5`, or `creator-5-pro`. The Creator tokens + * mark HTTP-only printers so the connection flow skips the legacy TCP probe. * * @param arg The --printers= argument string * @returns Array of PrinterSpec objects @@ -98,10 +113,8 @@ function parsePrintersArgument(arg: string): PrinterSpec[] { return []; } - // Remove quotes if present const cleanValue = value.replace(/^["']|["']$/g, ''); - // Split by comma to get individual printer specs const printerStrings = cleanValue.split(','); const specs: PrinterSpec[] = []; @@ -112,13 +125,21 @@ function parsePrintersArgument(arg: string): PrinterSpec[] { continue; } - const [ip, typeStr, checkCode] = parts; - const type: PrinterClientType = typeStr === 'new' ? 'new' : 'legacy'; + const [ip, typeStr, checkCode, serialNumber] = parts; + // The Creator 5 series is HTTP-only (no legacy TCP server), so the type + // token also carries the model for those printers. All other non-"new" + // tokens fall back to legacy, matching historical behavior. + const normalizedType = typeStr.trim().toLowerCase(); + const isCreator = normalizedType === 'creator-5' || normalizedType === 'creator-5-pro'; + const type: PrinterClientType = typeStr === 'new' || isCreator ? 'new' : 'legacy'; + const productId = isCreator ? (normalizedType === 'creator-5' ? 40 : 41) : undefined; specs.push({ ip: ip.trim(), type, checkCode: checkCode?.trim(), + productId, + serialNumber: serialNumber?.trim() || undefined, }); } @@ -158,7 +179,6 @@ function parseStringArgument(args: string[], flag: string): string | undefined { } const value = arg.split('=')[1]; - // Remove quotes if present return value?.replace(/^["']|["']$/g, ''); } @@ -171,12 +191,10 @@ function parseStringArgument(args: string[], flag: string): string | undefined { export function validateHeadlessConfig(config: HeadlessConfig): ValidationResult { const errors: string[] = []; - // Validate mode-specific requirements if (config.mode === 'explicit-printers') { if (!config.printers || config.printers.length === 0) { errors.push('No printers specified for explicit-printers mode'); } else { - // Validate each printer spec config.printers.forEach((printer, index) => { if (!printer.ip) { errors.push(`Printer ${index + 1}: Missing IP address`); @@ -187,11 +205,19 @@ export function validateHeadlessConfig(config: HeadlessConfig): ValidationResult if (printer.type === 'new' && !printer.checkCode) { errors.push(`Printer ${index + 1}: New printer type requires check code`); } + // HTTP-only models (Creator 5 series) can't be probed over TCP for their + // serial, so it must be supplied. Dual-API models fall back to the + // probe's serial, so it stays optional there for compatibility. + if (printer.productId !== undefined && !printer.serialNumber) { + errors.push( + `Printer ${index + 1}: Creator 5 series requires a serial number ` + + `(format IP:TYPE:CHECKCODE:SERIAL)` + ); + } }); } } - // Validate optional overrides if (config.webUIPort !== undefined) { if (config.webUIPort < 1 || config.webUIPort > 65535) { errors.push('WebUI port must be between 1 and 65535'); diff --git a/src/utils/PrinterUtils.test.ts b/src/utils/PrinterUtils.test.ts new file mode 100644 index 0000000..830fe44 --- /dev/null +++ b/src/utils/PrinterUtils.test.ts @@ -0,0 +1,99 @@ +/** + * @fileoverview Unit tests for printer model detection and classification utilities. + * Focuses on the PID-based detection paths and HTTP-only (Creator 5 series) + * classification that drive connection-flow routing (issue #17). + */ + +import { describe, expect, it } from '@jest/globals'; +import { + detectPrinterFamily, + detectPrinterFamilyFromId, + detectPrinterModelType, + detectPrinterModelTypeFromId, + isHttpOnlyModel, + supportsDualAPI, +} from './PrinterUtils'; + +describe('detectPrinterModelType', () => { + it('detects 5M family models from typeName', () => { + expect(detectPrinterModelType('FlashForge Adventurer 5M Pro')).toBe('adventurer-5m-pro'); + expect(detectPrinterModelType('FlashForge Adventurer 5M')).toBe('adventurer-5m'); + expect(detectPrinterModelType('FlashForge AD5X')).toBe('ad5x'); + }); + + it('detects Creator 5 series models from typeName', () => { + expect(detectPrinterModelType('Creator 5 Pro')).toBe('creator-5-pro'); + expect(detectPrinterModelType('Creator 5')).toBe('creator-5'); + }); + + it('falls back to generic-legacy for unknown or empty typeName', () => { + expect(detectPrinterModelType('Adventurer 4')).toBe('generic-legacy'); + expect(detectPrinterModelType('')).toBe('generic-legacy'); + }); +}); + +describe('detectPrinterModelTypeFromId', () => { + it('prefers the USB product ID when known', () => { + expect(detectPrinterModelTypeFromId(35, '')).toBe('adventurer-5m'); + expect(detectPrinterModelTypeFromId(36, '')).toBe('adventurer-5m-pro'); + expect(detectPrinterModelTypeFromId(38, '')).toBe('ad5x'); + expect(detectPrinterModelTypeFromId(40, '')).toBe('creator-5'); + expect(detectPrinterModelTypeFromId(41, '')).toBe('creator-5-pro'); + }); + + it('falls back to typeName when the product ID is missing or unknown', () => { + expect(detectPrinterModelTypeFromId(undefined, 'Creator 5 Pro')).toBe('creator-5-pro'); + expect(detectPrinterModelTypeFromId(9999, 'FlashForge Adventurer 5M')).toBe('adventurer-5m'); + expect(detectPrinterModelTypeFromId(undefined, '')).toBe('generic-legacy'); + }); +}); + +describe('detectPrinterFamilyFromId', () => { + it('classifies known product IDs as new-API printers requiring a check code', () => { + const info = detectPrinterFamilyFromId(40, ''); + expect(info.is5MFamily).toBe(true); + expect(info.requiresCheckCode).toBe(true); + expect(info.familyName).toBe('Creator 5'); + }); + + it('falls back to typeName detection without a product ID', () => { + const legacy = detectPrinterFamilyFromId(undefined, 'Adventurer 4'); + expect(legacy.is5MFamily).toBe(false); + expect(legacy.requiresCheckCode).toBe(false); + }); +}); + +describe('detectPrinterFamily', () => { + it('treats the Creator 5 series as part of the modern check-code family', () => { + expect(detectPrinterFamily('Creator 5').is5MFamily).toBe(true); + expect(detectPrinterFamily('Creator 5 Pro').requiresCheckCode).toBe(true); + }); +}); + +describe('isHttpOnlyModel', () => { + it('marks only the Creator 5 series as HTTP-only', () => { + expect(isHttpOnlyModel('creator-5')).toBe(true); + expect(isHttpOnlyModel('creator-5-pro')).toBe(true); + expect(isHttpOnlyModel('adventurer-5m')).toBe(false); + expect(isHttpOnlyModel('adventurer-5m-pro')).toBe(false); + expect(isHttpOnlyModel('ad5x')).toBe(false); + expect(isHttpOnlyModel('generic-legacy')).toBe(false); + }); +}); + +describe('supportsDualAPI', () => { + it('is true only for modern printers that also run the legacy TCP server', () => { + expect(supportsDualAPI('adventurer-5m')).toBe(true); + expect(supportsDualAPI('adventurer-5m-pro')).toBe(true); + expect(supportsDualAPI('ad5x')).toBe(true); + }); + + it('is false for HTTP-only models (Creator 5 series has no TCP server)', () => { + expect(supportsDualAPI('creator-5')).toBe(false); + expect(supportsDualAPI('creator-5-pro')).toBe(false); + }); + + it('is false for legacy printers', () => { + expect(supportsDualAPI('generic-legacy')).toBe(false); + }); +}); diff --git a/src/utils/PrinterUtils.ts b/src/utils/PrinterUtils.ts index a9a13b3..9e7033d 100644 --- a/src/utils/PrinterUtils.ts +++ b/src/utils/PrinterUtils.ts @@ -101,6 +101,28 @@ const HTTP_ONLY_MODEL_TYPES: ReadonlySet = new Set(['creator-5 export const isHttpOnlyModel = (modelType: PrinterModelType): boolean => HTTP_ONLY_MODEL_TYPES.has(modelType); +/** + * Model types that support the remote "Reboot Printer" feature. + * + * Reboot is delivered over the flashforge-easyssh root SSH/SFTP surface, which + * is only provisioned on the Adventurer 5M / 5M Pro / AD5X. Creator 5 / 5 Pro + * use a different (Klipper) SSH method that is not yet wired up, and legacy + * printers have no SSH surface at all. + */ +const REBOOT_SUPPORTED_MODEL_TYPES: ReadonlySet = new Set([ + 'adventurer-5m', + 'adventurer-5m-pro', + 'ad5x', +]); + +/** + * Whether a printer model supports the remote reboot command. Defense-in-depth: + * the WebUI hides the button for unsupported models, but the printer-power + * route re-checks this before dispatching the SSH command. + */ +export const isRebootSupportedModel = (modelType: PrinterModelType | undefined): boolean => + !!modelType && REBOOT_SUPPORTED_MODEL_TYPES.has(modelType); + /** * USB product IDs for new-API (HTTP + check-code) printers. The firmware-set * product ID is authoritative for model selection; typeName is the fallback. @@ -231,10 +253,12 @@ export const getPrinterModelInfo = (typeName: string): EnhancedPrinterFamilyInfo /** * Check if printer supports dual API usage - * Modern printers (5M family) can use both new and legacy APIs + * 5M / 5M Pro / AD5X can use both the new HTTP API and the legacy TCP API. + * HTTP-only models (Creator 5 series) have no legacy TCP server, so they are + * modern but NOT dual-API. */ export const supportsDualAPI = (modelType: PrinterModelType): boolean => { - return modelType !== 'generic-legacy'; + return modelType !== 'generic-legacy' && !isHttpOnlyModel(modelType); }; /** @@ -413,7 +437,6 @@ export const isValidCheckCode = (checkCode: string): boolean => { return false; } - // Check codes should be at least 1 character const trimmed = checkCode.trim(); return trimmed.length >= 1 && trimmed.length <= 20; }; @@ -462,7 +485,6 @@ export const getConnectionErrorMessage = (error: unknown): string => { return errorObj.message; } - // Handle specific error types if (errorObj.code === 'ECONNREFUSED') { return 'Connection refused - printer may be offline or unreachable'; } @@ -484,7 +506,6 @@ export const getConnectionErrorMessage = (error: unknown): string => { * 5M family printers may need longer timeouts for pairing */ export const getConnectionTimeout = (is5MFamily: boolean): number => { - // Return timeout in milliseconds return is5MFamily ? 15000 : 10000; // 15s for 5M, 10s for legacy }; diff --git a/src/utils/SecureStorage.ts b/src/utils/SecureStorage.ts new file mode 100644 index 0000000..20a89c6 --- /dev/null +++ b/src/utils/SecureStorage.ts @@ -0,0 +1,43 @@ +/** + * @fileoverview Lightweight helpers for encoding/decoding secrets at rest. + * + * Standalone port of the FlashForgeUI SecureStorage helpers. The desktop app + * uses Electron's safeStorage (OS keychain) when available; this Node-only + * build has no OS keychain access, so secrets are stored base64-encoded with + * the same `plain:` prefix the desktop uses as its fallback. The formats are + * interchangeable: `enc:` values written by the desktop cannot be decrypted + * here and resolve to undefined (callers fall back to defaults). + */ + +const ENCRYPT_PREFIX = 'enc:'; +const PLAIN_PREFIX = 'plain:'; + +export function encryptSecret(value: string): string { + if (!value) { + return value; + } + return `${PLAIN_PREFIX}${Buffer.from(value, 'utf-8').toString('base64')}`; +} + +export function decryptSecret(value?: string): string | undefined { + if (!value) { + return value; + } + + if (value.startsWith(ENCRYPT_PREFIX)) { + // Written by the Electron desktop app via OS-level encryption; not + // recoverable without that keychain. Treat as unset. + return undefined; + } + + if (value.startsWith(PLAIN_PREFIX)) { + const payload = value.slice(PLAIN_PREFIX.length); + try { + return Buffer.from(payload, 'base64').toString('utf-8'); + } catch { + return undefined; + } + } + + return value; +} diff --git a/src/utils/ShutdownTimeout.ts b/src/utils/ShutdownTimeout.ts index aa067af..3660545 100644 --- a/src/utils/ShutdownTimeout.ts +++ b/src/utils/ShutdownTimeout.ts @@ -61,14 +61,12 @@ export async function withTimeout( try { const result = await Promise.race([promise, timeoutPromise]); - // Clear timeout if promise won the race if (timeoutHandle) { clearTimeout(timeoutHandle); } return result; } catch (error) { - // Ensure timeout is cleared even on error if (timeoutHandle) { clearTimeout(timeoutHandle); } diff --git a/src/utils/camera-utils.ts b/src/utils/camera-utils.ts index 504b5ff..36a667f 100644 --- a/src/utils/camera-utils.ts +++ b/src/utils/camera-utils.ts @@ -73,7 +73,6 @@ export function validateCameraUrl(url: string | null | undefined): CameraUrlVali try { const parsedUrl = new URL(url); - // Check for supported protocols if (!['http:', 'https:', 'rtsp:'].includes(parsedUrl.protocol)) { return { isValid: false, @@ -81,7 +80,6 @@ export function validateCameraUrl(url: string | null | undefined): CameraUrlVali }; } - // Check for valid hostname if (!parsedUrl.hostname || parsedUrl.hostname === '') { return { isValid: false, @@ -167,7 +165,7 @@ export function resolveCameraConfig(params: CameraUrlResolutionParams): Resolved /** * Get camera configuration from per-printer settings. * - * @param contextId - Optional context ID to get per-printer camera settings + * @param contextId - Context ID * @returns Camera user configuration */ export function getCameraUserConfig(contextId?: string): CameraUserConfig { diff --git a/src/utils/time.utils.ts b/src/utils/time.utils.ts index 03e85e5..e7abc7d 100644 --- a/src/utils/time.utils.ts +++ b/src/utils/time.utils.ts @@ -111,40 +111,6 @@ export function calculateRemaining(elapsed: number, total: number): number { return Math.max(0, total - elapsed); } -/** - * Calculate ETA based on progress and elapsed time - */ -export function calculateETA(progress: number, elapsedSeconds: number): number { - if (progress <= 0) { - return 0; - } - - return Math.round((elapsedSeconds / progress) * 100); -} - -/** - * Format ETA as date/time string - */ -export function formatETA(etaSeconds: number): string { - const eta = new Date(Date.now() + etaSeconds * 1000); - const now = new Date(); - - // If ETA is today, show time only - if (eta.toDateString() === now.toDateString()) { - return formatTime(eta); - } - - // If ETA is tomorrow, show "Tomorrow HH:MM" - const tomorrow = new Date(now); - tomorrow.setDate(tomorrow.getDate() + 1); - if (eta.toDateString() === tomorrow.toDateString()) { - return `Tomorrow ${formatTime(eta)}`; - } - - // Otherwise show full date and time - return formatDateTime(eta); -} - /** * Parse duration string to seconds */ diff --git a/src/webui/schemas/web-api.schemas.ts b/src/webui/schemas/web-api.schemas.ts index 133500c..c2c8c61 100644 --- a/src/webui/schemas/web-api.schemas.ts +++ b/src/webui/schemas/web-api.schemas.ts @@ -13,6 +13,7 @@ * - WebSocket schemas: WebSocketCommandSchema, WebSocketCommandTypeSchema * - Command validation: PrinterCommandSchema, CommandDataValidators * - Temperature/Job schemas: TemperatureSetRequestSchema, JobStartRequestSchema, GCodeCommandRequestSchema + * - Job upload schemas: JobUploadStartRequestSchema, JobUploadCancelRequestSchema * - Helper functions: validateWebSocketCommand, extractBearerToken, createValidationError * - Type exports: ValidatedLoginRequest, ValidatedWebSocketCommand, ValidatedPrinterCommand */ @@ -105,6 +106,21 @@ const MaterialMappingSchema = z.object({ slotMaterialColor: z.string().min(1, 'slotMaterialColor is required'), }); +/** + * Spoolman spool chosen for one tool in the matching dialog. A null spoolId + * means the user chose not to track that tool. + */ +const SpoolAssignmentSchema = z.object({ + toolId: z.number().int('toolId must be an integer').min(0, 'toolId must be non-negative'), + spoolId: z + .number() + .int('spoolId must be an integer') + .positive('spoolId must be a positive integer') + .nullable(), +}); + +const SpoolAssignmentsSchema = z.array(SpoolAssignmentSchema).max(16).optional(); + export const JobStartRequestSchema = z.object({ filename: z.string().min(1, 'Filename is required'), leveling: z.boolean().optional().default(false), @@ -113,6 +129,31 @@ export const JobStartRequestSchema = z.object({ .array(MaterialMappingSchema) .min(1, 'materialMappings must contain at least one mapping') .optional(), + spoolAssignments: SpoolAssignmentsSchema, +}); + +/** + * Job upload start request validation. + * + * `uploadId` refers to a file already staged via POST /api/jobs/upload/stage, so + * no filename is accepted here - the server only ever prints the name it wrote. + */ +export const JobUploadStartRequestSchema = z.object({ + uploadId: z.string().uuid('uploadId must be a valid upload handle'), + startNow: z.boolean().optional().default(true), + autoLevel: z.boolean().optional().default(false), + materialMappings: z + .array(MaterialMappingSchema) + .min(1, 'materialMappings must contain at least one mapping') + .optional(), + spoolAssignments: SpoolAssignmentsSchema, +}); + +/** + * Job upload cancel/discard request validation. + */ +export const JobUploadCancelRequestSchema = z.object({ + uploadId: z.string().uuid('uploadId must be a valid upload handle'), }); /** @@ -199,6 +240,13 @@ export const PrinterFeaturesSchema = z.object({ canPause: z.boolean(), canResume: z.boolean(), canCancel: z.boolean(), + gcodeCommands: z + .object({ + available: z.boolean(), + usesLegacyAPI: z.boolean(), + supportedCommands: z.array(z.string()), + }) + .optional(), }); // ============================================================================ @@ -218,7 +266,6 @@ export function validateWebSocketCommand( const command = commandResult.data; - // Validate command-specific data if needed if (command.command in CommandDataValidators) { const validator = CommandDataValidators[command.command as keyof typeof CommandDataValidators]; const dataResult = validator.safeParse(command.data); @@ -295,13 +342,13 @@ export const SpoolClearRequestSchema = z.object({ }); /** - * Slot-config request validation (configure an AD5X material-station slot). + * Slot-config request validation (configure a material-station slot). * * The client snaps a Spoolman spool's material/color to the printer's fixed - * 14-material / 24-color palette and sends the already-snapped values here. Slot - * is 1-based (1-4); `materialName` is null when the spool's material did not - * resolve, in which case the slot keeps its current material. `colorHex` is a - * 3/6/8-digit hex (with or without leading '#'). + * palette (AD5X vs Creator 5) and sends the already-snapped values here. Slot is + * 1-based (1-4); `materialName` is required (the slot editor always resolves a + * material from the model's fixed palette), and `colorHex` must be a 6-digit hex + * with an optional leading '#'. Both string fields are trimmed before validation. */ export const SlotConfigRequestSchema = z.object({ contextId: z.string().optional(), @@ -310,12 +357,11 @@ export const SlotConfigRequestSchema = z.object({ .int('slot must be an integer') .min(1, 'slot must be at least 1') .max(4, 'slot must be at most 4'), - materialName: z.string().min(1).nullable(), + materialName: z.string().trim().min(1, 'Material name is required'), colorHex: z .string() - .regex(/^#?[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{2})?)?$/, 'Invalid hex color'), - /** Optional fallback material to keep when `materialName` is null. */ - currentMaterial: z.string().min(1).nullable().optional(), + .trim() + .regex(/^#?[0-9a-fA-F]{6}$/, 'A valid 6-digit hex color is required'), }); // ============================================================================ diff --git a/src/webui/server/AuthManager.ts b/src/webui/server/AuthManager.ts index dd77e53..93f0162 100644 --- a/src/webui/server/AuthManager.ts +++ b/src/webui/server/AuthManager.ts @@ -58,7 +58,6 @@ export class AuthManager { private cleanupInterval: NodeJS.Timeout | null = null; constructor() { - // Start periodic cleanup of expired sessions this.startSessionCleanup(); } @@ -76,7 +75,6 @@ export class AuthManager { const config = this.configManager.getConfig(); const serverPassword = config.WebUIPassword; - // Check if password matches if (request.password !== serverPassword) { return { success: false, @@ -84,7 +82,6 @@ export class AuthManager { }; } - // Generate session token const token = this.generateToken(request.rememberMe || false); return { @@ -103,7 +100,6 @@ export class AuthManager { const timeout = persistent ? this.sessionTimeout : this.tempSessionTimeout; const expiresAt = now + timeout; - // Create token payload const payload: TokenPayload = { sessionId, createdAt: now, @@ -111,17 +107,13 @@ export class AuthManager { persistent, }; - // Encode payload as base64 const tokenData = Buffer.from(JSON.stringify(payload)).toString('base64'); - // Create signature using config as secret const secret = this.getTokenSecret(); const signature = crypto.createHmac('sha256', secret).update(tokenData).digest('hex'); - // Combine token data and signature const token = `${tokenData}.${signature}`; - // Store session info const sessionInfo: SessionInfo = { token, createdAt: new Date(now), @@ -144,13 +136,11 @@ export class AuthManager { } try { - // Validate token format const validatedToken = validateAuthToken(token); if (!validatedToken) { return { isValid: false }; } - // Split token and signature const parts = token.split('.'); if (parts.length !== 2) { return { isValid: false }; @@ -158,7 +148,6 @@ export class AuthManager { const [tokenData, signature] = parts; - // Verify signature const secret = this.getTokenSecret(); const expectedSignature = crypto.createHmac('sha256', secret).update(tokenData).digest('hex'); @@ -166,22 +155,18 @@ export class AuthManager { return { isValid: false }; } - // Decode payload const payload = JSON.parse(Buffer.from(tokenData, 'base64').toString()) as TokenPayload; - // Check expiration if (payload.expiresAt < Date.now()) { this.sessions.delete(payload.sessionId); return { isValid: false }; } - // Check if session exists const session = this.sessions.get(payload.sessionId); if (!session) { return { isValid: false }; } - // Update last activity session.lastActivity = new Date(); return { isValid: true, sessionId: payload.sessionId }; @@ -200,13 +185,11 @@ export class AuthManager { } try { - // Validate token format const validatedToken = validateAuthToken(token); if (!validatedToken) { return false; } - // Split token and signature const parts = token.split('.'); if (parts.length !== 2) { return false; @@ -214,7 +197,6 @@ export class AuthManager { const [tokenData, signature] = parts; - // Verify signature const secret = this.getTokenSecret(); const expectedSignature = crypto.createHmac('sha256', secret).update(tokenData).digest('hex'); @@ -222,22 +204,18 @@ export class AuthManager { return false; } - // Decode payload const payload = JSON.parse(Buffer.from(tokenData, 'base64').toString()) as TokenPayload; - // Check expiration if (payload.expiresAt < Date.now()) { this.sessions.delete(payload.sessionId); return false; } - // Check if session exists const session = this.sessions.get(payload.sessionId); if (!session) { return false; } - // Update last activity session.lastActivity = new Date(); return true; @@ -307,7 +285,6 @@ export class AuthManager { */ private getTokenSecret(): string { const config = this.configManager.getConfig(); - // Use a combination of password and a fixed salt for the secret return crypto .createHash('sha256') .update(config.WebUIPassword) @@ -327,7 +304,6 @@ export class AuthManager { * Start periodic cleanup of expired sessions */ private startSessionCleanup(): void { - // Clean up every 5 minutes this.cleanupInterval = setInterval( () => { const now = Date.now(); @@ -354,7 +330,6 @@ export class AuthManager { } } -// Export singleton instance let authManager: AuthManager | null = null; export function getAuthManager(): AuthManager { diff --git a/src/webui/server/WebSocketManager.ts b/src/webui/server/WebSocketManager.ts index ed4f1b5..521ba78 100644 --- a/src/webui/server/WebSocketManager.ts +++ b/src/webui/server/WebSocketManager.ts @@ -21,12 +21,13 @@ import { EventEmitter } from 'events'; import type * as http from 'http'; import type { Duplex } from 'node:stream'; -import { type RawData, type WebSocket, WebSocketServer } from 'ws'; +import { type RawData, WebSocket, WebSocketServer } from 'ws'; import { getPrinterBackendManager } from '../../managers/PrinterBackendManager'; import { getPrinterContextManager } from '../../managers/PrinterContextManager'; import type { SpoolmanChangedEvent } from '../../services/SpoolmanIntegrationService'; import { getSpoolmanIntegrationService } from '../../services/SpoolmanIntegrationService'; import type { PollingData } from '../../types/polling'; +import type { RebootStatusPayload } from '../../types/printer-power'; import { AppError, ErrorCode, toAppError } from '../../utils/error.utils'; import { createValidationError, WebSocketCommandSchema } from '../schemas/web-api.schemas'; import type { PrinterStatusData, WebSocketCommand, WebSocketMessage } from '../types/web-api.types'; @@ -56,8 +57,6 @@ interface ClientInfo { readonly clientId: string; } -// FormattedPrinterStatus is now replaced by PrinterStatusData from web-api.types.ts - /** * WebSocket Manager - Handles real-time communication */ @@ -107,16 +106,13 @@ export class WebSocketManager extends EventEmitter { return; } - // Create WebSocket server this.wss = new WebSocketServer({ noServer: true, verifyClient: this.verifyClient.bind(this), }); - // Setup event handlers this.wss.on('connection', this.handleConnection.bind(this)); - // Setup Spoolman integration event listener try { const spoolmanService = getSpoolmanIntegrationService(); spoolmanService.on('spoolman-changed', this.handleSpoolmanChanged.bind(this)); @@ -163,7 +159,6 @@ export class WebSocketManager extends EventEmitter { return; } - // Extract token from URL query params or Authorization header const url = new URL(info.req.url || '', `http://${info.req.headers.host}`); const token = url.searchParams.get('token') || info.req.headers.authorization?.replace('Bearer ', ''); @@ -173,7 +168,6 @@ export class WebSocketManager extends EventEmitter { return; } - // Validate token const validation = this.authManager.validateToken(token); if (!validation.isValid) { @@ -181,7 +175,6 @@ export class WebSocketManager extends EventEmitter { return; } - // Store token for later use - properly typed (info.req as ExtendedIncomingMessage).wsToken = token; callback(true); } catch (error) { @@ -205,7 +198,6 @@ export class WebSocketManager extends EventEmitter { const clientId = this.generateClientId(); - // Create client info const clientInfo: ClientInfo = { token: token ?? null, connectedAt: new Date(), @@ -213,7 +205,6 @@ export class WebSocketManager extends EventEmitter { clientId, }; - // Store client this.clients.set(ws, clientInfo); // Add to token-based map for multi-tab support @@ -226,12 +217,10 @@ export class WebSocketManager extends EventEmitter { tokenClients.add(ws); } - // Update client count this.updateClientCount(); console.log(`WebSocket client connected: ${clientId} - Total clients: ${this.clients.size}`); - // Send authentication success const authMessage: WebSocketMessage = { type: 'AUTH_SUCCESS', timestamp: new Date().toISOString(), @@ -239,16 +228,13 @@ export class WebSocketManager extends EventEmitter { }; this.sendToClient(ws, authMessage); - // Send initial printer status if connected void this.sendInitialStatus(ws); - // Setup event handlers ws.on('message', (data) => this.handleMessage(ws, data)); ws.on('close', () => this.handleDisconnect(ws)); ws.on('error', (error) => this.handleError(ws, error)); ws.on('pong', () => this.handlePong(ws)); - // Start ping interval for this client this.startPingInterval(ws); } @@ -263,10 +249,8 @@ export class WebSocketManager extends EventEmitter { return; } - // Update last activity clientInfo.lastActivity = new Date(); - // Parse message safely let parsedData: unknown; try { parsedData = JSON.parse(data.toString()); @@ -281,7 +265,6 @@ export class WebSocketManager extends EventEmitter { return; } - // Validate as WebSocket command const validation = WebSocketCommandSchema.safeParse(parsedData); if (!validation.success) { @@ -296,7 +279,6 @@ export class WebSocketManager extends EventEmitter { const command = validation.data; - // Handle command based on type await this.handleCommand(ws, command); } catch (error) { console.error('Error handling WebSocket message:', error); @@ -379,10 +361,8 @@ export class WebSocketManager extends EventEmitter { console.log(`WebSocket client disconnected: ${clientInfo.clientId}`); - // Remove from clients map this.clients.delete(ws); - // Remove from token map if (clientInfo.token) { const tokenClients = this.clientsByToken.get(clientInfo.token); if (tokenClients) { @@ -393,7 +373,6 @@ export class WebSocketManager extends EventEmitter { } } - // Update client count this.updateClientCount(); } @@ -402,7 +381,6 @@ export class WebSocketManager extends EventEmitter { */ private handleError(ws: WebSocket, error: Error): void { console.error('WebSocket error:', error); - // Close the connection on error ws.close(); } @@ -421,15 +399,13 @@ export class WebSocketManager extends EventEmitter { */ private startPingInterval(ws: WebSocket): void { const interval = setInterval(() => { - if (ws.readyState === 1) { - // WebSocket.OPEN = 1 + if (ws.readyState === WebSocket.OPEN) { ws.ping(); } else { clearInterval(interval); } }, 30000); // Ping every 30 seconds - // Clear interval when connection closes ws.on('close', () => clearInterval(interval)); } @@ -500,11 +476,13 @@ export class WebSocketManager extends EventEmitter { const status = data.printerStatus; const currentJob = status.currentJob; - // Extract temperature data with null safety + // The library completion time is a Date here; serialize it to an ISO string + // so the wire payload matches PrinterStatusData (string | null). + const completionDate = currentJob?.progress.completionTime; + const bedTemp = status.temperatures?.bed || { current: 0, target: 0 }; const extruderTemp = status.temperatures?.extruder || { current: 0, target: 0 }; - // Extract filtration mode with null safety and ensure it's a valid type const rawFiltrationMode = status.filtration?.mode || 'none'; const filtrationMode: 'external' | 'internal' | 'none' = rawFiltrationMode === 'external' || rawFiltrationMode === 'internal' @@ -535,6 +513,7 @@ export class WebSocketManager extends EventEmitter { currentJob?.progress.formattedEta !== undefined ? currentJob.progress.formattedEta : undefined, + completionTime: completionDate instanceof Date ? completionDate.toISOString() : null, elapsedTimeSeconds: currentJob?.progress.elapsedTimeSeconds !== undefined ? currentJob.progress.elapsedTimeSeconds @@ -544,7 +523,6 @@ export class WebSocketManager extends EventEmitter { estimatedWeight: currentJob?.progress.weightUsed || undefined, estimatedLength: currentJob?.progress.lengthUsed || undefined, thumbnailData: data.thumbnailData || null, // Include thumbnail data - // Extract lifetime statistics from cumulative stats // Backend provides filament usage in meters, same as main UI cumulativeFilament: status.cumulativeStats?.totalFilamentUsed || undefined, cumulativePrintTime: status.cumulativeStats?.totalPrintTime || undefined, @@ -623,12 +601,29 @@ export class WebSocketManager extends EventEmitter { this.broadcast(spoolmanMessage); } + /** + * Broadcast a printer reboot lifecycle update to all connected clients. + * Called by the PrinterRebootService as the reboot progresses so the WebUI + * reboot overlay can follow along. + */ + public broadcastRebootStatus(contextId: string, payload: RebootStatusPayload): void { + if (!this.isRunning || this.clients.size === 0) { + return; + } + + this.broadcast({ + type: 'REBOOT_STATUS', + timestamp: new Date().toISOString(), + contextId, + reboot: payload, + }); + } + /** * Send message to specific client */ private sendToClient(ws: WebSocket, message: WebSocketMessage): void { - if (ws.readyState === 1) { - // WebSocket.OPEN = 1 + if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } } @@ -640,8 +635,7 @@ export class WebSocketManager extends EventEmitter { const messageStr = JSON.stringify(message); for (const [ws] of this.clients) { - if (ws.readyState === 1) { - // WebSocket.OPEN = 1 + if (ws.readyState === WebSocket.OPEN) { ws.send(messageStr); } } @@ -657,8 +651,7 @@ export class WebSocketManager extends EventEmitter { const messageStr = JSON.stringify(message); for (const ws of clients) { - if (ws.readyState === 1) { - // WebSocket.OPEN = 1 + if (ws.readyState === WebSocket.OPEN) { ws.send(messageStr); } } @@ -732,17 +725,14 @@ export class WebSocketManager extends EventEmitter { public shutdown(): void { if (!this.wss) return; - // Close all client connections for (const [ws] of this.clients) { ws.close(1000, 'Server shutting down'); } - // Clear maps this.clients.clear(); this.clientsByToken.clear(); this.latestPollingDataByContext.clear(); - // Close server this.wss.close(() => { console.log('WebSocket server shut down'); }); diff --git a/src/webui/server/WebUIManager.integration.test.ts b/src/webui/server/WebUIManager.integration.test.ts index 5357e08..75918f7 100644 --- a/src/webui/server/WebUIManager.integration.test.ts +++ b/src/webui/server/WebUIManager.integration.test.ts @@ -36,33 +36,27 @@ describe('WebUIManager Integration Tests', () => { const mockStaticPath = path.join(__dirname, '../static/mock-webui'); beforeAll(() => { - // Create mock static directory structure if (!fs.existsSync(mockStaticPath)) { fs.mkdirSync(mockStaticPath, { recursive: true }); } - // Create a mock index.html const mockIndexHtml = 'Mock WebUI'; fs.writeFileSync(path.join(mockStaticPath, 'index.html'), mockIndexHtml); - // Create a mock CSS file const mockCss = 'body { margin: 0; }'; fs.writeFileSync(path.join(mockStaticPath, 'styles.css'), mockCss); - // Create a mock JS file const mockJs = 'console.log("test");'; fs.writeFileSync(path.join(mockStaticPath, 'app.js'), mockJs); }); afterAll(() => { - // Cleanup mock directory if (fs.existsSync(mockStaticPath)) { fs.rmSync(mockStaticPath, { recursive: true, force: true }); } }); beforeEach(() => { - // Setup mocks mockEnvironmentService = { isPackaged: jest.fn().mockReturnValue(false), isProduction: jest.fn().mockReturnValue(false), @@ -147,7 +141,6 @@ describe('WebUIManager Integration Tests', () => { return next(); } - // Skip API routes if (req.path.startsWith('/api')) { return next(); } @@ -275,7 +268,6 @@ describe('WebUIManager Integration Tests', () => { }); it('should not serve index.html for requests with file extensions', async () => { - // This test verifies that file requests don't fall through to SPA const response = await request(app).get('/test.json'); expect(response.status).toBe(404); @@ -317,7 +309,6 @@ describe('WebUIManager Integration Tests', () => { // In development (isProduction: false), cache should be disabled const response = await request(app).get('/styles.css'); - // Cache header should be 'no-cache' or similar when maxAge is 0 const cacheControl = response.headers['cache-control']; expect(cacheControl).toBeDefined(); }); @@ -384,4 +375,3 @@ describe('WebUIManager Integration Tests', () => { }); // Note: EnvironmentService is fully tested in EnvironmentService.test.ts -// These integration tests focus on WebUIManager middleware and routing diff --git a/src/webui/server/WebUIManager.lifecycle.test.ts b/src/webui/server/WebUIManager.lifecycle.test.ts new file mode 100644 index 0000000..c490f82 --- /dev/null +++ b/src/webui/server/WebUIManager.lifecycle.test.ts @@ -0,0 +1,133 @@ +/** + * @fileoverview Lifecycle tests for the real WebUIManager start()/stop() paths: + * concurrent start() callers share one bind attempt, a failed attempt can be + * retried, and stop() waits for an in-flight start instead of being undone by it. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const configManager = Object.assign(new EventEmitter(), { + getConfig: jest.fn<() => Record>(), + get: jest.fn(() => true), +}); + +const environmentService = { + getWebUIStaticPath: jest.fn<() => string>(), + getEnvironmentInfo: jest.fn(() => ({ isPackaged: false })), +}; + +const webSocketManager = { + initialize: jest.fn(), + shutdown: jest.fn(), + handleUpgrade: jest.fn(), +}; + +jest.mock('../../managers/ConfigManager', () => ({ + getConfigManager: () => configManager, +})); + +jest.mock('../../services/EnvironmentService', () => ({ + getEnvironmentService: () => environmentService, +})); + +jest.mock('./AuthManager', () => ({ + getAuthManager: () => ({ isAuthenticationRequired: () => false }), +})); + +jest.mock('./WebSocketManager', () => ({ + getWebSocketManager: () => webSocketManager, +})); + +jest.mock('./api-routes', () => { + const express = jest.requireActual('express'); + return { + buildRouteDependencies: () => ({}), + createAPIRoutes: () => express.Router(), + }; +}); + +jest.mock('./auth-middleware', () => { + const passThrough = () => (_req: unknown, _res: unknown, next: () => void) => next(); + return { + createRequestLogger: passThrough, + createLoginRateLimiter: passThrough, + createErrorMiddleware: () => (_err: unknown, _req: unknown, _res: unknown, next: () => void) => next(), + }; +}); + +jest.mock('./routes/theme-routes', () => ({ + registerPublicThemeRoutes: jest.fn(), +})); + +jest.mock('./upload-staging', () => ({ + disposeUploadStaging: jest.fn(async () => {}), +})); + +import { getWebUIManager, WebUIManager } from './WebUIManager'; + +describe('WebUIManager lifecycle', () => { + let staticDir: string; + + beforeAll(() => { + staticDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ffwebui-lifecycle-')); + fs.writeFileSync(path.join(staticDir, 'index.html'), '', 'utf8'); + }); + + afterAll(() => { + fs.rmSync(staticDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (WebUIManager as unknown as { instance: unknown }).instance = null; + environmentService.getWebUIStaticPath.mockReturnValue(staticDir); + configManager.getConfig.mockReturnValue({ + WebUIEnabled: true, + WebUIPort: 0, + WebUIPassword: '', + WebUIPasswordRequired: false, + }); + }); + + afterEach(async () => { + await getWebUIManager().stop(); + (WebUIManager as unknown as { instance: unknown }).instance = null; + }); + + it('shares one start attempt between concurrent start() callers', async () => { + const manager = getWebUIManager(); + const results = await Promise.all([manager.start(), manager.start()]); + + expect(results).toEqual([true, true]); + // One attempt means one server build; a second would have rebound the port. + expect(webSocketManager.initialize).toHaveBeenCalledTimes(1); + expect(manager.getStatus().isRunning).toBe(true); + }); + + it('retries after an attempt that failed before its first await', async () => { + configManager.getConfig.mockImplementationOnce(() => { + throw new Error('config unavailable'); + }); + + const manager = getWebUIManager(); + + expect(await manager.start()).toBe(false); + expect(await manager.start()).toBe(true); + expect(manager.getStatus().isRunning).toBe(true); + }); + + it('waits for an in-flight start before stopping', async () => { + const manager = getWebUIManager(); + const starting = manager.start(); + const stopped = await manager.stop(); + + expect(await starting).toBe(true); + expect(stopped).toBe(true); + expect(manager.getStatus().isRunning).toBe(false); + expect(manager.getHttpServer()).toBeNull(); + }); +}); diff --git a/src/webui/server/WebUIManager.ts b/src/webui/server/WebUIManager.ts index bac6f3e..a5a47ef 100644 --- a/src/webui/server/WebUIManager.ts +++ b/src/webui/server/WebUIManager.ts @@ -35,12 +35,12 @@ import { buildRouteDependencies, createAPIRoutes } from './api-routes'; import { CameraStreamProxy } from './CameraStreamProxy'; import { type AuthenticatedRequest, - createAuthMiddleware, createErrorMiddleware, createLoginRateLimiter, createRequestLogger, } from './auth-middleware'; import { registerPublicThemeRoutes } from './routes/theme-routes'; +import { disposeUploadStaging } from './upload-staging'; import { getWebSocketManager } from './WebSocketManager'; /** @@ -87,12 +87,13 @@ export class WebUIManager extends EventEmitter { // Server state private isRunning: boolean = false; private isStopping: boolean = false; + /** In-flight start attempt, shared by concurrent start() callers */ + private startPromise: Promise | null = null; private serverIP: string = 'localhost'; private port: number = 3000; // Client tracking private connectedClients: number = 0; - // Track which contexts have WebUI enabled private readonly registeredContexts: Set = new Set(); private readonly contextSerialNumbers: Map = new Map(); @@ -156,12 +157,10 @@ export class WebUIManager extends EventEmitter { const environmentService = getEnvironmentService(); const webUIStaticPath = environmentService.getWebUIStaticPath(); - // Log environment info for debugging const envInfo = environmentService.getEnvironmentInfo(); console.log(`[WebUI] Environment: ${envInfo.isPackaged ? 'packaged binary' : 'development'}`); console.log(`[WebUI] Serving static files from: ${webUIStaticPath}`); - // Verify the static path exists if (!fs.existsSync(webUIStaticPath)) { console.error(`[WebUI] Static file path does not exist: ${webUIStaticPath}`); console.error('[WebUI] Environment details:', JSON.stringify(envInfo, null, 2)); @@ -196,7 +195,6 @@ export class WebUIManager extends EventEmitter { ); console.log('[WebUI] Static file middleware configured successfully'); - // Store static path for SPA fallback this.webUIStaticPath = webUIStaticPath; } catch (error) { console.error('[WebUI] Failed to configure static file serving:', error); @@ -223,10 +221,11 @@ export class WebUIManager extends EventEmitter { // Public routes that should be available without authentication (e.g., theme defaults) registerPublicThemeRoutes(this.expressApp, routeDependencies); - // Protected API routes (WebUI auth required) - this.expressApp.use('/api', createAuthMiddleware()); - - // Import and use API routes + // Protected API routes (WebUI auth required). + // Auth is applied PER-ROUTE inside createAPIRoutes (not as a blanket /api + // middleware) so that unknown /api/* paths find no matching route and fall + // through to the splat 404 handler below instead of being rejected with 401. + // Public routes registered above (auth + theme) stay open. const apiRoutes = createAPIRoutes(routeDependencies); this.expressApp.use('/api', apiRoutes); @@ -243,7 +242,6 @@ export class WebUIManager extends EventEmitter { // NOTE: This app does NOT use client-side routing. All UI state is managed via DOM manipulation. // The fallback ensures page refreshes and direct URL access work correctly. // Using path.extname() to detect file requests is safe since there are no client-side routes. - // If client-side routing is added in the future, this should use Accept header detection instead. this.expressApp.get('/*splat', (req, res, next) => { // Skip if this looks like a file request with extension (handled by static middleware) if (path.extname(req.path) && req.path !== '/') { @@ -320,8 +318,14 @@ export class WebUIManager extends EventEmitter { // Logout endpoint (optional auth) this.expressApp.post('/api/auth/logout', (req: AuthenticatedRequest, res) => { - if (req.auth?.token) { - this.authManager.revokeToken(req.auth.token); + // Extract the token manually: this route deliberately runs without the + // auth middleware (a logged-out caller must still reach it), so req.auth + // is never populated here. Relying on it silently skipped revocation and + // tokens stayed valid after logout - caught by the browser E2E suite. + const token = this.authManager.extractTokenFromHeader(req.headers.authorization); + + if (token) { + this.authManager.revokeToken(token); } const response: StandardAPIResponse = { @@ -386,16 +390,30 @@ export class WebUIManager extends EventEmitter { * Initialize and start the web UI server */ public async start(): Promise { - // Prevent concurrent calls if (this.isRunning) { console.log('WebUI server is already running'); return true; } + // Concurrent callers share one attempt. startWebUI() enables auto-start right + // before calling start(), so a configUpdated event during startup triggers a + // second start(); two independent attempts race to bind the same port and the + // loser's cleanupFailedStart() tears down the winner's sockets. The finally() + // callback always runs after the assignment, so a settled attempt can never be + // left behind in the field. + this.startPromise ??= this.startServer().finally(() => { + this.startPromise = null; + }); + return this.startPromise; + } + + /** + * Build and bind the server. Only start() calls this, so attempts never overlap. + */ + private async startServer(): Promise { try { const config = this.configManager.getConfig(); - // Check if WebUI is enabled if (!config.WebUIEnabled) { console.log('WebUI is disabled in configuration'); return false; @@ -404,19 +422,16 @@ export class WebUIManager extends EventEmitter { // Note: On Windows, binding to ports below 1024 may require administrator privileges // Users should run the application as administrator if needed - // Initialize Express application this.expressApp = express(); const expressApp = this.expressApp; this.port = config.WebUIPort; - // Setup middleware and routes this.setupMiddleware(); this.setupRoutes(); // Determine server IP this.serverIP = await this.determineServerIP(); - // Create HTTP server this.httpServer = http.createServer(expressApp); const httpServer = this.httpServer; @@ -445,7 +460,6 @@ export class WebUIManager extends EventEmitter { } }); - // Start listening await this.startListening(); this.isRunning = true; @@ -490,6 +504,12 @@ export class WebUIManager extends EventEmitter { * Stop the web UI server */ public async stop(timeoutMs = 3000): Promise { + // Let an in-flight start settle first, or it would mark the server running + // again after this stop has torn it down. + if (this.startPromise) { + await this.startPromise; + } + // Guard against concurrent stop calls if (this.isStopping) { console.warn('[WebUI] Stop already in progress'); @@ -502,6 +522,7 @@ export class WebUIManager extends EventEmitter { try { if (this.httpServer) { const httpServer = this.httpServer; + let closeTimer: NodeJS.Timeout | undefined; try { // Race server close against timeout await Promise.race([ @@ -511,9 +532,9 @@ export class WebUIManager extends EventEmitter { resolve(); }); }), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Server close timeout')), timeoutMs) - ), + new Promise((_, reject) => { + closeTimer = setTimeout(() => reject(new Error('Server close timeout')), timeoutMs); + }), ]); } catch (error) { if (error instanceof Error && error.message === 'Server close timeout') { @@ -525,6 +546,10 @@ export class WebUIManager extends EventEmitter { } else { throw error; } + } finally { + // A clean close wins the race but leaves the timer armed, holding the + // event loop open for the rest of the timeout. + clearTimeout(closeTimer); } this.httpServer = null; @@ -535,6 +560,10 @@ export class WebUIManager extends EventEmitter { this.cameraStreamProxy?.shutdown(); this.cameraStreamProxy = null; + // Scratch files for staged job uploads are worthless once the server that + // handed out their handles is gone. + await disposeUploadStaging(); + this.expressApp = null; this.isRunning = false; this.connectedClients = 0; @@ -618,7 +647,6 @@ export class WebUIManager extends EventEmitter { // Home network, preferred return iface.address; } else if (bestIP === 'localhost') { - // Use any non-internal IPv4 as fallback bestIP = iface.address; } } @@ -814,13 +842,11 @@ export class WebUIManager extends EventEmitter { * Handle WebUI startup errors */ private async handleStartupError(error: unknown): Promise { - // Convert to AppError for consistent handling const appError = error instanceof AppError ? error : new AppError(error instanceof Error ? error.message : String(error), ErrorCode.NETWORK); - // Log error where users can see it this.logToUI(`WebUI startup failed: ${appError.message}`); console.error('[WebUI] Startup error:', appError); diff --git a/src/webui/server/api-routes.ts b/src/webui/server/api-routes.ts index e55ca05..346e373 100644 --- a/src/webui/server/api-routes.ts +++ b/src/webui/server/api-routes.ts @@ -11,18 +11,25 @@ import { getConfigManager } from '../../managers/ConfigManager'; import { getPrinterConnectionManager } from '../../managers/ConnectionFlowManager'; import { getPrinterBackendManager } from '../../managers/PrinterBackendManager'; import { getPrinterContextManager } from '../../managers/PrinterContextManager'; +import { getMultiContextSpoolmanTracker } from '../../services/MultiContextSpoolmanTracker'; import { getSpoolmanIntegrationService } from '../../services/SpoolmanIntegrationService'; +import { createAuthMiddleware } from './auth-middleware'; +import { registerCalibrationRoutes } from './routes/calibration-routes'; import { registerCameraRoutes } from './routes/camera-routes'; import { registerContextRoutes } from './routes/context-routes'; import { registerDiscoveryRoutes } from './routes/discovery-routes'; +import { registerFileManagerRoutes } from './routes/file-manager-routes'; import { registerFiltrationRoutes } from './routes/filtration-routes'; import { registerJobRoutes } from './routes/job-routes'; +import { registerJobUploadRoutes } from './routes/job-upload-routes'; +import { registerPrinterPowerRoutes } from './routes/printer-power-routes'; import { registerPrinterControlRoutes } from './routes/printer-control-routes'; import { registerPrinterDetectionRoutes } from './routes/printer-detection-routes'; import { registerPrinterManagementRoutes } from './routes/printer-management-routes'; import { registerPrinterStatusRoutes } from './routes/printer-status-routes'; import type { RouteDependencies } from './routes/route-helpers'; import { registerSpoolmanRoutes } from './routes/spoolman-routes'; +import { registerSSHSettingsRoutes } from './routes/ssh-settings-routes'; import { registerTemperatureRoutes } from './routes/temperature-routes'; import { registerThemeRoutes } from './routes/theme-routes'; @@ -33,17 +40,37 @@ export function buildRouteDependencies(): RouteDependencies { contextManager: getPrinterContextManager(), configManager: getConfigManager(), spoolmanService: getSpoolmanIntegrationService(), + spoolmanTracker: getMultiContextSpoolmanTracker(), }; } export function createAPIRoutes(deps: RouteDependencies = buildRouteDependencies()): Router { const router = Router(); + // Per-route authentication: wrap each HTTP-verb registration method so the auth + // middleware is prepended to every route's handler chain. This MUST be per-route + // (rather than a pathless `router.use(auth)` or a blanket `app.use('/api', auth)`), + // because any pathless auth layer runs for EVERY /api/* request — including + // unknown paths — and would turn would-be 404s into 401s, defeating the + // app-level /api/*splat 404 handler. With per-route binding, requests to unknown + // /api/* paths find no matching route and fall through to 404, while every + // registered route stays authenticated exactly as before. + const auth = createAuthMiddleware(); + type Verb = 'get' | 'post' | 'put' | 'patch' | 'delete'; + const verbs: readonly Verb[] = ['get', 'post', 'put', 'patch', 'delete']; + const routerMethods = router as unknown as Record Router>; + for (const verb of verbs) { + const original = routerMethods[verb].bind(router); + routerMethods[verb] = (path: unknown, ...handlers: unknown[]): Router => + original(path, auth, ...handlers); + } + registerPrinterStatusRoutes(router, deps); registerPrinterControlRoutes(router, deps); registerTemperatureRoutes(router, deps); registerFiltrationRoutes(router, deps); registerJobRoutes(router, deps); + registerJobUploadRoutes(router, deps); registerCameraRoutes(router, deps); registerContextRoutes(router, deps); registerThemeRoutes(router, deps); @@ -51,6 +78,10 @@ export function createAPIRoutes(deps: RouteDependencies = buildRouteDependencies registerDiscoveryRoutes(router, deps); registerPrinterDetectionRoutes(router, deps); registerPrinterManagementRoutes(router, deps); + registerCalibrationRoutes(router, deps); + registerFileManagerRoutes(router, deps); + registerSSHSettingsRoutes(router, deps); + registerPrinterPowerRoutes(router, deps); return router; } diff --git a/src/webui/server/auth-middleware.ts b/src/webui/server/auth-middleware.ts index 4f6f7c3..b02349f 100644 --- a/src/webui/server/auth-middleware.ts +++ b/src/webui/server/auth-middleware.ts @@ -46,7 +46,6 @@ export function createAuthMiddleware() { return; } - // Extract token from Authorization header const authHeader = req.headers.authorization; const token = authManager.extractTokenFromHeader(authHeader); @@ -59,7 +58,6 @@ export function createAuthMiddleware() { return; } - // Verify token if (!authManager.verifyToken(token)) { const response: StandardAPIResponse = { success: false, @@ -95,7 +93,6 @@ export function createOptionalAuthMiddleware() { return; } - // Extract token from Authorization header const authHeader = req.headers.authorization; const token = authManager.extractTokenFromHeader(authHeader); @@ -127,7 +124,6 @@ export function createLoginRateLimiter() { const ip = req.ip || 'unknown'; const now = Date.now(); - // Get or create attempt record let record = attempts.get(ip); if (!record || record.resetTime < now) { @@ -138,7 +134,6 @@ export function createLoginRateLimiter() { attempts.set(ip, record); } - // Check if limit exceeded if (record.count >= maxAttempts) { const response: StandardAPIResponse = { success: false, diff --git a/src/webui/server/routes/calibration-routes.ts b/src/webui/server/routes/calibration-routes.ts new file mode 100644 index 0000000..df3d5a5 --- /dev/null +++ b/src/webui/server/routes/calibration-routes.ts @@ -0,0 +1,577 @@ +/** + * @fileoverview Calibration API route registrations for the WebUI server. + * + * Provides headless access to calibration workflows, analysis, history, reports, + * and SSH helpers so the WebUI can mirror the desktop dialog functionality. + */ + +import type { CalibrationSettings, ShaperResult } from '../../../types/calibration'; +import type { Response, Router } from 'express'; +import { getCalibrationManager } from '../../../managers/CalibrationManager'; +import { getPrinterContextManager } from '../../../managers/PrinterContextManager'; +import { getSSHConnectionManager, SCPFileTransfer } from '../../../services/calibration/ssh/index'; +import { getSSHSettingsService } from '../../../services/SSHSettingsService'; +import { toAppError } from '../../../utils/error.utils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; + +/** + * Resolve a context id to the printer's serial number and IP for SSH purposes. + */ +function resolveSshTarget(contextId: string): { serialNumber: string; host: string } | null { + const contextManager = getPrinterContextManager(); + const context = contextManager.getContext(contextId); + if (!context) { + return null; + } + return { + serialNumber: context.printerDetails.SerialNumber, + host: context.printerDetails.IPAddress, + }; +} + +const REPORT_FORMATS = new Set(['json', 'csv', 'png', 'pdf'] as const); +type ReportFormat = 'json' | 'csv' | 'png' | 'pdf'; + +export function registerCalibrationRoutes(router: Router, deps: RouteDependencies): void { + const manager = getCalibrationManager(); + void manager.initialize(); + + const sshManager = getSSHConnectionManager(); + const scpTransfer = new SCPFileTransfer(sshManager); + + router.get('/calibration/settings', async (_req: AuthenticatedRequest, res: Response) => { + try { + const settings = manager.getSettings(); + return res.json({ success: true, settings }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.patch('/calibration/settings', async (req: AuthenticatedRequest, res: Response) => { + try { + const payload = (req.body ?? {}) as Partial; + await manager.updateSettings(payload); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/workspace', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const workspace = manager.getWorkspace(contextResult.contextId) ?? null; + return res.json({ success: true, workspace }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/workspace/load', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { configContent, profileName } = req.body as { configContent?: string; profileName?: string }; + if (!configContent) { + return sendErrorResponse(res, 400, 'configContent is required'); + } + + const workspace = manager.loadMeshFromConfig(contextResult.contextId, configContent, profileName); + if (!workspace) { + return sendErrorResponse(res, 422, 'Unable to parse mesh data from config'); + } + + return res.json({ success: true, workspace }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/profiles', async (req: AuthenticatedRequest, res: Response) => { + try { + const { configContent } = req.body as { configContent?: string }; + if (!configContent) { + return sendErrorResponse(res, 400, 'configContent is required'); + } + + const profiles = manager.getAvailableProfiles(configContent); + return res.json({ success: true, profiles }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/workspace/clear', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + manager.clearWorkspace(contextResult.contextId); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/analysis', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const analysis = manager.analyzeMesh(contextResult.contextId); + if (!analysis) { + return sendErrorResponse(res, 404, 'No mesh data loaded'); + } + + return res.json({ success: true, analysis }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/workflow', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const workflow = manager.computeWorkflow(contextResult.contextId); + if (!workflow) { + return sendErrorResponse(res, 404, 'No mesh data loaded'); + } + + const workspace = manager.getWorkspace(contextResult.contextId); + if (workspace?.meshData && workspace.analysis) { + await manager.saveLastBedMesh(contextResult.contextId, workspace.meshData.matrix, workspace.analysis); + } + + return res.json({ success: true, workflow }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/history', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const history = await manager.getHistory(contextResult.contextId); + return res.json({ success: true, history }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/history', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { type, summary, data } = req.body as { + type?: 'bed_level' | 'input_shaper'; + summary?: string; + data?: unknown; + }; + if (!type || !summary) { + return sendErrorResponse(res, 400, 'type and summary are required'); + } + + await manager.addHistoryEntry(contextResult.contextId, type, summary, data); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.delete('/calibration/history', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + await manager.clearHistory(contextResult.contextId); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/report', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const formatRaw = typeof req.query?.format === 'string' ? req.query.format : 'json'; + const format = REPORT_FORMATS.has(formatRaw as ReportFormat) ? (formatRaw as ReportFormat) : 'json'; + + const report = await manager.exportReport(contextResult.contextId, format); + return sendReportPayload(res, report, format); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/report', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const formatRaw = (req.body as { format?: string }).format ?? 'json'; + const format = REPORT_FORMATS.has(formatRaw as ReportFormat) ? (formatRaw as ReportFormat) : 'json'; + + const report = await manager.exportReport(contextResult.contextId, format); + return sendReportPayload(res, report, format); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/shaper/analyze', async (req: AuthenticatedRequest, res: Response) => { + try { + const { csvContent, axis } = req.body as { csvContent?: string; axis?: 'x' | 'y' }; + if (!csvContent || (axis !== 'x' && axis !== 'y')) { + return sendErrorResponse(res, 400, 'csvContent and axis are required'); + } + + const { ShaperAnalyzer } = await import('../../../services/calibration/shaper/index'); + const analyzer = new ShaperAnalyzer(); + const calibration = analyzer.analyzeAxis(csvContent, axis); + + return res.json({ success: true, calibration }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/shaper/definitions', async (_req: AuthenticatedRequest, res: Response) => { + try { + const { getAllShaperDefinitions } = await import('../../../services/calibration/shaper/index'); + const definitions = getAllShaperDefinitions(); + return res.json({ success: true, definitions }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/shaper/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const { axis, result } = req.body as { axis?: 'x' | 'y'; result?: ShaperResult }; + if (!axis || !result) { + return sendErrorResponse(res, 400, 'axis and result are required'); + } + + const { ShaperAnalyzer } = await import('../../../services/calibration/shaper/index'); + const analyzer = new ShaperAnalyzer(); + const lines = analyzer.generateKlipperConfig(axis, result); + return res.json({ success: true, lines }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/shaper/save', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { axis, result } = req.body as { axis?: 'x' | 'y'; result?: ShaperResult }; + if (!axis || !result) { + return sendErrorResponse(res, 400, 'axis and result are required'); + } + + await manager.saveShaperResult(contextResult.contextId, axis, result); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/ssh/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const data = await manager.getPrinterData(contextResult.contextId); + const target = resolveSshTarget(contextResult.contextId); + if (!target) { + return res.json({ success: true, config: { configPath: data.sshConfigPath } }); + } + + const ssh = await getSSHSettingsService().getSettings(target.serialNumber); + return res.json({ + success: true, + config: { + host: target.host, + port: ssh.port, + username: ssh.username, + keyPath: ssh.keyPath, + isCustom: ssh.isCustom, + configPath: data.sshConfigPath, + }, + }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const config = (req.body ?? {}) as { + port?: number; + username?: string; + password?: string; + keyPath?: string; + configPath?: string; + }; + + const target = resolveSshTarget(contextResult.contextId); + if ( + target && + (config.username !== undefined || + config.password !== undefined || + config.port !== undefined || + config.keyPath !== undefined) + ) { + await getSSHSettingsService().updateSettings(target.serialNumber, { + username: config.username, + password: config.password, + port: config.port, + keyPath: config.keyPath, + }); + } + + if (config.configPath !== undefined) { + const data = await manager.getPrinterData(contextResult.contextId); + await manager.savePrinterData(contextResult.contextId, { + ...data, + sshConfigPath: config.configPath || undefined, + }); + } + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.delete('/calibration/ssh/config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const target = resolveSshTarget(contextResult.contextId); + if (target) { + await getSSHSettingsService().resetSettings(target.serialNumber); + } + + const data = await manager.getPrinterData(contextResult.contextId); + const updated = { + ...data, + sshConfigPath: undefined, + }; + await manager.savePrinterData(contextResult.contextId, updated); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/connect', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const target = resolveSshTarget(contextResult.contextId); + if (!target || !target.host) { + return sendErrorResponse(res, 409, 'No connected printer to resolve SSH settings for'); + } + + const config = await getSSHSettingsService().buildConnectionConfig(target.serialNumber, target.host); + await sshManager.connect(contextResult.contextId, config); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/disconnect', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + await sshManager.disconnect(contextResult.contextId); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/calibration/ssh/status', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const status = sshManager.getStatus(contextResult.contextId); + return res.json({ success: true, status }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/execute', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { command } = req.body as { command?: string }; + if (!command) { + return sendErrorResponse(res, 400, 'command is required'); + } + + const result = await sshManager.executeCommand(contextResult.contextId, command); + return res.json({ success: true, result }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/fetch-config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { remotePath } = req.body as { remotePath?: string }; + const content = await scpTransfer.fetchPrinterConfig(contextResult.contextId, remotePath); + return res.json({ success: true, content }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/fetch-shaper', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { axis } = req.body as { axis?: 'x' | 'y' }; + if (!axis) { + return sendErrorResponse(res, 400, 'axis is required'); + } + + const content = await scpTransfer.fetchShaperCSV(contextResult.contextId, axis); + return res.json({ success: true, content }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/calibration/ssh/upload-config', async (req: AuthenticatedRequest, res: Response) => { + try { + const contextResult = resolveContext(req, deps); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { content, remotePath } = req.body as { content?: string; remotePath?: string }; + if (!content) { + return sendErrorResponse(res, 400, 'content is required'); + } + + const result = await scpTransfer.uploadConfig(contextResult.contextId, content, remotePath); + return res.json({ success: result.success, result }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} + +function sendReportPayload(res: Response, payload: string | Buffer, format: ReportFormat): Response { + if (format === 'png') { + res.type('image/png'); + return res.send(payload); + } + if (format === 'pdf') { + res.type('application/pdf'); + return res.send(payload); + } + + if (typeof payload === 'string') { + res.type(format === 'csv' ? 'text/csv' : 'application/json'); + return res.send(payload); + } + + return res.send(payload); +} diff --git a/src/webui/server/routes/discovery-routes.ts b/src/webui/server/routes/discovery-routes.ts index 0aeac86..941da23 100644 --- a/src/webui/server/routes/discovery-routes.ts +++ b/src/webui/server/routes/discovery-routes.ts @@ -22,7 +22,9 @@ export function registerDiscoveryRoutes(router: Router, _deps: RouteDependencies */ router.post('/discovery/scan', async (req: AuthenticatedRequest, res: Response) => { try { - const body = req.body as { + // Express 5 leaves req.body undefined when the request carries no body, + // so fall back to an empty object and use the per-field defaults below. + const body = (req.body ?? {}) as { timeout?: number; interval?: number; retries?: number; @@ -32,7 +34,6 @@ export function registerDiscoveryRoutes(router: Router, _deps: RouteDependencies const interval = typeof body.interval === 'number' ? body.interval : 2000; const retries = typeof body.retries === 'number' ? body.retries : 3; - // Validate parameters if (timeout < 1000 || timeout > 60000) { return sendErrorResponse(res, 400, 'Timeout must be between 1000 and 60000ms'); } @@ -43,12 +44,10 @@ export function registerDiscoveryRoutes(router: Router, _deps: RouteDependencies return sendErrorResponse(res, 400, 'Retries must be between 1 and 5'); } - // Check if discovery is already running if (discoveryService.isDiscoveryInProgress()) { return sendErrorResponse(res, 409, 'Discovery already in progress'); } - // Start discovery const discoveredPrinters = await discoveryService.scanNetwork(timeout, interval, retries); // Match with saved printers @@ -82,7 +81,7 @@ export function registerDiscoveryRoutes(router: Router, _deps: RouteDependencies */ router.post('/discovery/scan-ip', async (req: AuthenticatedRequest, res: Response) => { try { - const body = req.body as { ipAddress?: string }; + const body = (req.body ?? {}) as { ipAddress?: string }; const ipAddress = body.ipAddress; if (!ipAddress || typeof ipAddress !== 'string') { diff --git a/src/webui/server/routes/file-manager-routes.ts b/src/webui/server/routes/file-manager-routes.ts new file mode 100644 index 0000000..24f0bb5 --- /dev/null +++ b/src/webui/server/routes/file-manager-routes.ts @@ -0,0 +1,187 @@ +/** + * @fileoverview SFTP file manager API routes for the WebUI server. + * + * Mirrors the desktop file manager IPC surface (file-manager-handlers.ts) over + * authenticated REST so browser clients can browse, delete, and rename printer + * files via the main-process FileManagerService. Unlike the desktop dialog + * (which pins the context at window-open time in the main process), REST + * clients pin the context themselves: the WebUI dialog captures the contextId + * when it opens and passes it explicitly on every request, so switching the + * active context mid-session never retargets operations. + * + * Routes: + * - GET /file-manager/support: cheap model-only support check (no SSH probe) + * - GET /file-manager/capabilities: model support + USB probe + * - GET /file-manager/files?storage=&path=: list a storage location + * - POST /file-manager/delete { storage, paths }: batch delete + * - POST /file-manager/rename { storage, path, newName }: rename in place + * - GET /file-manager/thumbnail?storage=&path=: cached/remote thumbnail fetch + */ + +import type { Response, Router } from 'express'; +import type { FileManagerStorageKind } from '../../../types/file-manager'; +import { + FileManagerService, + type FileManagerTarget, + getFileManagerService, +} from '../../../services/FileManagerService'; +import { toAppError } from '../../../utils/error.utils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { + type ResolvedContext, + type RouteDependencies, + resolveContext, + sendErrorResponse, +} from './route-helpers'; + +/** Convert a resolved printer context into a FileManagerService target. */ +function toFileManagerTarget(resolution: ResolvedContext): FileManagerTarget { + const details = resolution.context.printerDetails; + return { + contextId: resolution.contextId, + ipAddress: details.IPAddress, + serialNumber: details.SerialNumber, + printerName: details.Name, + modelType: details.modelType, + }; +} + +/** Validate the storage discriminator from query/body input. */ +function parseStorage(value: unknown): FileManagerStorageKind | null { + return value === 'internal' || value === 'usb' ? value : null; +} + +/** Read a single string value from an Express query parameter. */ +function readString(value: unknown): string | undefined { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value) && typeof value[0] === 'string') { + return value[0]; + } + return undefined; +} + +export function registerFileManagerRoutes(router: Router, deps: RouteDependencies): void { + const service = getFileManagerService(); + + // Model-only support probe: cheap enough to gate topbar buttons on every + // context switch (the full capabilities call opens an SSH session). + router.get('/file-manager/support', (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + const details = resolution.context.printerDetails; + return res.json({ + success: true, + supported: FileManagerService.isModelSupported(details.modelType), + contextId: resolution.contextId, + printerName: details.Name, + }); + }); + + router.get('/file-manager/capabilities', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse<{ success: boolean; error?: string; supported: boolean; usbPresent: boolean; usbMounts: string[] }>(res, resolution.statusCode, resolution.error, { + supported: false, + usbPresent: false, + usbMounts: [], + }); + } + + try { + const capabilities = await service.getCapabilities(toFileManagerTarget(resolution)); + return res.json({ success: true, contextId: resolution.contextId, ...capabilities }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/file-manager/files', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const storage = parseStorage(readString(req.query?.storage)); + if (!storage) { + return sendErrorResponse(res, 400, 'Invalid storage kind'); + } + const path = readString(req.query?.path) ?? ''; + + try { + const listing = await service.listFiles(toFileManagerTarget(resolution), storage, path); + return res.json(listing); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/file-manager/delete', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const body = (req.body ?? {}) as Record; + const storage = parseStorage(body.storage); + const paths = body.paths; + if (!storage || !Array.isArray(paths) || paths.some((path) => typeof path !== 'string')) { + return sendErrorResponse(res, 400, 'Invalid delete request'); + } + + try { + const result = await service.deleteFiles(toFileManagerTarget(resolution), storage, paths as string[]); + return res.json(result); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/file-manager/rename', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const body = (req.body ?? {}) as Record; + const storage = parseStorage(body.storage); + if (!storage || typeof body.path !== 'string' || typeof body.newName !== 'string') { + return sendErrorResponse(res, 400, 'Invalid rename request'); + } + + try { + const result = await service.renameFile(toFileManagerTarget(resolution), storage, body.path, body.newName); + return res.json(result); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.get('/file-manager/thumbnail', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const storage = parseStorage(readString(req.query?.storage)); + const path = readString(req.query?.path); + if (!storage || !path) { + return sendErrorResponse(res, 400, 'Invalid thumbnail request'); + } + + try { + const result = await service.getThumbnail(toFileManagerTarget(resolution), storage, path); + return res.json(result); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} diff --git a/src/webui/server/routes/job-routes.ts b/src/webui/server/routes/job-routes.ts index 4b4c008..8dd7a53 100644 --- a/src/webui/server/routes/job-routes.ts +++ b/src/webui/server/routes/job-routes.ts @@ -4,6 +4,12 @@ import type { Response, Router } from 'express'; import { isAD5XJobInfo } from '../../../printer-backends/ad5x/ad5x-utils'; +import { + clearTrackedJob, + commitStoredFileJob, + prepareStoredFileJob, +} from '../../../services/job-tracking'; +import { getThumbnailCacheService } from '../../../services/ThumbnailCacheService'; import type { AD5XJobInfo, BasicJobInfo } from '../../../types/printer-backend/backend-operations'; import { toAppError } from '../../../utils/error.utils'; import { createValidationError, JobStartRequestSchema } from '../../schemas/web-api.schemas'; @@ -13,6 +19,10 @@ import { type RouteDependencies, resolveContext, sendErrorResponse } from './rou type JobSource = 'local' | 'recent'; +interface ThumbnailAPIResponse extends StandardAPIResponse { + readonly thumbnail?: string | null; +} + export function registerJobRoutes(router: Router, deps: RouteDependencies): void { router.get('/jobs/local', async (req: AuthenticatedRequest, res: Response) => { await handleJobListRequest(req, res, deps, 'local'); @@ -22,6 +32,10 @@ export function registerJobRoutes(router: Router, deps: RouteDependencies): void await handleJobListRequest(req, res, deps, 'recent'); }); + router.get('/jobs/thumbnail', async (req: AuthenticatedRequest, res: Response) => { + await handleThumbnailRequest(req, res, deps); + }); + router.post('/jobs/start', async (req: AuthenticatedRequest, res: Response) => { try { const contextResult = resolveContext(req, deps, { requireBackendReady: true }); @@ -65,6 +79,29 @@ export function registerJobRoutes(router: Router, deps: RouteDependencies): void } } + // Spoolman per-job tracking for a file already on the printer: read the + // printer's per-tool data before the start, while the file list is + // stable. Only jobs with spool choices from the matching dialog qualify. + const contextId = contextResult.contextId; + const isStation = deps.backendManager.isFeatureAvailable(contextId, 'material-station'); + const { spoolAssignments, startNow, filename } = validation.data; + let trackedTools: Awaited> | null = null; + if (isStation && startNow && materialMappings?.length && spoolAssignments?.length) { + try { + trackedTools = await prepareStoredFileJob( + contextId, + filename, + materialMappings, + spoolAssignments + ); + } catch (error) { + console.warn( + '[job-routes] Spoolman job tracking lookup failed (start continues):', + error instanceof Error ? error.message : error + ); + } + } + const result = await deps.backendManager.startJob(contextResult.contextId, { operation: 'start', fileName: validation.data.filename, @@ -74,6 +111,22 @@ export function registerJobRoutes(router: Router, deps: RouteDependencies): void materialMappings && materialMappings.length > 0 ? { materialMappings } : undefined, }); + if (isStation && result.success && startNow) { + try { + clearTrackedJob(contextId); + if (trackedTools && 'tools' in trackedTools) { + commitStoredFileJob(contextId, filename, trackedTools.tools); + } else if (trackedTools) { + console.log(`[job-routes] ${filename} is not Spoolman-tracked (${trackedTools.reason}).`); + } + } catch (error) { + console.warn( + '[job-routes] Spoolman job tracking failed (start continues):', + error instanceof Error ? error.message : error + ); + } + } + const response: StandardAPIResponse = { success: result.success, message: result.success ? `Starting print: ${validation.data.filename}` : undefined, @@ -127,6 +180,67 @@ async function handleJobListRequest( } } +/** + * Serve a single job thumbnail for the lazy-loading file dialog. Mirrors the desktop + * job picker: check the shared per-printer {@link getThumbnailCacheService} first, and on + * a miss fetch from the resolved context's backend and cache the result. Fetching by the + * request's resolved context (not the active one) keeps multi-printer clients correct. + * Returns raw base64 (no data URL prefix); `thumbnail` is null when none is available. + */ +async function handleThumbnailRequest( + req: AuthenticatedRequest, + res: Response, + deps: RouteDependencies +): Promise { + try { + const contextResult = resolveContext(req, deps, { requireBackendReady: true }); + if (!contextResult.success) { + return sendErrorResponse( + res, + contextResult.statusCode, + contextResult.error + ); + } + + const filenameParam = req.query.filename; + const filename = typeof filenameParam === 'string' ? filenameParam : ''; + if (!filename) { + return sendErrorResponse( + res, + 400, + 'filename query parameter is required' + ); + } + + const serialNumber = deps.backendManager.getPrinterDetailsForContext( + contextResult.contextId + )?.SerialNumber; + const cache = getThumbnailCacheService(); + + if (serialNumber) { + const cached = await cache.get(serialNumber, filename); + if (cached.success && cached.data) { + return res.json({ success: true, thumbnail: cached.data } satisfies ThumbnailAPIResponse); + } + } + + const thumbnail = await deps.backendManager.getJobThumbnail(contextResult.contextId, filename); + if (!thumbnail) { + return res.json({ success: true, thumbnail: null } satisfies ThumbnailAPIResponse); + } + + const base64 = thumbnail.replace(/^data:image\/\w+;base64,/, ''); + if (serialNumber) { + await cache.set(serialNumber, filename, base64); + } + + return res.json({ success: true, thumbnail: base64 } satisfies ThumbnailAPIResponse); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } +} + function mapJobInfo(job: AD5XJobInfo | BasicJobInfo) { const base = { fileName: job.fileName, diff --git a/src/webui/server/routes/job-upload-routes.ts b/src/webui/server/routes/job-upload-routes.ts new file mode 100644 index 0000000..3e5a05f --- /dev/null +++ b/src/webui/server/routes/job-upload-routes.ts @@ -0,0 +1,356 @@ +/** + * @fileoverview Job upload routes - the browser-side equivalent of the desktop job uploader. + * + * The desktop dialog picks a file with a native dialog, parses it with + * `@parallel-7/slicer-meta`, and hands the local path to the printer backend. + * A browser has no local path to give, so the flow is split into three calls: + * + * 1. POST /jobs/upload/stage - raw file bytes in, parsed metadata + handle out + * 2. POST /jobs/upload/start - handle + options in, printer upload performed + * 3. POST /jobs/upload/cancel - handle in, scratch file dropped + * + * Staging deliberately happens up front (while the dialog shows "Parsing file") + * so the start call has a real path for `uploadFileAD5X` / `startJob`, exactly + * as the desktop handlers receive one. + * + * Key exports: + * - registerJobUploadRoutes(): mounts the three endpoints on the API router + */ + +import type { AD5XMaterialMapping } from '@ghosttypes/ff-api'; +import { type ParseResult, parseSlicerFile } from '@parallel-7/slicer-meta'; +import express, { type Response, type Router } from 'express'; +import { toAppError } from '../../../utils/error.utils'; +import { + createValidationError, + JobUploadCancelRequestSchema, + JobUploadStartRequestSchema, +} from '../../schemas/web-api.schemas'; +import type { + JobUploadStageResponse, + JobUploadStartResponse, + StandardAPIResponse, + UploadFilamentInfo, + UploadJobMetadata, + UploadSliceWarning, +} from '../../types/web-api.types'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { discardStagedUpload, getStagedUpload, isAllowedJobFileName, stageUpload } from '../upload-staging'; +import { armUploadedJob, clearTrackedJob, toolIdForFilament } from '../../../services/job-tracking'; +import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; + +/** + * Largest job file accepted in one request. Sliced 3MFs with dense thumbnails and + * multi-plate data routinely reach a few hundred megabytes. + */ +const MAX_UPLOAD_BYTES = 512 * 1024 * 1024; + +export function registerJobUploadRoutes(router: Router, deps: RouteDependencies): void { + router.post( + '/jobs/upload/stage', + express.raw({ type: () => true, limit: MAX_UPLOAD_BYTES }), + async (req: AuthenticatedRequest, res: Response) => { + await handleStageRequest(req, res, deps); + } + ); + + router.post('/jobs/upload/start', async (req: AuthenticatedRequest, res: Response) => { + await handleStartRequest(req, res, deps); + }); + + router.post('/jobs/upload/cancel', async (req: AuthenticatedRequest, res: Response) => { + const validation = JobUploadCancelRequestSchema.safeParse(req.body); + if (!validation.success) { + const validationError = createValidationError(validation.error); + return sendErrorResponse(res, 400, validationError.error); + } + + await discardStagedUpload(validation.data.uploadId); + return res.json({ success: true } satisfies StandardAPIResponse); + }); +} + +/** + * Accept the raw file body, write it to scratch storage, and parse its metadata. + * + * A parse failure discards the staged file and reports the parser's message, so + * the browser can render the same "Could not parse file metadata" alert the + * desktop dialog shows. + */ +async function handleStageRequest( + req: AuthenticatedRequest, + res: Response, + deps: RouteDependencies +): Promise { + let uploadId: string | null = null; + + try { + const contextResult = resolveContext(req, deps, { requireBackendReady: true }); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const filenameParam = req.query.filename; + const fileName = typeof filenameParam === 'string' ? filenameParam : ''; + if (!isAllowedJobFileName(fileName)) { + return sendErrorResponse( + res, + 400, + 'filename must be a bare job file name ending in .gcode, .gx or .3mf' + ); + } + + const body = req.body; + if (!Buffer.isBuffer(body) || body.byteLength === 0) { + return sendErrorResponse(res, 400, 'Request body must contain the job file'); + } + + // Material-station printers (AD5X, Creator 5 / 5 Pro) only accept 3MF files, + // which carry the per-tool material data the firmware needs for matching. + // The client blocks this earlier to avoid a pointless transfer; this is the guard. + const hasMaterialStation = deps.backendManager.isFeatureAvailable(contextResult.contextId, 'material-station'); + const is3MF = fileName.toLowerCase().endsWith('.3mf'); + if (hasMaterialStation && !is3MF) { + return sendErrorResponse(res, 400, 'This printer only supports 3MF files.'); + } + + const staged = await stageUpload(fileName, body); + uploadId = staged.id; + + const parsed = await parseSlicerFile(staged.filePath); + + return res.json({ + success: true, + uploadId: staged.id, + fileName: staged.fileName, + metadata: normalizeMetadata(parsed), + requiresMaterialMatching: hasMaterialStation && is3MF, + } satisfies JobUploadStageResponse); + } catch (error) { + if (uploadId) { + await discardStagedUpload(uploadId); + } + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } +} + +/** + * Send a staged file to the printer. + * + * Mirrors the desktop split: material-station printers take the material-aware + * 3MF path (`uploadFileAD5X`), everything else goes through `startJob` with a + * `filePath`, which uploads and optionally starts the job. + */ +async function handleStartRequest( + req: AuthenticatedRequest, + res: Response, + deps: RouteDependencies +): Promise { + try { + const contextResult = resolveContext(req, deps, { requireBackendReady: true }); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const validation = JobUploadStartRequestSchema.safeParse(req.body); + if (!validation.success) { + const validationError = createValidationError(validation.error); + return sendErrorResponse(res, 400, validationError.error); + } + + const { uploadId, startNow, autoLevel, materialMappings, spoolAssignments } = validation.data; + const staged = getStagedUpload(uploadId); + if (!staged) { + return sendErrorResponse( + res, + 410, + 'The staged file is no longer available. Select the file again.' + ); + } + + const mappingError = materialMappings ? findDuplicateMappingError(materialMappings) : null; + if (mappingError) { + return sendErrorResponse(res, 400, mappingError); + } + + const hasMaterialStation = deps.backendManager.isFeatureAvailable(contextResult.contextId, 'material-station'); + const is3MF = staged.fileName.toLowerCase().endsWith('.3mf'); + + try { + if (hasMaterialStation && !is3MF) { + return sendErrorResponse(res, 400, 'This printer only supports 3MF files.'); + } + + const result = hasMaterialStation + ? await deps.backendManager.uploadFileAD5X( + contextResult.contextId, + staged.filePath, + startNow, + autoLevel, + materialMappings as AD5XMaterialMapping[] | undefined + ) + : await deps.backendManager.startJob(contextResult.contextId, { + operation: 'start', + filePath: staged.filePath, + fileName: staged.fileName, + leveling: autoLevel, + startNow, + }); + + // Spoolman per-job tracking: a job the app starts replaces any earlier + // tracked job, and is tracked itself when the user chose spools for it. + // Best effort only - a tracking failure must never fail the upload. + if (hasMaterialStation && result.success && startNow) { + try { + clearTrackedJob(contextResult.contextId); + if (materialMappings?.length) { + const armed = await armUploadedJob(contextResult.contextId, { + fileName: result.fileName || staged.fileName, + filePath: staged.filePath, + parsed: await parseSlicerFile(staged.filePath), + mappings: materialMappings, + spoolAssignments, + }); + if (!armed.armed) { + console.log( + `[job-upload-routes] ${staged.fileName} is not Spoolman-tracked (${armed.reason}).` + ); + } + } + } catch (trackingError) { + console.warn( + '[job-upload-routes] Spoolman job tracking failed (upload continues):', + trackingError instanceof Error ? trackingError.message : trackingError + ); + } + } + + const response: JobUploadStartResponse = { + success: result.success, + fileName: result.fileName || staged.fileName, + started: result.started, + message: result.success ? `Uploaded ${result.fileName || staged.fileName}` : undefined, + error: result.error, + }; + return res.status(result.success ? 200 : 500).json(response); + } finally { + // The backend has read the file by now either way; keeping it around only + // risks a stale replay against a different printer context. + await discardStagedUpload(uploadId); + } + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } +} + +/** Reject mappings that reuse a tool or a slot, matching the /jobs/start guard. */ +function findDuplicateMappingError(mappings: readonly { toolId: number; slotId: number }[]): string | null { + const toolIds = new Set(); + const slotIds = new Set(); + + for (const mapping of mappings) { + if (toolIds.has(mapping.toolId)) { + return `Duplicate toolId in materialMappings: ${mapping.toolId}`; + } + if (slotIds.has(mapping.slotId)) { + return `Duplicate slotId in materialMappings: ${mapping.slotId}`; + } + toolIds.add(mapping.toolId); + slotIds.add(mapping.slotId); + } + + return null; +} + +/** + * Collapse a slicer-meta ParseResult into the flat shape the WebUI renders. + * + * Every fallback chain below is copied from the desktop job uploader's + * `populateMetadata`, so both surfaces show the same value for the same file. + */ +function normalizeMetadata(parsed: ParseResult): UploadJobMetadata { + const file = parsed.file ?? null; + const threeMf = parsed.threeMf ?? null; + const slicer = parsed.slicer ?? null; + + const filamentLengthM = resolveFilamentLengthM(parsed); + + return { + printerModel: file?.printerModel || threeMf?.printerModelId || null, + filamentType: file?.filamentType || threeMf?.filaments?.[0]?.type || null, + filamentLengthM, + // The desktop only renders a weight when it also has a length, so resolving + // one without the other would surface a value the desktop hides. + filamentWeightG: filamentLengthM === null ? null : resolveFilamentWeightG(parsed), + supportUsed: threeMf ? threeMf.supportUsed : null, + slicerName: slicer?.slicerName || null, + slicerVersion: slicer?.slicerVersion || null, + sliceDate: slicer?.sliceDate || null, + sliceTime: slicer?.sliceTime || null, + printEta: slicer?.printEta || null, + layerHeight: file?.layerHeight ?? null, + infillDensity: file?.infillDensity ?? null, + layerCount: file?.layerCount ?? null, + firstLayerTime: threeMf?.firstLayerTime ?? null, + warnings: (threeMf?.warnings ?? []).map( + (warning): UploadSliceWarning => ({ + level: warning.level, + message: warning.message || warning.msg, + }) + ), + thumbnail: normalizeThumbnail(threeMf?.plateImage || file?.thumbnail || null), + filaments: (threeMf?.filaments ?? file?.filaments ?? []).map( + (filament, index): UploadFilamentInfo => ({ + toolId: toolIdForFilament(filament, index), + type: filament.type ?? null, + color: filament.color ?? null, + usedM: filament.usedM ?? null, + usedG: filament.usedG ?? null, + }) + ), + }; +} + +function resolveFilamentLengthM(parsed: ParseResult): number | null { + const fromFile = parseNumeric(parsed.file?.filaments?.[0]?.usedM); + if (fromFile !== null) { + return fromFile; + } + const fromThreeMf = parseNumeric(parsed.threeMf?.filaments?.[0]?.usedM); + if (fromThreeMf !== null) { + return fromThreeMf; + } + if (parsed.file?.filamentUsedMM) { + return parsed.file.filamentUsedMM / 1000; + } + return null; +} + +function resolveFilamentWeightG(parsed: ParseResult): number | null { + if (parsed.file?.filamentUsedG) { + return parsed.file.filamentUsedG; + } + const fromThreeMf = parseNumeric(parsed.threeMf?.filaments?.[0]?.usedG); + if (fromThreeMf !== null) { + return fromThreeMf; + } + return parseNumeric(parsed.file?.filaments?.[0]?.usedG); +} + +function parseNumeric(value: string | null | undefined): number | null { + if (!value) { + return null; + } + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** Strip any data URL prefix so the client can apply one consistently. */ +function normalizeThumbnail(thumbnail: string | null): string | null { + if (!thumbnail) { + return null; + } + return thumbnail.replace(/^data:image\/\w+;base64,/, ''); +} diff --git a/src/webui/server/routes/printer-detection-routes.ts b/src/webui/server/routes/printer-detection-routes.ts index 837adff..6df45c7 100644 --- a/src/webui/server/routes/printer-detection-routes.ts +++ b/src/webui/server/routes/printer-detection-routes.ts @@ -30,10 +30,11 @@ export function registerPrinterDetectionRoutes(router: Router, _deps: RouteDepen ipAddress?: string; commandPort?: number; httpPort?: number; + productId?: number; + serialNumber?: string; }; const ipAddress = body.ipAddress; - // Validate IP address if (!ipAddress || typeof ipAddress !== 'string') { return sendErrorResponse(res, 400, 'IP address is required'); } @@ -47,17 +48,21 @@ export function registerPrinterDetectionRoutes(router: Router, _deps: RouteDepen console.log(`[Detection] Probing printer at ${ipAddress}...`); - // Create mock discovered printer for temporary connection + // Create mock discovered printer for temporary connection. The optional + // productId (from UDP discovery or the manual "Creator 5" selection) lets + // createTemporaryConnection short-circuit the legacy TCP probe for + // HTTP-only models — the Creator 5 series has no TCP server on 8899, so + // probing it would only time out. const mockPrinter: DiscoveredPrinter = { name: `Printer at ${ipAddress}`, ipAddress, - serialNumber: '', // Will be determined during connection + serialNumber: typeof body.serialNumber === 'string' ? body.serialNumber.trim() : '', model: undefined, commandPort: typeof body.commandPort === 'number' ? body.commandPort : undefined, eventPort: typeof body.httpPort === 'number' ? body.httpPort : undefined, + productId: typeof body.productId === 'number' ? body.productId : undefined, }; - // Create temporary connection to probe the printer const tempResult = await connectionService.createTemporaryConnection(mockPrinter); if (!tempResult.success || !tempResult.typeName) { @@ -65,7 +70,6 @@ export function registerPrinterDetectionRoutes(router: Router, _deps: RouteDepen return sendErrorResponse(res, 500, tempResult.error || 'Failed to detect printer type'); } - // Extract printer information const typeName = tempResult.typeName; const serialNumber = tempResult.printerInfo?.SerialNumber && @@ -77,7 +81,6 @@ export function registerPrinterDetectionRoutes(router: Router, _deps: RouteDepen ? tempResult.printerInfo.Name : `Printer at ${ipAddress}`; - // Detect printer family and determine requirements const familyInfo = detectPrinterFamily(typeName); const clientType = determineClientType(familyInfo.is5MFamily); diff --git a/src/webui/server/routes/printer-management-routes.ts b/src/webui/server/routes/printer-management-routes.ts index 55c566e..3d5f211 100644 --- a/src/webui/server/routes/printer-management-routes.ts +++ b/src/webui/server/routes/printer-management-routes.ts @@ -7,6 +7,7 @@ import type { Response, Router } from 'express'; import { getSavedPrinterService } from '../../../services/SavedPrinterService'; import type { PrinterClientType } from '../../../types/printer'; import { toAppError } from '../../../utils/error.utils'; +import { detectPrinterModelTypeFromId, isHttpOnlyModel } from '../../../utils/PrinterUtils'; import type { StandardAPIResponse } from '../../types/web-api.types'; import type { AuthenticatedRequest } from '../auth-middleware'; import type { RouteDependencies } from './route-helpers'; @@ -30,6 +31,7 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen checkCode?: string; commandPort?: number; httpPort?: number; + productId?: number; }; const ipAddress = body.ipAddress; @@ -37,8 +39,8 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen const checkCode = body.checkCode; const commandPort = body.commandPort; const httpPort = body.httpPort; + const productId = body.productId; - // Validate required fields if (!ipAddress || typeof ipAddress !== 'string') { return sendErrorResponse(res, 400, 'IP address is required'); } @@ -47,21 +49,43 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen return sendErrorResponse(res, 400, 'Type must be "new" or "legacy"'); } - // Validate check code for new printers if (type === 'new' && (!checkCode || typeof checkCode !== 'string')) { - return sendErrorResponse(res, 400, 'Check code is required for 5M/Pro printers'); + return sendErrorResponse(res, 400, 'Check code is required for modern printers'); + } + + const serialNumber = + typeof body.serialNumber === 'string' && body.serialNumber.trim() !== '' + ? body.serialNumber.trim() + : undefined; + + // Modern printers authenticate with serial + check code, and a manual connect + // that names a model skips the legacy TCP probe — so the serial can no longer + // be recovered by probing and must be supplied by the caller. The Creator 5 + // series never had a probe to fall back on in the first place, so it keeps a + // model-specific message. + if (type === 'new' && !serialNumber) { + const isCreator = + typeof productId === 'number' && + isHttpOnlyModel(detectPrinterModelTypeFromId(productId, '')); + return sendErrorResponse( + res, + 400, + isCreator + ? 'Serial number is required for Creator 5 series printers' + : 'Serial number is required for modern printers' + ); } - // Build printer spec const spec = { ip: ipAddress, type: type as PrinterClientType, checkCode: type === 'new' ? checkCode : undefined, commandPort: typeof commandPort === 'number' ? commandPort : undefined, httpPort: typeof httpPort === 'number' ? httpPort : undefined, + productId: typeof productId === 'number' ? productId : undefined, + serialNumber, }; - // Connect via ConnectionFlowManager console.log('[API] Connecting to printer:', spec); const results = await deps.connectionManager.connectHeadlessDirect([spec]); @@ -106,13 +130,11 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen return sendErrorResponse(res, 400, 'Context ID is required'); } - // Verify context exists const context = deps.contextManager.getContext(contextId); if (!context) { return sendErrorResponse(res, 404, `Context ${contextId} not found`); } - // Disconnect via ConnectionFlowManager console.log('[API] Disconnecting printer:', context.printerDetails.Name); await deps.connectionManager.disconnectContext(contextId); @@ -160,7 +182,6 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen return sendErrorResponse(res, 400, 'Serial number is required'); } - // Check if printer is currently connected const contexts = deps.contextManager.getAllContexts(); const connectedContext = contexts.find( (ctx) => ctx.printerDetails.SerialNumber === serialNumber @@ -174,7 +195,6 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen ); } - // Delete from saved printers await savedPrinterService.removePrinter(serialNumber); return res.json({ @@ -208,7 +228,6 @@ export function registerPrinterManagementRoutes(router: Router, deps: RouteDepen return sendErrorResponse(res, 404, 'Saved printer not found'); } - // Connect using saved details console.log('[API] Reconnecting to saved printer:', savedPrinter.Name); const results = await deps.connectionManager.connectHeadlessFromSaved([savedPrinter]); diff --git a/src/webui/server/routes/printer-power-routes.ts b/src/webui/server/routes/printer-power-routes.ts new file mode 100644 index 0000000..688976c --- /dev/null +++ b/src/webui/server/routes/printer-power-routes.ts @@ -0,0 +1,52 @@ +/** + * @fileoverview Printer power API routes for the WebUI server. + * + * Exposes the remote "Reboot Printer" feature to WebUI clients. The route + * delegates to the shared startPrinterReboot() core (same code path as the + * desktop IPC handler), so validation, the SSH dispatch, and the reconnect + * monitor behave identically on both surfaces. Reboot lifecycle updates reach + * the browser over the REBOOT_STATUS WebSocket broadcast; this route only + * acknowledges that the reboot was dispatched. + * + * Routes: + * - POST /printer/reboot: dispatch a reboot for the resolved context + * - GET /printer/reboot/support: whether the resolved context supports reboot + */ + +import type { Response, Router } from 'express'; +import { startPrinterReboot } from '../../../services/PrinterRebootService'; +import { toAppError } from '../../../utils/error.utils'; +import { isRebootSupportedModel } from '../../../utils/PrinterUtils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; + +export function registerPrinterPowerRoutes(router: Router, deps: RouteDependencies): void { + router.get('/printer/reboot/support', (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + const modelType = resolution.context.printerDetails.modelType; + return res.json({ + success: true, + supported: isRebootSupportedModel(modelType), + contextId: resolution.contextId, + printerName: resolution.context.printerDetails.Name, + }); + }); + + router.post('/printer/reboot', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + try { + await startPrinterReboot(resolution.contextId); + return res.json({ success: true, contextId: resolution.contextId }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} diff --git a/src/webui/server/routes/printer-status-routes.ts b/src/webui/server/routes/printer-status-routes.ts index eb11c30..32620d7 100644 --- a/src/webui/server/routes/printer-status-routes.ts +++ b/src/webui/server/routes/printer-status-routes.ts @@ -45,6 +45,7 @@ interface ExtendedPrinterStatus { readonly cumulativeFilament?: number; readonly cumulativePrintTime?: number; readonly printEta?: string; + readonly completionTime?: Date | string | null; // Creator 5 series (multi-tool) fields surfaced by Creator5Backend.getAdditionalStatusFields. // Raw ff-api Temperature entries use `set` (not `target`) for the target reading. readonly toolTemps?: ReadonlyArray<{ readonly current: number; readonly set: number }>; @@ -86,6 +87,7 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc let timeElapsed: number | undefined; let elapsedTimeSeconds: number | undefined; let formattedEta: string | undefined; + let completionTime: string | null = null; let cumulativeFilament: number | undefined; let cumulativePrintTime: number | undefined; let toolTemps: Array<{ current: number; target: number }> | undefined; @@ -117,6 +119,13 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc elapsedTimeSeconds = statusResult.status.printDuration; formattedEta = statusResult.status.printEta; + const rawCompletion = statusResult.status.completionTime; + if (rawCompletion instanceof Date) { + completionTime = rawCompletion.toISOString(); + } else if (typeof rawCompletion === 'string') { + completionTime = rawCompletion; + } + if ('cumulativeFilament' in statusResult.status) { cumulativeFilament = statusResult.status.cumulativeFilament as number; } @@ -160,6 +169,7 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc cumulativeFilament, cumulativePrintTime, formattedEta, + completionTime, elapsedTimeSeconds, toolTemps, chamberTemperature, @@ -212,6 +222,7 @@ export function registerPrinterStatusRoutes(router: Router, deps: RouteDependenc features.ledControl.customControlEnabled || features.ledControl.usesLegacyAPI, hasMultiTool, isCreator5Pro, + gcodeCommands: features.gcodeCommands, }; return res.json({ diff --git a/src/webui/server/routes/route-helpers.ts b/src/webui/server/routes/route-helpers.ts index 75b2580..42929c7 100644 --- a/src/webui/server/routes/route-helpers.ts +++ b/src/webui/server/routes/route-helpers.ts @@ -16,6 +16,7 @@ import type { PrinterContextManager, } from '../../../managers/PrinterContextManager'; import type { BasePrinterBackend } from '../../../printer-backends/BasePrinterBackend'; +import type { MultiContextSpoolmanTracker } from '../../../services/MultiContextSpoolmanTracker'; import type { SpoolmanIntegrationService } from '../../../services/SpoolmanIntegrationService'; import type { AuthenticatedRequest } from '../auth-middleware'; @@ -28,6 +29,7 @@ export interface RouteDependencies { readonly contextManager: PrinterContextManager; readonly configManager: ConfigManager; readonly spoolmanService: SpoolmanIntegrationService; + readonly spoolmanTracker: MultiContextSpoolmanTracker; } /** diff --git a/src/webui/server/routes/spoolman-routes.ts b/src/webui/server/routes/spoolman-routes.ts index 75451fc..1b30fbc 100644 --- a/src/webui/server/routes/spoolman-routes.ts +++ b/src/webui/server/routes/spoolman-routes.ts @@ -1,5 +1,6 @@ /** - * @fileoverview Spoolman integration routes (config, search, active spool management). + * @fileoverview Spoolman integration routes (config, search, active spool + * management, per-job tracking view for material-station printers). */ import { FiveMClient } from '@ghosttypes/ff-api'; @@ -15,6 +16,7 @@ import type { ActiveSpoolResponse, SlotConfigResponse, SpoolmanConfigResponse, + SpoolmanStationTracking, SpoolSearchResponse, SpoolSelectResponse, SpoolSummary, @@ -48,6 +50,7 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies): serverUrl: deps.spoolmanService.getServerUrl(), updateMode: deps.spoolmanService.getUpdateMode(), contextId: activeContextId, + station: buildStationTracking(deps, activeContextId), }; return res.json(response); } catch (error) { @@ -125,7 +128,7 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies): return sendErrorResponse( res, 409, - 'Spoolman integration is disabled for this printer (AD5X with material station)', + 'Spoolman integration is not available for this printer', { spool: null } ); } @@ -165,7 +168,7 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies): return sendErrorResponse( res, 409, - 'Spoolman integration is disabled for this printer (AD5X with material station)' + 'Spoolman integration is not available for this printer' ); } @@ -206,7 +209,7 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies): return sendErrorResponse( res, 409, - 'Spoolman integration is disabled for this printer (AD5X with material station)' + 'Spoolman integration is not available for this printer' ); } @@ -229,7 +232,7 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies): return sendErrorResponse(res, 400, validationError.error); } - const { contextId, slot, materialName, colorHex, currentMaterial } = validation.data; + const { contextId, slot, materialName, colorHex } = validation.data; const overrideContextId = contextId || null; const contextResult = resolveContext(req, deps, { @@ -258,16 +261,9 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies): ); } - // Resolve the material to write: the client snaps the spool material to the - // fixed palette; when it does not resolve, keep the slot's current material. - const materialToWrite = materialName ?? currentMaterial ?? null; - if (!materialToWrite) { - return sendErrorResponse( - res, - 400, - 'Spool material did not match a known material and the slot has no current material to keep' - ); - } + // The client snaps the spool material to the model's fixed palette and always + // sends a resolved material, so `materialName` is the value to write. + const materialToWrite = materialName; const primaryClient = backend.getPrimaryClient(); if (!(primaryClient instanceof FiveMClient)) { @@ -294,3 +290,44 @@ export function registerSpoolmanRoutes(router: Router, deps: RouteDependencies): } }); } + +/** Copy shown in the Spoolman panel for station contexts. */ +const STATION_TRACKING_NOTE = + 'Choose a spool for each tool when you match materials for an upload. ' + + 'The slicer estimate is charged to those spools when the print ends. ' + + 'Each spool choice applies to that one print only. ' + + 'Prints started on the printer, or files sent without Start Now, are not tracked.'; + +/** + * Assemble per-job tracking info for a context, or null when the context has + * no material station. + */ +function buildStationTracking( + deps: RouteDependencies, + contextId: string +): SpoolmanStationTracking | null { + if (!deps.spoolmanService.isStationContext(contextId)) { + return null; + } + const job = deps.spoolmanTracker.getTrackedJob(contextId); + return { + supported: true, + note: STATION_TRACKING_NOTE, + activeJob: job + ? { + fileName: job.fileName, + started: job.startedAt !== null, + lastProgress: job.lastProgress, + hasUsageProfile: job.usageProfile !== null, + tools: job.tools.map((tool) => ({ + toolId: tool.toolId, + slotId: tool.slotId, + spoolId: tool.spoolId, + usedG: tool.usedG, + usedM: tool.usedM, + })), + } + : null, + lastDeduction: deps.spoolmanTracker.getLastDeduction(contextId), + }; +} diff --git a/src/webui/server/routes/ssh-settings-routes.ts b/src/webui/server/routes/ssh-settings-routes.ts new file mode 100644 index 0000000..64565a0 --- /dev/null +++ b/src/webui/server/routes/ssh-settings-routes.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Centralized per-printer SSH settings API routes for the WebUI server. + * + * Mirrors the desktop Settings -> SSH tab over authenticated REST so browser + * clients can view and edit the credentials consumed by every SSH feature + * (file manager, calibration assistant, reboot). Credentials are stored per + * printer serial via SSHSettingsService. + * + * SECURITY: the stored password is WRITE-ONLY over this surface. GET responses + * never include the password — only whether a custom (non easy-SSH default) + * password is set. Submitting a new password overwrites it; submitting an + * empty string resets it to the easy-SSH default; omitting the field keeps + * the current value. + * + * Routes: + * - GET /ssh-settings: resolved settings (password redacted) for the context + * - POST /ssh-settings: apply a partial update + * - POST /ssh-settings/reset: restore the easy-SSH defaults + */ + +import type { Response, Router } from 'express'; +import { SSH_DEFAULTS, type SSHSettingsUpdate } from '../../../types/ssh-settings'; +import { getSSHSettingsService } from '../../../services/SSHSettingsService'; +import { toAppError } from '../../../utils/error.utils'; +import type { AuthenticatedRequest } from '../auth-middleware'; +import { type RouteDependencies, resolveContext, sendErrorResponse } from './route-helpers'; + +/** SSH settings shape returned to WebUI clients (password redacted). */ +export interface WebUISSHSettings { + readonly username: string; + readonly port: number; + readonly keyPath?: string; + readonly isCustom: boolean; + /** True when a non-default password is stored (the value itself is never sent). */ + readonly passwordIsCustom: boolean; +} + +/** Coerce and validate the incoming update payload; null when malformed. */ +function parseUpdatePayload(body: unknown): SSHSettingsUpdate | null { + if (!body || typeof body !== 'object') { + return null; + } + const raw = body as Record; + const update: { + username?: string; + password?: string; + port?: number; + keyPath?: string; + } = {}; + + if (raw.username !== undefined) { + if (typeof raw.username !== 'string') return null; + update.username = raw.username; + } + if (raw.password !== undefined) { + if (typeof raw.password !== 'string') return null; + update.password = raw.password; + } + if (raw.port !== undefined) { + const port = typeof raw.port === 'number' ? raw.port : Number(raw.port); + if (!Number.isInteger(port) || port < 0 || port > 65535) return null; + update.port = port; + } + if (raw.keyPath !== undefined) { + if (typeof raw.keyPath !== 'string') return null; + update.keyPath = raw.keyPath; + } + return update; +} + +export function registerSSHSettingsRoutes(router: Router, deps: RouteDependencies): void { + router.get('/ssh-settings', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + try { + const details = resolution.context.printerDetails; + const settings = await getSSHSettingsService().getSettings(details.SerialNumber); + const redacted: WebUISSHSettings = { + username: settings.username, + port: settings.port, + keyPath: settings.keyPath, + isCustom: settings.isCustom, + passwordIsCustom: settings.password !== SSH_DEFAULTS.password, + }; + return res.json({ + success: true, + settings: redacted, + printerName: details.Name, + contextId: resolution.contextId, + }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/ssh-settings', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + const update = parseUpdatePayload(req.body); + if (!update) { + return sendErrorResponse(res, 400, 'Invalid SSH settings payload'); + } + + try { + await getSSHSettingsService().updateSettings(resolution.context.printerDetails.SerialNumber, update); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); + + router.post('/ssh-settings/reset', async (req: AuthenticatedRequest, res: Response) => { + const resolution = resolveContext(req, deps); + if (!resolution.success) { + return sendErrorResponse(res, resolution.statusCode, resolution.error); + } + + try { + await getSSHSettingsService().resetSettings(resolution.context.printerDetails.SerialNumber); + return res.json({ success: true }); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } + }); +} diff --git a/src/webui/server/routes/temperature-routes.test.ts b/src/webui/server/routes/temperature-routes.test.ts new file mode 100644 index 0000000..3fd50d7 --- /dev/null +++ b/src/webui/server/routes/temperature-routes.test.ts @@ -0,0 +1,247 @@ +/** + * @fileoverview Jest coverage for WebUI bed/extruder temperature route handlers. + * + * Asserts the single-tool heater routes delegate to backend-manager temperature + * commands (which route HTTP-only printers such as the Creator 5 series onto + * the HTTP temperature-control API) instead of raw G-code, along with request + * validation, multi-context routing, and error responses. + */ + +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import express from 'express'; +import type { CommandResult } from '../../../types/printer-backend'; +import { startTestServer } from './test-server'; +import { registerTemperatureRoutes } from './temperature-routes'; + +function okResult(): CommandResult { + return { success: true, timestamp: new Date() }; +} + +function createDependencies() { + return { + backendManager: { + isBackendReady: jest.fn<(contextId: string) => boolean>(() => true), + setBedTemperature: jest.fn< + (contextId: string, temperature: number) => Promise + >(async () => okResult()), + cancelBedTemperature: jest.fn<(contextId: string) => Promise>(async () => + okResult() + ), + setExtruderTemperature: jest.fn< + (contextId: string, temperature: number) => Promise + >(async () => okResult()), + cancelExtruderTemperature: jest.fn<(contextId: string) => Promise>(async () => + okResult() + ), + executeGCodeCommand: jest.fn<(contextId: string, command: string) => Promise>(), + }, + contextManager: { + getActiveContextId: jest.fn<() => string | null>(() => 'context-1'), + getContext: jest.fn<(contextId: string) => unknown>((contextId) => + contextId === 'context-1' || contextId === 'context-2' + ? { id: contextId, printerDetails: {} } + : undefined + ), + }, + connectionManager: {}, + configManager: {}, + spoolmanService: {}, + // biome-ignore lint/suspicious/noExplicitAny: test double for RouteDependencies + } as any; +} + +interface TestDeps { + // biome-ignore lint/suspicious/noExplicitAny: test double for RouteDependencies + backendManager: any; + // biome-ignore lint/suspicious/noExplicitAny: test double for RouteDependencies + contextManager: any; +} + +async function startTemperatureServer(deps: TestDeps) { + return await startTestServer((app) => { + const router = express.Router(); + registerTemperatureRoutes(router, deps as never); + app.use('/api', router); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('temperature-routes (bed/extruder heaters)', () => { + it('sets the bed temperature through the backend manager, never raw G-code', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 60 }), + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + message: 'Setting bed temperature to 60°C', + error: undefined, + }); + expect(deps.backendManager.setBedTemperature).toHaveBeenCalledTimes(1); + expect(deps.backendManager.setBedTemperature).toHaveBeenCalledWith('context-1', 60); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('turns the bed heater off through the backend manager cancel command', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed/off`, { + method: 'POST', + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + message: 'Bed heating turned off', + error: undefined, + }); + expect(deps.backendManager.cancelBedTemperature).toHaveBeenCalledWith('context-1'); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('sets the extruder temperature (rounded) through the backend manager', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/extruder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 210.4 }), + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(200); + expect(body).toEqual({ + success: true, + message: 'Setting extruder temperature to 210°C', + error: undefined, + }); + expect(deps.backendManager.setExtruderTemperature).toHaveBeenCalledWith('context-1', 210); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('turns the extruder heater off through the backend manager cancel command', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/extruder/off`, { + method: 'POST', + }); + await server.close(); + + expect(response.status).toBe(200); + expect(deps.backendManager.cancelExtruderTemperature).toHaveBeenCalledWith('context-1'); + expect(deps.backendManager.executeGCodeCommand).not.toHaveBeenCalled(); + }); + + it('routes heater commands to an explicit contextId override', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch( + `${server.baseUrl}/api/printer/temperature/bed?contextId=context-2`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 55 }), + } + ); + await server.close(); + + expect(response.status).toBe(200); + expect(deps.backendManager.setBedTemperature).toHaveBeenCalledWith('context-2', 55); + }); + + it('rejects out-of-range temperatures with a 400 validation error', async () => { + const deps = createDependencies(); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 500 }), + }); + const body = (await response.json()) as { success: boolean; error: string }; + await server.close(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(typeof body.error).toBe('string'); + expect(deps.backendManager.setBedTemperature).not.toHaveBeenCalled(); + }); + + it('returns 500 when the backend manager reports a failed command', async () => { + const deps = createDependencies(); + deps.backendManager.setBedTemperature.mockResolvedValueOnce({ + success: false, + error: 'Temperature command failed', + timestamp: new Date(), + }); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 60 }), + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + success: false, + message: undefined, + error: 'Temperature command failed', + }); + }); + + it('returns 503 when no printer context is active', async () => { + const deps = createDependencies(); + deps.contextManager.getActiveContextId.mockReturnValue(null); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/bed/off`, { + method: 'POST', + }); + const body = await response.json(); + await server.close(); + + expect(response.status).toBe(503); + expect(body).toEqual({ + success: false, + error: 'No active printer context', + }); + expect(deps.backendManager.cancelBedTemperature).not.toHaveBeenCalled(); + }); + + it('returns 503 when the printer backend is not ready', async () => { + const deps = createDependencies(); + deps.backendManager.isBackendReady.mockReturnValue(false); + const server = await startTemperatureServer(deps); + + const response = await fetch(`${server.baseUrl}/api/printer/temperature/extruder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ temperature: 200 }), + }); + await server.close(); + + expect(response.status).toBe(503); + expect(deps.backendManager.setExtruderTemperature).not.toHaveBeenCalled(); + }); +}); diff --git a/src/webui/server/routes/temperature-routes.ts b/src/webui/server/routes/temperature-routes.ts index f2c6bf8..0d31f9c 100644 --- a/src/webui/server/routes/temperature-routes.ts +++ b/src/webui/server/routes/temperature-routes.ts @@ -4,6 +4,7 @@ import { FiveMClient } from '@ghosttypes/ff-api'; import type { Response, Router } from 'express'; +import type { CommandResult } from '../../../types/printer-backend'; import { toAppError } from '../../../utils/error.utils'; import { createValidationError, TemperatureSetRequestSchema } from '../../schemas/web-api.schemas'; import type { StandardAPIResponse } from '../../types/web-api.types'; @@ -17,88 +18,42 @@ const CHAMBER_MAX_TEMP = 80; const MAX_TOOL_COUNT = 4; export function registerTemperatureRoutes(router: Router, deps: RouteDependencies): void { - router.post('/printer/temperature/bed', async (req: AuthenticatedRequest, res: Response) => { - try { - const contextResult = resolveContext(req, deps, { requireBackendReady: true }); - if (!contextResult.success) { - return sendErrorResponse( - res, - contextResult.statusCode, - contextResult.error - ); - } - - const validation = TemperatureSetRequestSchema.safeParse(req.body); - if (!validation.success) { - const validationError = createValidationError(validation.error); - return sendErrorResponse(res, 400, validationError.error); - } - - const temperature = Math.round(validation.data.temperature); - const result = await deps.backendManager.executeGCodeCommand( - contextResult.contextId, - `~M140 S${temperature}` - ); + // ----- Single-tool bed / extruder heaters ----------------------------------- + // These route through backend-manager temperature commands rather than raw + // G-code: dual-API printers keep using the legacy TCP channel, while + // HTTP-only printers (Creator 5 series, which have no TCP channel) use the + // HTTP temperature-control API. - const response: StandardAPIResponse = { - success: result.success, - message: result.success ? `Setting bed temperature to ${temperature}°C` : undefined, - error: result.error, - }; - return res.status(result.success ? 200 : 500).json(response); - } catch (error) { - const appError = toAppError(error); - return sendErrorResponse(res, 500, appError.message); - } + router.post('/printer/temperature/bed', async (req: AuthenticatedRequest, res: Response) => { + return handleSetTemperature(req, res, deps, 'bed', (contextId, temperature) => + deps.backendManager.setBedTemperature(contextId, temperature) + ); }); router.post('/printer/temperature/bed/off', async (req: AuthenticatedRequest, res: Response) => { - await handleSimpleTemperatureCommand(req, res, deps, '~M140 S0', 'Bed heating turned off'); + return handleTemperatureCommand( + req, + res, + deps, + (contextId) => deps.backendManager.cancelBedTemperature(contextId), + 'Bed heating turned off' + ); }); router.post('/printer/temperature/extruder', async (req: AuthenticatedRequest, res: Response) => { - try { - const contextResult = resolveContext(req, deps, { requireBackendReady: true }); - if (!contextResult.success) { - return sendErrorResponse( - res, - contextResult.statusCode, - contextResult.error - ); - } - - const validation = TemperatureSetRequestSchema.safeParse(req.body); - if (!validation.success) { - const validationError = createValidationError(validation.error); - return sendErrorResponse(res, 400, validationError.error); - } - - const temperature = Math.round(validation.data.temperature); - const result = await deps.backendManager.executeGCodeCommand( - contextResult.contextId, - `~M104 S${temperature}` - ); - - const response: StandardAPIResponse = { - success: result.success, - message: result.success ? `Setting extruder temperature to ${temperature}°C` : undefined, - error: result.error, - }; - return res.status(result.success ? 200 : 500).json(response); - } catch (error) { - const appError = toAppError(error); - return sendErrorResponse(res, 500, appError.message); - } + return handleSetTemperature(req, res, deps, 'extruder', (contextId, temperature) => + deps.backendManager.setExtruderTemperature(contextId, temperature) + ); }); router.post( '/printer/temperature/extruder/off', async (req: AuthenticatedRequest, res: Response) => { - await handleSimpleTemperatureCommand( + return handleTemperatureCommand( req, res, deps, - '~M104 S0', + (contextId) => deps.backendManager.cancelExtruderTemperature(contextId), 'Extruder heating turned off' ); } @@ -144,49 +99,56 @@ export function registerTemperatureRoutes(router: Router, deps: RouteDependencie } /** - * Run a FiveMClient temperature-control action for the resolved context. Used by the - * Creator 5 per-tool and chamber heaters, which have no G-code passthrough. + * Validate and run a backend-manager set-temperature command for the resolved + * context. Used by the single-tool bed and extruder heaters. */ -async function handleTempControl( +async function handleSetTemperature( req: AuthenticatedRequest, res: Response, deps: RouteDependencies, - action: (client: FiveMClient) => Promise, - successMessage: string + heaterLabel: string, + run: (contextId: string, temperature: number) => Promise ): Promise { try { - const contextResult = resolveContext(req, deps, { - requireBackendReady: true, - requireBackendInstance: true, - }); + const contextResult = resolveContext(req, deps, { requireBackendReady: true }); if (!contextResult.success) { - return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + return sendErrorResponse( + res, + contextResult.statusCode, + contextResult.error + ); } - const { backend } = contextResult; - const primaryClient = backend?.getPrimaryClient(); - if (!(primaryClient instanceof FiveMClient)) { - return sendErrorResponse(res, 400, 'Temperature control requires new API client'); + const validation = TemperatureSetRequestSchema.safeParse(req.body); + if (!validation.success) { + const validationError = createValidationError(validation.error); + return sendErrorResponse(res, 400, validationError.error); } - const result = await action(primaryClient); + const temperature = Math.round(validation.data.temperature); + const result = await run(contextResult.contextId, temperature); + const response: StandardAPIResponse = { - success: result, - message: result ? successMessage : undefined, - error: result ? undefined : 'Temperature command failed', + success: result.success, + message: result.success ? `Setting ${heaterLabel} temperature to ${temperature}°C` : undefined, + error: result.error, }; - return res.status(result ? 200 : 500).json(response); + return res.status(result.success ? 200 : 500).json(response); } catch (error) { const appError = toAppError(error); return sendErrorResponse(res, 500, appError.message); } } -async function handleSimpleTemperatureCommand( +/** + * Run a backend-manager temperature command for the resolved context. Used by + * the bed/extruder "off" endpoints, which carry no request body. + */ +async function handleTemperatureCommand( req: AuthenticatedRequest, res: Response, deps: RouteDependencies, - command: string, + action: (contextId: string) => Promise, successMessage: string ): Promise { try { @@ -199,7 +161,7 @@ async function handleSimpleTemperatureCommand( ); } - const result = await deps.backendManager.executeGCodeCommand(contextResult.contextId, command); + const result = await action(contextResult.contextId); const response: StandardAPIResponse = { success: result.success, @@ -212,3 +174,42 @@ async function handleSimpleTemperatureCommand( return sendErrorResponse(res, 500, appError.message); } } + +/** + * Run a FiveMClient temperature-control action for the resolved context. Used by the + * Creator 5 per-tool and chamber heaters, which have no G-code passthrough. + */ +async function handleTempControl( + req: AuthenticatedRequest, + res: Response, + deps: RouteDependencies, + action: (client: FiveMClient) => Promise, + successMessage: string +): Promise { + try { + const contextResult = resolveContext(req, deps, { + requireBackendReady: true, + requireBackendInstance: true, + }); + if (!contextResult.success) { + return sendErrorResponse(res, contextResult.statusCode, contextResult.error); + } + + const { backend } = contextResult; + const primaryClient = backend?.getPrimaryClient(); + if (!(primaryClient instanceof FiveMClient)) { + return sendErrorResponse(res, 400, 'Temperature control requires new API client'); + } + + const result = await action(primaryClient); + const response: StandardAPIResponse = { + success: result, + message: result ? successMessage : undefined, + error: result ? undefined : 'Temperature command failed', + }; + return res.status(result ? 200 : 500).json(response); + } catch (error) { + const appError = toAppError(error); + return sendErrorResponse(res, 500, appError.message); + } +} diff --git a/src/webui/server/upload-staging.ts b/src/webui/server/upload-staging.ts new file mode 100644 index 0000000..c3674c4 --- /dev/null +++ b/src/webui/server/upload-staging.ts @@ -0,0 +1,173 @@ +/** + * @fileoverview Temporary staging store for browser-supplied job files. + * + * The desktop job uploader hands the printer backend a path on the local disk, + * picked through a native file dialog. A browser cannot do that, so the WebUI + * receives the bytes over HTTP, writes them to a scratch file, and hands that + * path to the same backend calls. This module owns the scratch files: creation, + * lookup by opaque id, explicit disposal, and a TTL sweep so an abandoned + * dialog never leaks a multi-hundred-megabyte 3MF onto disk. + * + * Key exports: + * - stageUpload(): persist an uploaded buffer and return its handle + * - getStagedUpload(): resolve a handle back to a path + * - discardStagedUpload(): delete one staged file + * - disposeUploadStaging(): delete everything and stop the sweep (shutdown) + * - isAllowedJobFileName(): extension allow-list shared with the routes + */ + +import { randomUUID } from 'node:crypto'; +import { mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; + +/** Extensions the desktop file dialog offers for job files. */ +const ALLOWED_EXTENSIONS = ['.gcode', '.gx', '.3mf'] as const; + +/** Staged files are dropped this long after creation if never used. */ +const STAGE_TTL_MS = 30 * 60 * 1000; + +/** How often the TTL sweep runs. */ +const SWEEP_INTERVAL_MS = 5 * 60 * 1000; + +/** Upper bound on concurrently staged files, guarding against disk exhaustion. */ +const MAX_STAGED_FILES = 8; + +const STAGE_DIR = join(tmpdir(), 'flashforge-webui-uploads'); + +export interface StagedUpload { + readonly id: string; + readonly filePath: string; + readonly fileName: string; + readonly size: number; + readonly createdAt: number; +} + +const staged = new Map(); +let sweepTimer: NodeJS.Timeout | null = null; + +/** + * Reduce a client-supplied name to a bare filename and confirm it is a job file. + * Rejects path separators outright rather than stripping them, so a traversal + * attempt surfaces as an error instead of silently succeeding under a new name. + */ +export function isAllowedJobFileName(fileName: string): boolean { + if (!fileName || fileName !== basename(fileName)) { + return false; + } + if (fileName.includes('\0') || fileName.startsWith('.')) { + return false; + } + const lower = fileName.toLowerCase(); + return ALLOWED_EXTENSIONS.some((extension) => lower.endsWith(extension)); +} + +function ensureSweepTimer(): void { + if (sweepTimer) { + return; + } + sweepTimer = setInterval(() => { + void sweepExpired(); + }, SWEEP_INTERVAL_MS); + // Never hold the process open just to expire scratch files. + sweepTimer.unref?.(); +} + +async function sweepExpired(): Promise { + const cutoff = Date.now() - STAGE_TTL_MS; + for (const entry of Array.from(staged.values())) { + if (entry.createdAt <= cutoff) { + await discardStagedUpload(entry.id); + } + } +} + +/** + * Remove orphaned scratch files from a previous run. Called once on first stage + * so a crash mid-upload does not leave the temp directory growing forever. + */ +let cleanedOrphans = false; +async function cleanOrphansOnce(): Promise { + if (cleanedOrphans) { + return; + } + cleanedOrphans = true; + try { + const entries = await readdir(STAGE_DIR); + await Promise.all( + entries + .filter((entry) => !staged.has(entry)) + .map((entry) => rm(join(STAGE_DIR, entry), { recursive: true, force: true })) + ); + } catch { + // Directory may not exist yet; nothing to clean. + } +} + +/** + * Write an uploaded job file to scratch storage and return its handle. + * + * Each upload gets its own subdirectory so the original filename can be kept + * verbatim — the printer stores the file under the name we send it, and the + * desktop path preserves the user's filename exactly. + */ +export async function stageUpload(fileName: string, data: Buffer): Promise { + if (!isAllowedJobFileName(fileName)) { + throw new Error('Unsupported job file name'); + } + + await mkdir(STAGE_DIR, { recursive: true }); + await cleanOrphansOnce(); + + if (staged.size >= MAX_STAGED_FILES) { + // Evict the oldest rather than rejecting: a stale handle is always less + // valuable than the upload the user is actively performing. + const oldest = Array.from(staged.values()).sort((a, b) => a.createdAt - b.createdAt)[0]; + if (oldest) { + await discardStagedUpload(oldest.id); + } + } + + const id = randomUUID(); + const directory = join(STAGE_DIR, id); + await mkdir(directory, { recursive: true }); + + const filePath = join(directory, fileName); + await writeFile(filePath, data); + + const entry: StagedUpload = { + id, + filePath, + fileName, + size: data.byteLength, + createdAt: Date.now(), + }; + staged.set(id, entry); + ensureSweepTimer(); + + return entry; +} + +export function getStagedUpload(id: string): StagedUpload | undefined { + return staged.get(id); +} + +export async function discardStagedUpload(id: string): Promise { + const entry = staged.get(id); + if (!entry) { + return; + } + staged.delete(id); + await rm(join(STAGE_DIR, id), { recursive: true, force: true }); +} + +/** Drop every staged file and stop the sweep. Intended for server shutdown. */ +export async function disposeUploadStaging(): Promise { + if (sweepTimer) { + clearInterval(sweepTimer); + sweepTimer = null; + } + const ids = Array.from(staged.keys()); + staged.clear(); + await Promise.all(ids.map((id) => rm(join(STAGE_DIR, id), { recursive: true, force: true }))); +} diff --git a/src/webui/static/app.ts b/src/webui/static/app.ts index 5b41208..2542d67 100644 --- a/src/webui/static/app.ts +++ b/src/webui/static/app.ts @@ -27,6 +27,7 @@ import { onSpoolmanUpdate, onStatusUpdate, } from './core/Transport.js'; +import { setupAbout } from './features/about.js'; import { checkAuthStatus, loadAuthStatus, @@ -62,7 +63,12 @@ import { confirmMaterialMatching, setupMaterialMatchingHandlers, } from './features/material-matching.js'; +import { refreshCalibrationButton, setupCalibration } from './features/calibration.js'; +import { refreshFileManagerButton, setupFileManager } from './features/file-manager.js'; import { refreshIfsStationCard, setupIfsStationCard } from './features/ifs-station.js'; +import { setupJobUpload } from './features/job-upload.js'; +import { refreshRebootButton, setupReboot } from './features/reboot.js'; +import { refreshSSHSettings, setupSSHSettings } from './features/ssh.js'; import { initializeDiscovery } from './features/printer-discovery.js'; import { loadSpoolmanConfig, setupSpoolmanHandlers } from './features/spoolman.js'; import { $, hideElement, showElement } from './shared/dom.js'; @@ -92,7 +98,7 @@ export interface AuthStatusResponse { } export interface WebSocketMessage { - type: 'AUTH_SUCCESS' | 'STATUS_UPDATE' | 'ERROR' | 'COMMAND_RESULT' | 'PONG' | 'SPOOLMAN_UPDATE'; + type: 'AUTH_SUCCESS' | 'STATUS_UPDATE' | 'ERROR' | 'COMMAND_RESULT' | 'PONG' | 'SPOOLMAN_UPDATE' | 'REBOOT_STATUS'; timestamp: string; status?: PrinterStatus; error?: string; @@ -101,6 +107,23 @@ export interface WebSocketMessage { success?: boolean; contextId?: string; spool?: ActiveSpoolData | null; + reboot?: RebootStatusPayload; +} + +/** Lifecycle phase of an in-flight printer reboot (see printer-power routes). */ +export type RebootPhase = + | 'rebooting' + | 'reconnecting' + | 'reconnecting-services' + | 'success' + | 'timeout' + | 'failed'; + +/** Payload carried by REBOOT_STATUS WebSocket broadcasts. */ +export interface RebootStatusPayload { + readonly phase: RebootPhase; + readonly message?: string; + readonly printerName?: string; } export interface WebSocketCommand { @@ -137,6 +160,7 @@ export interface PrinterStatus { cumulativeFilament?: number; // Total lifetime filament usage in meters cumulativePrintTime?: number; // Total lifetime print time in minutes formattedEta?: string; // Firmware ETA string (e.g. "04:48" = 4h48m remaining) + completionTime?: Date | string | null; // Library completion timestamp, null when not advancing elapsedTimeSeconds?: number; // Precise elapsed seconds for HH:MM:SS display // Creator 5 series (multi-tool) fields. Undefined/empty on single-nozzle printers. toolTemps?: ToolTemperature[]; @@ -160,6 +184,12 @@ export interface PrinterFeatures { hasMultiTool?: boolean; /** Creator 5 Pro — gates the read-only TVOC air-quality display. */ isCreator5Pro?: boolean; + /** Raw G-code passthrough availability; gates the Home Axes (~G28) button. */ + gcodeCommands?: { + available: boolean; + usesLegacyAPI: boolean; + supportedCommands: readonly string[]; + }; } export interface AD5XToolData { @@ -270,6 +300,62 @@ export interface MaterialMapping { slotMaterialColor: string; } +// Job upload types (mirror src/webui/types/web-api.types.ts) + +/** A single slicer warning extracted from a 3MF. */ +export interface UploadSliceWarning { + level: number; + message: string; +} + +/** Per-tool filament entry parsed out of a staged upload. */ +export interface UploadFilamentInfo { + /** Gcode tool index (0-based) this filament prints with. */ + toolId: number; + type: string | null; + color: string | null; + usedM: string | null; + usedG: string | null; +} + +/** + * Slicer metadata for a staged upload, already resolved by the server through + * the same fallback chains the desktop job uploader applies while rendering. + */ +export interface UploadJobMetadata { + printerModel: string | null; + filamentType: string | null; + filamentLengthM: number | null; + filamentWeightG: number | null; + supportUsed: boolean | null; + slicerName: string | null; + slicerVersion: string | null; + sliceDate: string | null; + sliceTime: string | null; + printEta: string | null; + layerHeight: number | null; + infillDensity: number | null; + layerCount: number | null; + firstLayerTime: number | null; + warnings: UploadSliceWarning[]; + /** Base64 PNG without a data URL prefix. */ + thumbnail: string | null; + filaments: UploadFilamentInfo[]; +} + +export interface JobUploadStageResponse extends ApiResponse { + uploadId?: string; + fileName?: string; + metadata?: UploadJobMetadata; + /** True when the printer has a material station and the file is a 3MF. */ + requiresMaterialMatching?: boolean; +} + +export interface JobUploadStartResponse extends ApiResponse { + fileName?: string; + started?: boolean; +} + export interface PendingJobStart { filename: string; leveling: boolean; @@ -310,6 +396,57 @@ export interface SpoolmanConfigResponse extends ApiResponse { serverUrl: string; updateMode: 'length' | 'weight'; contextId: string | null; + station?: SpoolmanStationTracking | null; +} + +/** Estimate-based tracking info for material-station contexts. */ +export interface SpoolmanStationTracking { + supported: boolean; + note: string; + activeJob: SpoolmanTrackedJobView | null; + lastDeduction: DeductionSummary | null; +} + +export interface SpoolmanTrackedJobView { + fileName: string; + started: boolean; + lastProgress: number | null; + hasUsageProfile: boolean; + tools: Array<{ + toolId: number; + slotId: number; + spoolId: number; + usedG: number | null; + usedM: number | null; + }>; +} + +/** Spool chosen for one tool in the matching dialog (null = do not track). */ +export interface SpoolAssignment { + toolId: number; + spoolId: number | null; +} + +export interface DeductionSummary { + fileName: string; + terminal: 'completed' | 'cancelled' | 'error' | 'interrupted'; + progress: number; + approximate: boolean; + tools: ToolDeduction[]; + deductedCount: number; + skippedCount: number; + at: string; +} + +export interface ToolDeduction { + toolId: number; + slotId: number; + spoolId: number | null; + amount: number | null; + mode: 'weight' | 'length'; + fraction: number; + status: 'deducted' | 'skipped'; + reason?: string; } export interface ActiveSpoolResponse extends ApiResponse { @@ -330,10 +467,6 @@ export interface SlotConfigResponse extends ApiResponse { colorHex: string; } -// ============================================================================ -// GRID AND SETTINGS MANAGEMENT -// ============================================================================ - // ============================================================================ // UI UPDATES // ============================================================================ @@ -354,21 +487,6 @@ onSpoolmanUpdate((contextId, spool) => { } }); -// ============================================================================ -// PRINTER CONTROLS -// ============================================================================ - -// ============================================================================ -// SPOOLMAN INTEGRATION -// ============================================================================ - -// ============================================================================ -// VIEWPORT AND LAYOUT SWITCHING -// ============================================================================ - -/** - * Handle viewport resize across breakpoint - */ // ============================================================================ // EVENT HANDLERS // ============================================================================ @@ -403,15 +521,31 @@ async function initialize(): Promise { setupDialogEventHandlers(dialogHandlers); setupJobControlEventHandlers(); setupMaterialMatchingHandlers(); + setupJobUpload(); setupSpoolmanHandlers(); setupIfsStationCard(); + setupFileManager(); + setupCalibration(); + setupReboot(); + setupAbout(); + setupSSHSettings(); initializeDiscovery(); + // The SSH settings section reflects the ACTIVE printer; reload it whenever + // the settings modal is opened. + $('settings-button')?.addEventListener('click', () => { + void refreshSSHSettings(); + }); + const contextHandlers = { onContextSwitched: async () => { await loadPrinterFeatures(); await loadSpoolmanConfig(); initializeCamera(); + void refreshFileManagerButton(); + void refreshCalibrationButton(); + void refreshRebootButton(); + void refreshSSHSettings(); }, }; @@ -465,12 +599,14 @@ async function handlePostLoginTasks(): Promise { await loadSpoolmanConfig(); initializeCamera(); + void refreshFileManagerButton(); + void refreshCalibrationButton(); + void refreshRebootButton(); } catch (error) { console.error('Failed to load features:', error); } } -// Start the application when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { diff --git a/src/webui/static/core/AppState.ts b/src/webui/static/core/AppState.ts index 8126147..de736dd 100644 --- a/src/webui/static/core/AppState.ts +++ b/src/webui/static/core/AppState.ts @@ -16,6 +16,7 @@ import type { PrinterFeatures, PrinterStatus, SpoolmanConfigResponse, + SpoolAssignment, SpoolSummary, WebUIJobFile, WebUISettings, @@ -69,11 +70,33 @@ let gridChangeUnsubscribe: (() => void) | null = null; export const contextById = new Map(); let isMobileLayout = false; +/** + * Optional overrides that let a caller other than the file dialog drive the + * material matching modal. The job uploader uses them to collect mappings for a + * file that is not on the printer yet, instead of starting a resident job. + */ +export interface MaterialMatchingHooks { + /** Runs in place of the default job-start request. Return true to close the modal. */ + onConfirm?: (mappings: MaterialMapping[], spoolAssignments: SpoolAssignment[]) => Promise; + /** Runs when the modal is dismissed without confirming. */ + onCancel?: () => void; +} + export interface MaterialMatchingState { pending: PendingJobStart; materialStation: MaterialStationStatus | null; selectedToolId: number | null; mappings: Map; + /** + * Spoolman spool per tool when per-job tracking applies: a spool id, null + * for "do not track", or no entry while the user has not chosen yet. + */ + spoolChoices: Map; + /** Spools offered in the dialog; null when tracking does not apply. */ + spools: SpoolSummary[] | null; + hooks?: MaterialMatchingHooks; + /** Set once onConfirm succeeds so teardown does not also fire onCancel. */ + confirmed?: boolean; } let materialMatchingState: MaterialMatchingState | null = null; diff --git a/src/webui/static/core/Transport.ts b/src/webui/static/core/Transport.ts index e19a8b0..5a50426 100644 --- a/src/webui/static/core/Transport.ts +++ b/src/webui/static/core/Transport.ts @@ -6,7 +6,13 @@ * spoolman updates. Keeps transport concerns isolated from UI orchestration. */ -import type { ActiveSpoolData, PrinterStatus, WebSocketCommand, WebSocketMessage } from '../app.js'; +import type { + ActiveSpoolData, + PrinterStatus, + RebootStatusPayload, + WebSocketCommand, + WebSocketMessage, +} from '../app.js'; import { showToast } from '../shared/dom.js'; import { state } from './AppState.js'; @@ -94,10 +100,12 @@ export async function apiRequestWithMetadata( type StatusUpdateCallback = (status: PrinterStatus) => void; type SpoolmanUpdateCallback = (contextId: string, spool: ActiveSpoolData | null) => void; type ConnectionChangeCallback = (connected: boolean) => void; +type RebootStatusCallback = (contextId: string, payload: RebootStatusPayload) => void; const statusUpdateCallbacks: StatusUpdateCallback[] = []; const spoolmanUpdateCallbacks: SpoolmanUpdateCallback[] = []; const connectionCallbacks: ConnectionChangeCallback[] = []; +const rebootStatusCallbacks: RebootStatusCallback[] = []; export function onStatusUpdate(callback: StatusUpdateCallback): void { statusUpdateCallbacks.push(callback); @@ -111,6 +119,10 @@ export function onConnectionChange(callback: ConnectionChangeCallback): void { connectionCallbacks.push(callback); } +export function onRebootStatus(callback: RebootStatusCallback): void { + rebootStatusCallbacks.push(callback); +} + function notifyConnectionChange(connected: boolean): void { connectionCallbacks.forEach((callback) => { callback(connected); @@ -223,5 +235,15 @@ function handleWebSocketMessage(message: WebSocketMessage): void { }); } break; + + case 'REBOOT_STATUS': + if (message.contextId && message.reboot) { + const contextId = message.contextId; + const payload = message.reboot; + rebootStatusCallbacks.forEach((callback) => { + callback(contextId, payload); + }); + } + break; } } diff --git a/src/webui/static/features/about.ts b/src/webui/static/features/about.ts new file mode 100644 index 0000000..7ea4e49 --- /dev/null +++ b/src/webui/static/features/about.ts @@ -0,0 +1,39 @@ +/** + * @fileoverview "About" popup for the WebUI client. + * + * A topbar info button opens a small modal showing the running WebUI version + * and links to releases, source, and the issue tracker. The version string is + * stamped into index.html at build time by scripts/copy-webui-assets.js, so + * this module only handles opening and closing the modal. + * + * Key exports: + * - setupAbout(): wire the topbar button and modal close handlers + */ + +import { $, hideElement, showElement } from '../shared/dom.js'; + +const MODAL_ID = 'about-modal'; + +function closeAboutModal(): void { + hideElement(MODAL_ID); +} + +/** Wire the topbar About button and the modal's close handlers. */ +export function setupAbout(): void { + const modal = $(MODAL_ID); + + $('about-button')?.addEventListener('click', () => showElement(MODAL_ID)); + $('about-close')?.addEventListener('click', closeAboutModal); + + modal?.addEventListener('click', (event) => { + if (event.target === modal) { + closeAboutModal(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !modal?.classList.contains('hidden')) { + closeAboutModal(); + } + }); +} diff --git a/src/webui/static/features/calibration.ts b/src/webui/static/features/calibration.ts new file mode 100644 index 0000000..fb7ecfa --- /dev/null +++ b/src/webui/static/features/calibration.ts @@ -0,0 +1,1200 @@ +/** + * @fileoverview Calibration Assistant feature for the WebUI client. + * + * Browser port of the desktop calibration dialog: a topbar button (shown only + * for SSH-capable models: Adventurer 5M / 5M Pro / AD5X) opens a full overlay + * with the same four tabs — Bed Leveling (mesh heatmap + workflow analysis + + * animated screw/tape recommendations), Input Shaper (PSD plot + shaper + * comparison + Klipper config output), SSH connection controls, and History. + * Talks to the authenticated /api/calibration/* REST surface. + * + * Desktop-only affordances are replaced with browser equivalents: native file + * dialogs become pickers, save dialogs become Blob + * downloads, and report export streams the raw payload from + * GET /api/calibration/report with auth headers. + * + * Context pinning: the active context id is captured when the modal opens and + * sent with every request, so switching the printer selector mid-session never + * retargets calibration operations. + * + * Key exports: + * - setupCalibration(): wire the topbar button + modal event handlers + * - refreshCalibrationButton(): show/hide the topbar button per model support + */ + +import { contextById } from '../core/AppState.js'; +import { apiRequest, buildAuthHeaders } from '../core/Transport.js'; +import { showToast } from '../shared/dom.js'; +import { hydrateLucideIcons } from '../shared/icons.js'; +import { AnimatedRecommendationVisualizer } from './calibration/AnimatedRecommendationVisualizer.js'; +import { BedMeshVisualizer } from './calibration/BedMeshVisualizer.js'; +import { ShaperPlotVisualizer } from './calibration/ShaperPlotVisualizer.js'; +import type { + AnalysisResult, + AxisCalibration, + CalibrationHistoryEntry, + CalibrationWorkspace, + MeshData, + ScrewAdjustment, + ShaperResult, + TapeRecommendation, + WorkflowData, +} from './calibration/types.js'; +import { getCurrentContextId } from './context-switching.js'; + +// --------------------------------------------------------------------------- +// API response shapes +// --------------------------------------------------------------------------- + +interface SupportResponse { + readonly success: boolean; + readonly supported?: boolean; + readonly error?: string; +} + +interface WorkspaceResponse { + readonly success: boolean; + readonly workspace?: CalibrationWorkspace | null; + readonly error?: string; +} + +interface ProfilesResponse { + readonly success: boolean; + readonly profiles?: string[]; + readonly error?: string; +} + +interface WorkflowResponse { + readonly success: boolean; + readonly workflow?: WorkflowData; + readonly error?: string; +} + +interface HistoryResponse { + readonly success: boolean; + readonly history?: CalibrationHistoryEntry[]; + readonly error?: string; +} + +interface ShaperAnalyzeResponse { + readonly success: boolean; + readonly calibration?: AxisCalibration; + readonly error?: string; +} + +interface ShaperConfigResponse { + readonly success: boolean; + readonly lines?: string[]; + readonly error?: string; +} + +interface SSHConfigResponse { + readonly success: boolean; + readonly config?: { + readonly host?: string; + readonly port?: number; + readonly username?: string; + readonly keyPath?: string; + readonly isCustom?: boolean; + readonly configPath?: string; + }; + readonly error?: string; +} + +interface SSHStatusResponse { + readonly success: boolean; + readonly status?: unknown; + readonly error?: string; +} + +interface SSHExecuteResponse { + readonly success: boolean; + readonly result?: { + readonly success?: boolean; + readonly stdout?: string; + readonly stderr?: string; + readonly error?: string; + }; + readonly error?: string; +} + +interface SSHContentResponse { + readonly success: boolean; + readonly content?: string; + readonly error?: string; +} + +interface UploadConfigResponse { + readonly success: boolean; + readonly result?: { readonly success?: boolean; readonly error?: string }; + readonly error?: string; +} + +interface BasicResponse { + readonly success: boolean; + readonly error?: string; +} + +// --------------------------------------------------------------------------- +// Constants + state +// --------------------------------------------------------------------------- + +/** Lucide icons used inside the calibration modal markup. */ +const CAL_ICONS = [ + 'grid-3x3', + 'activity', + 'terminal', + 'history', + 'file-up', + 'download', + 'circle', + 'play', + 'sparkles', + 'copy', + 'upload', + 'plug', + 'unplug', + 'check-circle', + 'trash-2', + 'x', +]; + +interface CalibrationState { + /** Context pinned when the modal opened; null when the modal is closed. */ + pinnedContextId: string | null; + meshData: MeshData | null; + analysisResult: AnalysisResult | null; + workflowData: WorkflowData | null; + configContent: string | null; + sshConnected: boolean; + activeTab: string; + shaperResults: { + x: AxisCalibration | null; + y: AxisCalibration | null; + activeAxis: 'x' | 'y'; + }; +} + +const calState: CalibrationState = { + pinnedContextId: null, + meshData: null, + analysisResult: null, + workflowData: null, + configContent: null, + sshConnected: false, + activeTab: 'bed', + shaperResults: { x: null, y: null, activeAxis: 'x' }, +}; + +let meshVisualizer: BedMeshVisualizer | null = null; +let shaperVisualizer: ShaperPlotVisualizer | null = null; +let recommendationVisualizer: AnimatedRecommendationVisualizer | null = null; +let iconsHydrated = false; + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function el(id: string): HTMLElement { + const element = document.getElementById(id); + if (!element) { + throw new Error(`[Calibration] Missing element #${id}`); + } + return element; +} + +function inputEl(id: string): HTMLInputElement { + return el(id) as HTMLInputElement; +} + +function buttonEl(id: string): HTMLButtonElement { + return el(id) as HTMLButtonElement; +} + +function selectEl(id: string): HTMLSelectElement { + return el(id) as HTMLSelectElement; +} + +function setStatus(message: string): void { + el('cal-status-message').textContent = message; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} + +/** Append the pinned context id to a query-string API path. */ +function calQuery(path: string, params: Record = {}): string { + const query = new URLSearchParams(params); + if (calState.pinnedContextId) { + query.set('contextId', calState.pinnedContextId); + } + const suffix = query.toString(); + return suffix ? `${path}?${suffix}` : path; +} + +/** Build a JSON body payload that carries the pinned context id. */ +function calBody(payload: Record = {}): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + calState.pinnedContextId ? { ...payload, contextId: calState.pinnedContextId } : payload + ), + }; +} + +/** Open a browser file picker and read the chosen file as text. */ +function pickTextFile(accept: string): Promise<{ name: string; content: string } | null> { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + file + .text() + .then((content) => resolve({ name: file.name, content })) + .catch(() => resolve(null)); + }); + input.click(); + }); +} + +/** Trigger a browser download for the given content. */ +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 5000); +} + +// --------------------------------------------------------------------------- +// Topbar button gating +// --------------------------------------------------------------------------- + +/** + * Show or hide the topbar calibration button. Calibration shares the SSH model + * set with the file manager (5M / 5M Pro / AD5X), so it reuses the cheap + * model-only support endpoint. + */ +export async function refreshCalibrationButton(): Promise { + const button = document.getElementById('calibration-button'); + if (!button) { + return; + } + try { + const result = await apiRequest('/api/file-manager/support'); + button.classList.toggle('hidden', !(result.success && result.supported)); + } catch { + button.classList.add('hidden'); + } +} + +// --------------------------------------------------------------------------- +// Modal lifecycle +// --------------------------------------------------------------------------- + +async function openCalibration(): Promise { + const contextId = getCurrentContextId(); + if (!contextId) { + showToast('No printer is connected', 'error'); + return; + } + + calState.pinnedContextId = contextId; + calState.meshData = null; + calState.analysisResult = null; + calState.workflowData = null; + calState.configContent = null; + calState.sshConnected = false; + calState.shaperResults.x = null; + calState.shaperResults.y = null; + + const context = contextById.get(contextId); + el('cal-context-indicator').textContent = context ? `Printer: ${context.name}` : 'Printer: (active context)'; + + el('calibration-modal').classList.remove('hidden'); + if (!iconsHydrated) { + hydrateLucideIcons(CAL_ICONS, el('calibration-modal')); + iconsHydrated = true; + } + + if (!meshVisualizer) { + meshVisualizer = new BedMeshVisualizer(el('cal-mesh-canvas') as HTMLCanvasElement, { + width: 400, + height: 400, + colorScheme: 'viridis', + showGrid: true, + showLabels: true, + showCorners: true, + interpolationFactor: 1, + }); + meshVisualizer.setEventHandlers({ + onCellHover: (cell) => { + setStatus(cell ? `Cell [${cell.row}, ${cell.col}]: ${cell.value.toFixed(4)} mm` : ''); + }, + }); + } + if (!shaperVisualizer) { + shaperVisualizer = new ShaperPlotVisualizer(el('cal-shaper-canvas') as HTMLCanvasElement, { + width: 600, + height: 300, + backgroundColor: '#141414', + }); + } + if (!recommendationVisualizer) { + recommendationVisualizer = new AnimatedRecommendationVisualizer(el('cal-rec-canvas') as HTMLCanvasElement, { + width: 620, + height: 420, + }); + } + + switchTab('bed'); + setStatus(''); + updateMeshDisplay(); + updateAnalysisDisplay(); + updateShaperDisplay(null); + + await loadWorkspace(); + await loadSSHInfo(); + await updateSSHStatus(); + + // Auto-connect SSH so the Fetch-via-SSH workflows are immediately usable + // (mirrors the desktop dialog and file manager). + if (!calState.sshConnected) { + void handleSSHConnect(true); + } +} + +function closeCalibration(): void { + hideVisualRecommendations(); + el('calibration-modal').classList.add('hidden'); + calState.pinnedContextId = null; +} + +// --------------------------------------------------------------------------- +// Tab navigation +// --------------------------------------------------------------------------- + +function switchTab(tabId: string): void { + calState.activeTab = tabId; + + document.querySelectorAll('.cal-tab-button').forEach((button) => { + const isActive = button.dataset.tab === tabId; + button.setAttribute('aria-selected', String(isActive)); + button.tabIndex = isActive ? 0 : -1; + }); + + document.querySelectorAll('.cal-tab-panel').forEach((panel) => { + panel.hidden = panel.id !== `cal-panel-${tabId}`; + }); + + if (tabId === 'history' && calState.pinnedContextId) { + void loadHistory(); + } +} + +// --------------------------------------------------------------------------- +// Workspace + mesh display +// --------------------------------------------------------------------------- + +async function loadWorkspace(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/workspace')); + if (result.success && result.workspace?.meshData) { + calState.meshData = result.workspace.meshData; + calState.analysisResult = result.workspace.analysis; + calState.workflowData = result.workspace.workflow; + updateMeshDisplay(); + updateAnalysisDisplay(); + } + } catch (error) { + console.error('[Calibration] Failed to load workspace:', error); + } +} + +function updateMeshDisplay(): void { + meshVisualizer?.setMeshData(calState.meshData, calState.analysisResult); + + if (calState.meshData) { + const values = calState.meshData.matrix.flat(); + const min = Math.min(...values); + const max = Math.max(...values); + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const stdDev = Math.sqrt(values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length); + + el('cal-stat-range').textContent = `${(max - min).toFixed(4)} mm`; + el('cal-stat-max-dev').textContent = `${Math.max(Math.abs(min), Math.abs(max)).toFixed(4)} mm`; + el('cal-stat-std-dev').textContent = `${stdDev.toFixed(4)} mm`; + buttonEl('cal-btn-run-workflow').disabled = false; + } else { + el('cal-stat-range').textContent = '--'; + el('cal-stat-max-dev').textContent = '--'; + el('cal-stat-std-dev').textContent = '--'; + buttonEl('cal-btn-run-workflow').disabled = true; + } +} + +function formatCornerName(corner: string): string { + const names: Record = { + frontLeft: 'Front Left', + frontRight: 'Front Right', + rearLeft: 'Rear Left', + rearRight: 'Rear Right', + }; + return names[corner] || corner; +} + +function updateAnalysisDisplay(): void { + const resultsSection = el('cal-results-section'); + const visualButton = buttonEl('cal-btn-visual-rec'); + + if (!calState.workflowData) { + resultsSection.hidden = true; + visualButton.disabled = true; + updateWorkflowStages(); + return; + } + + resultsSection.hidden = false; + visualButton.disabled = false; + + const screwAdjustments = (calState.workflowData.screwAdjustments || []).filter( + (adj: ScrewAdjustment) => adj.requiresAdjustment + ); + el('cal-screw-list').innerHTML = + screwAdjustments.length > 0 + ? screwAdjustments + .map( + (adj: ScrewAdjustment) => ` +
+ ${formatCornerName(adj.corner)} + ${adj.formattedAmount} +
` + ) + .join('') + : '
No adjustments needed
'; + + const tapeRecs = calState.workflowData.tapeRecommendations || []; + el('cal-tape-list').innerHTML = + tapeRecs.length > 0 + ? tapeRecs + .map( + (rec: TapeRecommendation) => ` +
+ ${formatCornerName(rec.corner)} + ${rec.layers} layer${rec.layers !== 1 ? 's' : ''} +
` + ) + .join('') + : '
No tape needed
'; + + el('cal-improvement-value').textContent = `${calState.workflowData.improvementPercent.toFixed(1)}%`; + updateWorkflowStages(); +} + +function updateWorkflowStages(): void { + el('cal-workflow-stages') + .querySelectorAll('.cal-workflow-stage') + .forEach((stageEl) => { + const stage = stageEl.dataset.stage; + stageEl.classList.remove('active', 'completed'); + if (stage && calState.workflowData?.completedStages?.includes(stage as never)) { + stageEl.classList.add('completed'); + } + if (stage && calState.workflowData?.currentStage === stage) { + stageEl.classList.add('active'); + } + }); +} + +// --------------------------------------------------------------------------- +// Config file handling (bed leveling) +// --------------------------------------------------------------------------- + +async function handleLoadConfigFile(): Promise { + const picked = await pickTextFile('.cfg,.conf,.txt,text/plain'); + if (!picked) { + setStatus('File selection cancelled'); + return; + } + + calState.configContent = picked.content; + setStatus(`Loaded: ${picked.name}`); + await loadProfilesAndFirstMesh(picked.content); +} + +async function handleFetchConfigSSH(): Promise { + try { + setStatus('Fetching config via SSH...'); + const overridePath = inputEl('cal-ssh-config-path').value.trim() || undefined; + const result = await apiRequest( + '/api/calibration/ssh/fetch-config', + calBody(overridePath ? { remotePath: overridePath } : {}) + ); + if (!result.success || typeof result.content !== 'string') { + setStatus(`SSH Error: ${result.error || 'Failed to fetch config'}`); + return; + } + calState.configContent = result.content; + await loadProfilesAndFirstMesh(result.content); + setStatus('Config fetched successfully'); + } catch (error) { + setStatus(`SSH Error: ${errorText(error)}`); + } +} + +async function loadProfilesAndFirstMesh(configContent: string): Promise { + try { + const result = await apiRequest('/api/calibration/profiles', calBody({ configContent })); + const profiles = result.success && result.profiles ? result.profiles : []; + populateProfileSelect(profiles); + if (profiles.length > 0) { + await loadMeshProfile(profiles[0]); + } else { + setStatus('No mesh profiles found in config'); + } + } catch (error) { + setStatus(`Error: ${errorText(error)}`); + } +} + +function populateProfileSelect(profiles: string[]): void { + const select = selectEl('cal-profile-select'); + select.innerHTML = ''; + profiles.forEach((profile) => { + const option = document.createElement('option'); + option.value = profile; + option.textContent = profile; + select.appendChild(option); + }); + select.disabled = profiles.length === 0; +} + +async function loadMeshProfile(profileName: string): Promise { + if (!calState.configContent) return; + + try { + const result = await apiRequest( + '/api/calibration/workspace/load', + calBody({ configContent: calState.configContent, profileName }) + ); + if (result.success && result.workspace?.meshData) { + calState.meshData = result.workspace.meshData; + calState.analysisResult = result.workspace.analysis; + updateMeshDisplay(); + setStatus(`Loaded profile: ${profileName}`); + } else { + setStatus(`Error loading profile: ${result.error || 'Unable to parse mesh data'}`); + } + } catch (error) { + setStatus(`Error loading profile: ${errorText(error)}`); + } +} + +// --------------------------------------------------------------------------- +// Workflow +// --------------------------------------------------------------------------- + +async function handleRunWorkflow(): Promise { + const runButton = buttonEl('cal-btn-run-workflow'); + try { + setStatus('Running calibration workflow...'); + runButton.disabled = true; + + const result = await apiRequest('/api/calibration/workflow', calBody()); + if (result.success && result.workflow) { + calState.workflowData = result.workflow; + updateAnalysisDisplay(); + setStatus('Workflow complete'); + + await apiRequest( + '/api/calibration/history', + calBody({ + type: 'bed_level', + summary: `Range: ${result.workflow.initialRange.toFixed(3)}mm -> ${result.workflow.finalRange.toFixed(3)}mm`, + data: result.workflow, + }) + ); + } else { + setStatus(`Workflow error: ${result.error || 'No mesh data loaded'}`); + } + } catch (error) { + setStatus(`Workflow error: ${errorText(error)}`); + } finally { + runButton.disabled = false; + } +} + +function handleShowVisualRecommendations(): void { + if (!calState.workflowData) { + setStatus('Run analysis to generate recommendations'); + return; + } + + el('cal-visual-rec-overlay').classList.remove('hidden'); + recommendationVisualizer?.setRecommendations( + calState.workflowData.screwAdjustments || [], + calState.workflowData.tapeRecommendations || [] + ); + recommendationVisualizer?.start(); +} + +function hideVisualRecommendations(): void { + el('cal-visual-rec-overlay').classList.add('hidden'); + recommendationVisualizer?.stop(); +} + +// --------------------------------------------------------------------------- +// Input shaper +// --------------------------------------------------------------------------- + +function switchAxis(axis: 'x' | 'y'): void { + buttonEl('cal-btn-axis-x').classList.toggle('active', axis === 'x'); + buttonEl('cal-btn-axis-y').classList.toggle('active', axis === 'y'); + calState.shaperResults.activeAxis = axis; + updateShaperDisplay(calState.shaperResults[axis]); +} + +async function handleLoadShaperCSV(): Promise { + const picked = await pickTextFile('.csv,text/csv'); + if (!picked) { + setStatus('File selection cancelled'); + return; + } + + const axis = calState.shaperResults.activeAxis; + await analyzeShaperContent(picked.content, axis); + setStatus(`Loaded shaper CSV (${axis.toUpperCase()} axis)`); +} + +async function handleFetchShaperSSH(): Promise { + const axis = calState.shaperResults.activeAxis; + try { + setStatus(`Fetching ${axis.toUpperCase()} shaper data via SSH...`); + const result = await apiRequest('/api/calibration/ssh/fetch-shaper', calBody({ axis })); + if (!result.success || typeof result.content !== 'string') { + setStatus(`SSH Error: ${result.error || 'Failed to fetch shaper data'}`); + return; + } + await analyzeShaperContent(result.content, axis); + setStatus(`Fetched shaper data (${axis.toUpperCase()} axis)`); + } catch (error) { + setStatus(`SSH Error: ${errorText(error)}`); + } +} + +async function analyzeShaperContent(csvContent: string, axis: 'x' | 'y'): Promise { + try { + setStatus('Analyzing input shaper data...'); + const result = await apiRequest( + '/api/calibration/shaper/analyze', + calBody({ csvContent, axis }) + ); + if (!result.success || !result.calibration) { + setStatus(`Shaper analysis error: ${result.error || 'Analysis failed'}`); + return; + } + await setShaperCalibration(axis, result.calibration); + } catch (error) { + setStatus(`Shaper analysis error: ${errorText(error)}`); + } +} + +async function setShaperCalibration(axis: 'x' | 'y', calibration: AxisCalibration): Promise { + calState.shaperResults[axis] = calibration; + if (axis === calState.shaperResults.activeAxis) { + updateShaperDisplay(calibration); + } + + const summary = `${axis.toUpperCase()}: ${calibration.recommendedShaper.type.toUpperCase()} @ ${calibration.recommendedShaper.frequency.toFixed(1)} Hz`; + try { + await apiRequest( + '/api/calibration/history', + calBody({ type: 'input_shaper', summary, data: calibration }) + ); + await apiRequest( + '/api/calibration/shaper/save', + calBody({ axis, result: calibration.recommendedShaper }) + ); + } catch (error) { + console.error('[Calibration] Failed to persist shaper result:', error); + } +} + +function updateShaperDisplay(calibration: AxisCalibration | null): void { + shaperVisualizer?.setCalibration(calibration); + + if (!calibration) { + el('cal-shaper-recommendation').innerHTML = + '
Load accelerometer data to see recommendations
'; + el('cal-shaper-comparison').innerHTML = '
No data loaded
'; + void updateShaperConfigOutput(null); + return; + } + + const rec = calibration.recommendedShaper; + el('cal-shaper-recommendation').innerHTML = ` + +
+
Vibration reduction: ${(rec.vibrationReduction * 100).toFixed(1)}%
+
Smoothing: ${rec.smoothingTime.toFixed(2)} ms
+
Max accel: ${rec.maxAcceleration} mm/s²
+
+ `; + + el('cal-shaper-comparison').innerHTML = calibration.allShaperResults + .map( + (result: ShaperResult) => ` +
+
${result.type.toUpperCase()}
+
${result.frequency.toFixed(1)} Hz
+
` + ) + .join(''); + + void updateShaperConfigOutput(calibration); +} + +async function updateShaperConfigOutput(calibration: AxisCalibration | null): Promise { + const output = el('cal-shaper-config-output'); + const copyButton = buttonEl('cal-btn-copy-config'); + const saveButton = buttonEl('cal-btn-save-config'); + const uploadButton = buttonEl('cal-btn-upload-config'); + + if (!calibration) { + output.textContent = 'Load data to generate config'; + copyButton.disabled = true; + saveButton.disabled = true; + uploadButton.disabled = true; + return; + } + + const lines = await generateShaperConfigLines(calibration.axis, calibration.recommendedShaper); + output.textContent = lines.join('\n'); + copyButton.disabled = false; + saveButton.disabled = false; + uploadButton.disabled = !calState.sshConnected; +} + +async function generateShaperConfigLines(axis: 'x' | 'y', result: ShaperResult): Promise { + const response = await apiRequest( + '/api/calibration/shaper/config', + calBody({ axis, result }) + ); + return response.success && response.lines ? response.lines : []; +} + +async function handleCopyShaperConfig(): Promise { + const text = el('cal-shaper-config-output').textContent || ''; + if (!text || text.includes('Load data')) { + setStatus('No shaper config available'); + return; + } + + try { + await navigator.clipboard.writeText(text); + setStatus('Shaper config copied to clipboard'); + } catch (error) { + setStatus(`Copy failed: ${errorText(error)}`); + } +} + +async function handleSaveShaperConfig(): Promise { + const calibration = calState.shaperResults[calState.shaperResults.activeAxis]; + if (!calibration) { + setStatus('No shaper data to export'); + return; + } + + try { + const content = await buildShaperConfigContent(calibration.axis, calibration); + downloadBlob(new Blob([content], { type: 'text/plain' }), `input_shaper_${calibration.axis}.cfg`); + setStatus(`Config downloaded: input_shaper_${calibration.axis}.cfg`); + } catch (error) { + setStatus(`Export error: ${errorText(error)}`); + } +} + +async function handleUploadShaperConfig(): Promise { + const calibration = calState.shaperResults[calState.shaperResults.activeAxis]; + if (!calibration) { + setStatus('No shaper data to upload'); + return; + } + + if (!calState.configContent) { + setStatus('Load or fetch printer.cfg before uploading'); + return; + } + + try { + const content = await buildShaperConfigContent(calibration.axis, calibration); + const remotePath = inputEl('cal-ssh-config-path').value.trim() || undefined; + const result = await apiRequest( + '/api/calibration/ssh/upload-config', + calBody(remotePath ? { content, remotePath } : { content }) + ); + if (result.success) { + calState.configContent = content; + setStatus('Config uploaded via SSH'); + } else { + setStatus(`Upload failed: ${result.error || result.result?.error || 'Unknown error'}`); + } + } catch (error) { + setStatus(`Upload error: ${errorText(error)}`); + } +} + +async function buildShaperConfigContent(axis: 'x' | 'y', calibration: AxisCalibration): Promise { + const lines = await generateShaperConfigLines(axis, calibration.recommendedShaper); + if (!calState.configContent) { + return lines.join('\n'); + } + return applyShaperConfigToPrinterConfig(calState.configContent, axis, lines); +} + +/** + * Merge generated [input_shaper] lines into an existing printer.cfg, replacing + * only the target axis keys. Mirrors the desktop implementation verbatim. + */ +function applyShaperConfigToPrinterConfig(configContent: string, axis: 'x' | 'y', configLines: string[]): string { + const lines = configContent.split(/\r?\n/); + const axisKeys = [`shaper_freq_${axis}`, `shaper_type_${axis}`]; + + let sectionStart = -1; + let sectionEnd = lines.length; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim().toLowerCase(); + if (trimmed.startsWith('[')) { + if (sectionStart !== -1) { + sectionEnd = i; + break; + } + if (trimmed === '[input_shaper]') { + sectionStart = i; + } + } + } + + const axisLines = configLines.filter((line) => line.trim().startsWith('shaper_')); + + if (sectionStart === -1) { + const suffix = configContent.trim().length > 0 ? '\n\n' : ''; + return `${configContent.trimEnd()}${suffix}${configLines.join('\n')}\n`; + } + + const bodyLines = lines.slice(sectionStart + 1, sectionEnd); + const filtered = bodyLines.filter((line) => { + const trimmed = line.trim().toLowerCase(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) { + return true; + } + return !axisKeys.some((key) => trimmed.startsWith(`${key}:`) || trimmed.startsWith(`${key} `)); + }); + + const newSection = [lines[sectionStart], ...filtered, ...axisLines]; + return [...lines.slice(0, sectionStart), ...newSection, ...lines.slice(sectionEnd)].join('\n'); +} + +// --------------------------------------------------------------------------- +// SSH +// --------------------------------------------------------------------------- + +async function loadSSHInfo(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/ssh/config')); + if (!result.success || !result.config) { + return; + } + + const config = result.config; + const context = calState.pinnedContextId ? contextById.get(calState.pinnedContextId) : undefined; + const host = context?.ipAddress?.trim() || config.host?.trim() || ''; + const hostInput = inputEl('cal-ssh-host'); + hostInput.value = host; + if (!host) { + hostInput.placeholder = 'Printer IP unavailable (connect printer first)'; + } + + const username = config.username || 'root'; + const port = config.port || 22; + const auth = config.keyPath ? 'private key' : 'password'; + const source = config.isCustom ? 'custom' : 'easy-SSH defaults'; + inputEl('cal-ssh-summary').value = `${username} (port ${port}, ${auth} auth, ${source})`; + + if (config.configPath) { + inputEl('cal-ssh-config-path').value = config.configPath; + } + } catch (error) { + console.error('[Calibration] Failed to load SSH settings:', error); + } +} + +/** Defensively derive "connected" from the SSH status payload. */ +function isConnectedStatus(status: unknown): boolean { + if (typeof status === 'string') { + return status === 'connected'; + } + if (status && typeof status === 'object') { + const record = status as Record; + return record.connected === true || record.status === 'connected' || record.state === 'connected'; + } + return false; +} + +async function updateSSHStatus(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/ssh/status')); + calState.sshConnected = result.success && isConnectedStatus(result.status); + } catch { + calState.sshConnected = false; + } + + const connected = calState.sshConnected; + const indicator = el('cal-ssh-indicator'); + indicator.querySelector('.cal-ssh-dot')?.classList.toggle('connected', connected); + const statusText = indicator.querySelector('.cal-ssh-text'); + if (statusText) { + statusText.textContent = connected ? 'SSH: Connected' : 'SSH: Disconnected'; + } + + buttonEl('cal-btn-ssh-connect').disabled = connected; + buttonEl('cal-btn-ssh-disconnect').disabled = !connected; + buttonEl('cal-btn-fetch-ssh').disabled = !connected; + buttonEl('cal-btn-fetch-shaper').disabled = !connected; + buttonEl('cal-btn-upload-config').disabled = + !connected || !calState.shaperResults[calState.shaperResults.activeAxis]; +} + +async function handleSSHConnect(silent = false): Promise { + try { + buttonEl('cal-btn-ssh-connect').disabled = true; + if (!silent) { + setSSHResult('Connecting...', 'info'); + } + + const result = await apiRequest('/api/calibration/ssh/connect', calBody()); + if (!result.success) { + throw new Error(result.error || 'Connection failed'); + } + + if (!silent) { + setSSHResult('Connected successfully!', 'success'); + } + await updateSSHStatus(); + } catch (error) { + if (!silent) { + setSSHResult(`Connection failed: ${errorText(error)}`, 'error'); + } + buttonEl('cal-btn-ssh-connect').disabled = false; + } +} + +async function handleSSHDisconnect(): Promise { + try { + await apiRequest('/api/calibration/ssh/disconnect', calBody()); + setSSHResult('Disconnected', 'info'); + await updateSSHStatus(); + } catch (error) { + setSSHResult(`Disconnect error: ${errorText(error)}`, 'error'); + } +} + +async function handleSSHTest(): Promise { + const testButton = buttonEl('cal-btn-ssh-test'); + try { + testButton.disabled = true; + setSSHResult('Testing connection...', 'info'); + + const connect = await apiRequest('/api/calibration/ssh/connect', calBody()); + if (!connect.success) { + throw new Error(connect.error || 'Connection failed'); + } + + const result = await apiRequest( + '/api/calibration/ssh/execute', + calBody({ command: 'echo "Connection test"' }) + ); + if (result.success && result.result?.success !== false) { + setSSHResult('Connection test successful!', 'success'); + } else { + setSSHResult(`Command failed: ${result.error || result.result?.error || result.result?.stderr || 'Unknown error'}`, 'error'); + } + + await updateSSHStatus(); + } catch (error) { + setSSHResult(`Test failed: ${errorText(error)}`, 'error'); + } finally { + testButton.disabled = false; + } +} + +function setSSHResult(message: string, type: 'success' | 'error' | 'info'): void { + const resultEl = el('cal-ssh-test-result'); + resultEl.textContent = message; + resultEl.className = 'cal-ssh-test-result'; + if (type !== 'info') { + resultEl.classList.add(type); + } +} + +/** Persist the calibration-specific remote config path override. */ +async function persistConfigPath(): Promise { + try { + await apiRequest( + '/api/calibration/ssh/config', + calBody({ configPath: inputEl('cal-ssh-config-path').value.trim() }) + ); + } catch (error) { + console.error('[Calibration] Failed to save config path:', error); + } +} + +// --------------------------------------------------------------------------- +// History +// --------------------------------------------------------------------------- + +async function loadHistory(): Promise { + try { + const result = await apiRequest(calQuery('/api/calibration/history')); + const history = result.success && result.history ? result.history : []; + + if (history.length === 0) { + el('cal-history-list').innerHTML = + '
No calibration history for this printer
'; + return; + } + + el('cal-history-list').innerHTML = history + .map( + (entry: CalibrationHistoryEntry) => ` +
+
+ ${entry.type === 'bed_level' ? 'Bed Leveling' : 'Input Shaper'} + ${entry.summary} + ${new Date(entry.timestamp).toLocaleString()} +
+
` + ) + .join(''); + } catch (error) { + console.error('[Calibration] Failed to load history:', error); + } +} + +async function handleClearHistory(): Promise { + if (!window.confirm('Are you sure you want to clear calibration history?')) { + return; + } + + try { + await apiRequest(calQuery('/api/calibration/history'), { method: 'DELETE' }); + await loadHistory(); + setStatus('History cleared'); + } catch (error) { + setStatus(`Error clearing history: ${errorText(error)}`); + } +} + +// --------------------------------------------------------------------------- +// Report export +// --------------------------------------------------------------------------- + +async function handleExport(): Promise { + if (!calState.meshData) { + setStatus('Load mesh data before exporting a report'); + return; + } + + const format = selectEl('cal-export-format').value || 'json'; + try { + setStatus(`Exporting ${format.toUpperCase()} report...`); + // The report endpoint streams the raw payload (JSON/CSV text or PNG/PDF + // binary) rather than a JSON envelope, so it needs a direct fetch. + const response = await fetch(calQuery('/api/calibration/report', { format }), { + headers: buildAuthHeaders(), + }); + if (!response.ok) { + let message = `HTTP ${response.status}`; + try { + const body = (await response.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + // Non-JSON error body; keep the HTTP status message. + } + setStatus(`Export error: ${message}`); + return; + } + + const blob = await response.blob(); + downloadBlob(blob, `calibration-report.${format}`); + setStatus(`Report downloaded (calibration-report.${format})`); + } catch (error) { + setStatus(`Export error: ${errorText(error)}`); + } +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +/** Wire the topbar button and all modal event handlers. Call once at startup. */ +export function setupCalibration(): void { + document.getElementById('calibration-button')?.addEventListener('click', () => void openCalibration()); + + el('cal-close').addEventListener('click', closeCalibration); + el('cal-close-footer').addEventListener('click', closeCalibration); + + document.querySelectorAll('.cal-tab-button').forEach((button) => { + button.addEventListener('click', () => switchTab(button.dataset.tab || 'bed')); + }); + + el('cal-btn-load-file').addEventListener('click', () => void handleLoadConfigFile()); + el('cal-btn-fetch-ssh').addEventListener('click', () => void handleFetchConfigSSH()); + selectEl('cal-profile-select').addEventListener('change', () => { + const profile = selectEl('cal-profile-select').value; + if (profile) { + void loadMeshProfile(profile); + } + }); + el('cal-btn-run-workflow').addEventListener('click', () => void handleRunWorkflow()); + + el('cal-btn-axis-x').addEventListener('click', () => switchAxis('x')); + el('cal-btn-axis-y').addEventListener('click', () => switchAxis('y')); + el('cal-btn-load-csv').addEventListener('click', () => void handleLoadShaperCSV()); + el('cal-btn-fetch-shaper').addEventListener('click', () => void handleFetchShaperSSH()); + el('cal-btn-copy-config').addEventListener('click', () => void handleCopyShaperConfig()); + el('cal-btn-save-config').addEventListener('click', () => void handleSaveShaperConfig()); + el('cal-btn-upload-config').addEventListener('click', () => void handleUploadShaperConfig()); + + el('cal-btn-ssh-connect').addEventListener('click', () => void handleSSHConnect()); + el('cal-btn-ssh-disconnect').addEventListener('click', () => void handleSSHDisconnect()); + el('cal-btn-ssh-test').addEventListener('click', () => void handleSSHTest()); + inputEl('cal-ssh-config-path').addEventListener('change', () => void persistConfigPath()); + + el('cal-btn-clear-history').addEventListener('click', () => void handleClearHistory()); + + el('cal-btn-export').addEventListener('click', () => void handleExport()); + + el('cal-btn-visual-rec').addEventListener('click', handleShowVisualRecommendations); + el('cal-visual-rec-close').addEventListener('click', hideVisualRecommendations); + el('cal-visual-rec-overlay').addEventListener('click', (event) => { + if (event.target === el('cal-visual-rec-overlay')) { + hideVisualRecommendations(); + } + }); +} diff --git a/src/webui/static/features/calibration/AnimatedRecommendationVisualizer.ts b/src/webui/static/features/calibration/AnimatedRecommendationVisualizer.ts new file mode 100644 index 0000000..20cfb7c --- /dev/null +++ b/src/webui/static/features/calibration/AnimatedRecommendationVisualizer.ts @@ -0,0 +1,233 @@ +/** + * @fileoverview Animated visualization for screw adjustments and tape + * recommendations (WebUI copy). Browser-pure port of the desktop + * AnimatedRecommendationVisualizer: draws the bed outline with pulsing + * rotation arrows per corner and tape-layer markers. + */ + +import type { ScrewAdjustment, TapeRecommendation } from './types.js'; + +export interface RecommendationVisualizerOptions { + width: number; + height: number; + padding: number; + backgroundColor: string; + bedColor: string; + gridColor: string; + textColor: string; + cwColor: string; + ccwColor: string; + tapeColor: string; + fontSize: number; +} + +const DEFAULT_OPTIONS: RecommendationVisualizerOptions = { + width: 620, + height: 420, + padding: 40, + backgroundColor: '#141414', + bedColor: '#1f1f1f', + gridColor: 'rgba(255,255,255,0.1)', + textColor: '#e0e0e0', + cwColor: '#4caf50', + ccwColor: '#ff9800', + tapeColor: '#5cc8ff', + fontSize: 12, +}; + +export class AnimatedRecommendationVisualizer { + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private readonly dpr: number; + private options: RecommendationVisualizerOptions; + private screwAdjustments: ScrewAdjustment[] = []; + private tapeRecommendations: TapeRecommendation[] = []; + private animationFrame: number | null = null; + private startTime = 0; + + constructor(canvas: HTMLCanvasElement, options: Partial = {}) { + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get 2D rendering context'); + } + this.ctx = ctx; + this.dpr = window.devicePixelRatio || 1; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.setupCanvas(); + } + + private setupCanvas(): void { + const { width, height } = this.options; + this.canvas.width = width * this.dpr; + this.canvas.height = height * this.dpr; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + this.setupCanvas(); + this.render(0); + } + + setRecommendations(adjustments: ScrewAdjustment[], tape: TapeRecommendation[]): void { + this.screwAdjustments = adjustments; + this.tapeRecommendations = tape; + this.render(0); + } + + start(): void { + if (this.animationFrame !== null) return; + this.startTime = performance.now(); + const loop = (timestamp: number) => { + this.render(timestamp); + this.animationFrame = requestAnimationFrame(loop); + }; + this.animationFrame = requestAnimationFrame(loop); + } + + stop(): void { + if (this.animationFrame !== null) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + } + + private getCornerPositions(): Record { + const { width, height, padding } = this.options; + const bedWidth = width - padding * 2; + const bedHeight = height - padding * 2; + const left = padding; + const right = padding + bedWidth; + const top = padding; + const bottom = padding + bedHeight; + + return { + frontLeft: { x: left, y: bottom }, + frontRight: { x: right, y: bottom }, + rearLeft: { x: left, y: top }, + rearRight: { x: right, y: top }, + }; + } + + private drawBed(): void { + const { width, height, padding, bedColor, gridColor } = this.options; + const bedWidth = width - padding * 2; + const bedHeight = height - padding * 2; + + this.ctx.fillStyle = bedColor; + this.ctx.fillRect(padding, padding, bedWidth, bedHeight); + + this.ctx.strokeStyle = gridColor; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(padding, padding, bedWidth, bedHeight); + + const gridCount = 4; + for (let i = 1; i < gridCount; i++) { + const x = padding + (i / gridCount) * bedWidth; + const y = padding + (i / gridCount) * bedHeight; + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, padding + bedHeight); + this.ctx.stroke(); + this.ctx.beginPath(); + this.ctx.moveTo(padding, y); + this.ctx.lineTo(padding + bedWidth, y); + this.ctx.stroke(); + } + } + + private drawScrewAdjustment(corner: { x: number; y: number }, adjustment: ScrewAdjustment, pulse: number): void { + const { cwColor, ccwColor, textColor, fontSize } = this.options; + const color = adjustment.direction === 'CW' ? cwColor : ccwColor; + const radius = 18 + pulse * 4; + + this.ctx.strokeStyle = color; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + const startAngle = adjustment.direction === 'CW' ? Math.PI * 1.1 : Math.PI * 0.1; + const endAngle = adjustment.direction === 'CW' ? Math.PI * 0.1 : Math.PI * 1.1; + this.ctx.arc(corner.x, corner.y, radius, startAngle, endAngle, adjustment.direction !== 'CW'); + this.ctx.stroke(); + + const arrowAngle = adjustment.direction === 'CW' ? endAngle : startAngle; + const arrowX = corner.x + radius * Math.cos(arrowAngle); + const arrowY = corner.y + radius * Math.sin(arrowAngle); + const arrowSize = 6; + this.ctx.fillStyle = color; + this.ctx.beginPath(); + this.ctx.moveTo(arrowX, arrowY); + this.ctx.lineTo( + arrowX - arrowSize * Math.cos(arrowAngle - Math.PI / 6), + arrowY - arrowSize * Math.sin(arrowAngle - Math.PI / 6) + ); + this.ctx.lineTo( + arrowX - arrowSize * Math.cos(arrowAngle + Math.PI / 6), + arrowY - arrowSize * Math.sin(arrowAngle + Math.PI / 6) + ); + this.ctx.closePath(); + this.ctx.fill(); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'bottom'; + this.ctx.fillText(adjustment.formattedAmount, corner.x, corner.y - radius - 6); + } + + private drawTapeRecommendation(corner: { x: number; y: number }, recommendation: TapeRecommendation): void { + const { tapeColor, textColor, fontSize } = this.options; + const size = 16; + const offset = 10; + + this.ctx.fillStyle = tapeColor; + this.ctx.fillRect(corner.x - size / 2, corner.y - offset - size / 2, size, size); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText( + `${recommendation.layers} layer${recommendation.layers === 1 ? '' : 's'}`, + corner.x, + corner.y + 8 + ); + } + + private render(timestamp: number): void { + const { width, height, backgroundColor, textColor, fontSize } = this.options; + + this.ctx.fillStyle = backgroundColor; + this.ctx.fillRect(0, 0, width, height); + + this.drawBed(); + + const cornerPositions = this.getCornerPositions(); + const pulse = 0.5 + 0.5 * Math.sin((timestamp - this.startTime) / 400); + + const activeAdjustments = this.screwAdjustments.filter((adj) => adj.requiresAdjustment); + activeAdjustments.forEach((adj) => { + const corner = cornerPositions[adj.corner]; + if (corner) { + this.drawScrewAdjustment(corner, adj, pulse); + } + }); + + this.tapeRecommendations.forEach((rec) => { + const corner = cornerPositions[rec.corner]; + if (corner) { + this.drawTapeRecommendation(corner, rec); + } + }); + + if (activeAdjustments.length === 0 && this.tapeRecommendations.length === 0) { + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize + 2}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText('No visual adjustments needed', width / 2, height / 2); + } + } +} diff --git a/src/webui/static/features/calibration/BedMeshVisualizer.ts b/src/webui/static/features/calibration/BedMeshVisualizer.ts new file mode 100644 index 0000000..6ece382 --- /dev/null +++ b/src/webui/static/features/calibration/BedMeshVisualizer.ts @@ -0,0 +1,453 @@ +/** + * @fileoverview Canvas-based bed mesh visualization for the WebUI client. + * Browser-pure port of the desktop BedMeshVisualizer: renders a 2D heatmap of + * bed mesh data with color-coded height values, hover tooltips, grid lines, + * value labels, and a color bar legend. + */ + +import { type ColorScheme, createColorMapper, getColor, rgbToCSS } from './ColorScales.js'; +import type { AnalysisResult, MeshData } from './types.js'; + +/** Configuration options for the visualizer. */ +export interface VisualizerOptions { + width: number; + height: number; + padding: number; + colorScheme: ColorScheme; + showGrid: boolean; + showLabels: boolean; + showCorners: boolean; + interpolationFactor: number; + fontSize: number; + gridColor: string; + labelColor: string; + backgroundColor: string; +} + +/** Default visualizer options. */ +export const DEFAULT_VISUALIZER_OPTIONS: VisualizerOptions = { + width: 400, + height: 400, + padding: 40, + colorScheme: 'viridis', + showGrid: true, + showLabels: true, + showCorners: true, + interpolationFactor: 1, + fontSize: 10, + gridColor: 'rgba(255, 255, 255, 0.3)', + labelColor: '#ffffff', + backgroundColor: '#1a1a1a', +}; + +/** Cell information for hover/click events. */ +export interface CellInfo { + row: number; + col: number; + value: number; + x: number; + y: number; +} + +/** Event handlers for the visualizer. */ +export interface VisualizerEventHandlers { + onCellHover?: (cell: CellInfo | null) => void; + onCellClick?: (cell: CellInfo) => void; +} + +/** Canvas-based bed mesh visualizer. */ +export class BedMeshVisualizer { + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private meshData: MeshData | null = null; + private analysisResult: AnalysisResult | null = null; + private options: VisualizerOptions; + private handlers: VisualizerEventHandlers = {}; + private hoveredCell: CellInfo | null = null; + private interpolatedMesh: number[][] | null = null; + private readonly dpr: number; + + constructor(canvas: HTMLCanvasElement, options: Partial = {}) { + this.canvas = canvas; + this.options = { ...DEFAULT_VISUALIZER_OPTIONS, ...options }; + this.dpr = window.devicePixelRatio || 1; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get 2D rendering context'); + } + this.ctx = ctx; + + this.setupCanvas(); + this.setupEventListeners(); + } + + private setupCanvas(): void { + const { width, height } = this.options; + this.canvas.width = width * this.dpr; + this.canvas.height = height * this.dpr; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + private setupEventListeners(): void { + this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); + this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); + this.canvas.addEventListener('click', this.handleClick.bind(this)); + } + + private handleMouseMove(event: MouseEvent): void { + const cell = this.getCellAtPosition(event.offsetX, event.offsetY); + if (cell !== this.hoveredCell) { + this.hoveredCell = cell; + this.handlers.onCellHover?.(cell); + this.render(); + } + } + + private handleMouseLeave(): void { + if (this.hoveredCell) { + this.hoveredCell = null; + this.handlers.onCellHover?.(null); + this.render(); + } + } + + private handleClick(event: MouseEvent): void { + const cell = this.getCellAtPosition(event.offsetX, event.offsetY); + if (cell) { + this.handlers.onCellClick?.(cell); + } + } + + private getCellAtPosition(x: number, y: number): CellInfo | null { + if (!this.meshData) return null; + + const { width, height, padding } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + + if (x < padding || x > width - padding || y < padding || y > height - padding) { + return null; + } + + const { pointsX, pointsY, matrix } = this.meshData; + const cellWidth = meshWidth / pointsX; + const cellHeight = meshHeight / pointsY; + + const col = Math.floor((x - padding) / cellWidth); + const row = Math.floor((y - padding) / cellHeight); + + if (row >= 0 && row < pointsY && col >= 0 && col < pointsX) { + return { + row, + col, + value: matrix[row][col], + x: padding + col * cellWidth + cellWidth / 2, + y: padding + row * cellHeight + cellHeight / 2, + }; + } + + return null; + } + + setEventHandlers(handlers: VisualizerEventHandlers): void { + this.handlers = handlers; + } + + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + this.interpolatedMesh = null; + this.setupCanvas(); + this.render(); + } + + setMeshData(meshData: MeshData | null, analysisResult?: AnalysisResult | null): void { + this.meshData = meshData; + this.analysisResult = analysisResult ?? null; + this.interpolatedMesh = null; + this.render(); + } + + /** Latest analysis result associated with the current mesh (may be null). */ + getAnalysisResult(): AnalysisResult | null { + return this.analysisResult; + } + + private interpolateMesh(): number[][] { + if (!this.meshData) return []; + + const factor = this.options.interpolationFactor; + if (factor <= 1) return this.meshData.matrix; + + const { matrix, pointsX, pointsY } = this.meshData; + const newWidth = (pointsX - 1) * factor + 1; + const newHeight = (pointsY - 1) * factor + 1; + const result: number[][] = []; + + for (let y = 0; y < newHeight; y++) { + const row: number[] = []; + for (let x = 0; x < newWidth; x++) { + const srcX = x / factor; + const srcY = y / factor; + const x0 = Math.floor(srcX); + const y0 = Math.floor(srcY); + const x1 = Math.min(x0 + 1, pointsX - 1); + const y1 = Math.min(y0 + 1, pointsY - 1); + const tx = srcX - x0; + const ty = srcY - y0; + + const v00 = matrix[y0][x0]; + const v01 = matrix[y0][x1]; + const v10 = matrix[y1][x0]; + const v11 = matrix[y1][x1]; + + const v0 = v00 * (1 - tx) + v01 * tx; + const v1 = v10 * (1 - tx) + v11 * tx; + row.push(v0 * (1 - ty) + v1 * ty); + } + result.push(row); + } + + return result; + } + + private getMeshBounds(): { min: number; max: number } { + if (!this.meshData) { + return { min: 0, max: 1 }; + } + + let min = Infinity; + let max = -Infinity; + for (const row of this.meshData.matrix) { + for (const value of row) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + return { min, max }; + } + + render(): void { + const { width, height, backgroundColor } = this.options; + + this.ctx.fillStyle = backgroundColor; + this.ctx.fillRect(0, 0, width, height); + + if (!this.meshData) { + this.renderEmptyState(); + return; + } + + if (!this.interpolatedMesh) { + this.interpolatedMesh = this.interpolateMesh(); + } + + this.renderHeatmap(); + this.renderGrid(); + this.renderLabels(); + this.renderCorners(); + this.renderHover(); + this.renderColorBar(); + } + + private renderEmptyState(): void { + const { width, height, labelColor } = this.options; + this.ctx.fillStyle = labelColor; + this.ctx.font = '14px sans-serif'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText('No mesh data loaded', width / 2, height / 2); + } + + private renderHeatmap(): void { + if (!this.interpolatedMesh || this.interpolatedMesh.length === 0) return; + + const { width, height, padding, colorScheme } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + + const rows = this.interpolatedMesh.length; + const cols = this.interpolatedMesh[0].length; + const cellWidth = meshWidth / cols; + const cellHeight = meshHeight / rows; + + const { min, max } = this.getMeshBounds(); + const colorMapper = createColorMapper(colorScheme, min, max); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const value = this.interpolatedMesh[row][col]; + const x = padding + col * cellWidth; + const y = padding + row * cellHeight; + + this.ctx.fillStyle = colorMapper(value); + this.ctx.fillRect(x, y, cellWidth + 0.5, cellHeight + 0.5); + } + } + } + + private renderGrid(): void { + if (!this.options.showGrid || !this.meshData) return; + + const { width, height, padding, gridColor } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + const { pointsX, pointsY } = this.meshData; + + this.ctx.strokeStyle = gridColor; + this.ctx.lineWidth = 1; + this.ctx.beginPath(); + + for (let i = 0; i <= pointsX; i++) { + const x = padding + (i / pointsX) * meshWidth; + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, height - padding); + } + + for (let i = 0; i <= pointsY; i++) { + const y = padding + (i / pointsY) * meshHeight; + this.ctx.moveTo(padding, y); + this.ctx.lineTo(width - padding, y); + } + + this.ctx.stroke(); + } + + private renderLabels(): void { + if (!this.options.showLabels || !this.meshData) return; + + const { width, height, padding, fontSize, labelColor } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + const { pointsX, pointsY, matrix } = this.meshData; + + const cellWidth = meshWidth / pointsX; + const cellHeight = meshHeight / pointsY; + + this.ctx.fillStyle = labelColor; + this.ctx.font = `${fontSize}px monospace`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + + for (let row = 0; row < pointsY; row++) { + for (let col = 0; col < pointsX; col++) { + const value = matrix[row][col]; + const x = padding + col * cellWidth + cellWidth / 2; + const y = padding + row * cellHeight + cellHeight / 2; + + this.ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + this.ctx.shadowBlur = 2; + this.ctx.fillText(value.toFixed(3), x, y); + this.ctx.shadowBlur = 0; + } + } + } + + private renderCorners(): void { + if (!this.options.showCorners || !this.meshData) return; + + const { width, height, padding, fontSize, labelColor } = this.options; + + const corners = [ + { x: padding, y: height - padding }, + { x: width - padding, y: height - padding }, + { x: padding, y: padding }, + { x: width - padding, y: padding }, + ]; + + this.ctx.fillStyle = labelColor; + this.ctx.font = `${fontSize}px sans-serif`; + + for (const corner of corners) { + this.ctx.beginPath(); + this.ctx.arc(corner.x, corner.y, 4, 0, Math.PI * 2); + this.ctx.fill(); + } + } + + private renderHover(): void { + if (!this.hoveredCell || !this.meshData) return; + + const { width, height, padding, labelColor } = this.options; + const meshWidth = width - padding * 2; + const meshHeight = height - padding * 2; + const { pointsX, pointsY } = this.meshData; + + const cellWidth = meshWidth / pointsX; + const cellHeight = meshHeight / pointsY; + + const { row, col, value } = this.hoveredCell; + const x = padding + col * cellWidth; + const y = padding + row * cellHeight; + + this.ctx.strokeStyle = '#ffffff'; + this.ctx.lineWidth = 2; + this.ctx.strokeRect(x, y, cellWidth, cellHeight); + + const tooltipText = `[${row}, ${col}]: ${value.toFixed(4)} mm`; + const tooltipPadding = 5; + this.ctx.font = '12px monospace'; + const textWidth = this.ctx.measureText(tooltipText).width; + + let tooltipX = x + cellWidth / 2 - textWidth / 2 - tooltipPadding; + let tooltipY = y - 25; + + tooltipX = Math.max(5, Math.min(width - textWidth - tooltipPadding * 2 - 5, tooltipX)); + if (tooltipY < 5) tooltipY = y + cellHeight + 5; + + this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + this.ctx.fillRect(tooltipX, tooltipY, textWidth + tooltipPadding * 2, 20); + + this.ctx.fillStyle = labelColor; + this.ctx.textAlign = 'left'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(tooltipText, tooltipX + tooltipPadding, tooltipY + 10); + } + + private renderColorBar(): void { + const { width, height, padding, colorScheme, labelColor, fontSize } = this.options; + const { min, max } = this.getMeshBounds(); + + const barWidth = 15; + const barHeight = height - padding * 2; + const barX = width - padding / 2 - barWidth / 2; + const barY = padding; + + const gradient = this.ctx.createLinearGradient(barX, barY + barHeight, barX, barY); + const steps = 10; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + gradient.addColorStop(t, rgbToCSS(getColor(colorScheme, t))); + } + + this.ctx.fillStyle = gradient; + this.ctx.fillRect(barX, barY, barWidth, barHeight); + + this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(barX, barY, barWidth, barHeight); + + this.ctx.fillStyle = labelColor; + this.ctx.font = `${fontSize - 1}px monospace`; + + const ticks = [0, 0.25, 0.5, 0.75, 1]; + for (const t of ticks) { + const y = barY + barHeight * (1 - t); + this.ctx.beginPath(); + this.ctx.moveTo(barX + barWidth, y); + this.ctx.lineTo(barX + barWidth + 3, y); + this.ctx.stroke(); + } + + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(max.toFixed(3), barX + barWidth / 2, barY - 8); + this.ctx.fillText(min.toFixed(3), barX + barWidth / 2, barY + barHeight + 8); + } + + toDataURL(format: 'png' | 'jpeg' = 'png'): string { + return this.canvas.toDataURL(`image/${format}`); + } +} diff --git a/src/webui/static/features/calibration/ColorScales.ts b/src/webui/static/features/calibration/ColorScales.ts new file mode 100644 index 0000000..f3fadb1 --- /dev/null +++ b/src/webui/static/features/calibration/ColorScales.ts @@ -0,0 +1,135 @@ +/** + * @fileoverview Color scale utilities for heatmap visualization (WebUI copy). + * Browser-pure port of src/renderer/src/ui/calibration/visualization/ColorScales.ts + * for the static WebUI client (which cannot import renderer modules). + * Supports multiple color schemes: viridis, plasma, inferno, coolwarm. + */ + +/** RGB color tuple. */ +export type RGB = [number, number, number]; + +/** Available color scheme names. */ +export type ColorScheme = 'viridis' | 'plasma' | 'inferno' | 'coolwarm'; + +/** Color stop definition for gradient. */ +export interface ColorStop { + position: number; + color: RGB; +} + +const VIRIDIS_STOPS: ColorStop[] = [ + { position: 0.0, color: [68, 1, 84] }, + { position: 0.1, color: [72, 40, 120] }, + { position: 0.2, color: [62, 74, 137] }, + { position: 0.3, color: [49, 104, 142] }, + { position: 0.4, color: [38, 130, 142] }, + { position: 0.5, color: [31, 158, 137] }, + { position: 0.6, color: [53, 183, 121] }, + { position: 0.7, color: [109, 205, 89] }, + { position: 0.8, color: [180, 222, 44] }, + { position: 0.9, color: [223, 227, 24] }, + { position: 1.0, color: [253, 231, 37] }, +]; + +const PLASMA_STOPS: ColorStop[] = [ + { position: 0.0, color: [13, 8, 135] }, + { position: 0.1, color: [75, 3, 161] }, + { position: 0.2, color: [125, 3, 168] }, + { position: 0.3, color: [168, 34, 150] }, + { position: 0.4, color: [203, 70, 121] }, + { position: 0.5, color: [229, 107, 93] }, + { position: 0.6, color: [248, 148, 65] }, + { position: 0.7, color: [253, 195, 40] }, + { position: 0.8, color: [240, 249, 33] }, + { position: 1.0, color: [240, 249, 33] }, +]; + +const INFERNO_STOPS: ColorStop[] = [ + { position: 0.0, color: [0, 0, 4] }, + { position: 0.1, color: [40, 11, 84] }, + { position: 0.2, color: [89, 13, 115] }, + { position: 0.3, color: [137, 27, 100] }, + { position: 0.4, color: [181, 50, 64] }, + { position: 0.5, color: [219, 87, 26] }, + { position: 0.6, color: [244, 130, 7] }, + { position: 0.7, color: [252, 180, 31] }, + { position: 0.8, color: [250, 230, 102] }, + { position: 1.0, color: [252, 255, 164] }, +]; + +const COOLWARM_STOPS: ColorStop[] = [ + { position: 0.0, color: [59, 76, 192] }, + { position: 0.1, color: [98, 130, 234] }, + { position: 0.2, color: [141, 176, 254] }, + { position: 0.3, color: [184, 208, 249] }, + { position: 0.4, color: [221, 221, 221] }, + { position: 0.5, color: [245, 245, 245] }, + { position: 0.6, color: [249, 196, 178] }, + { position: 0.7, color: [244, 154, 123] }, + { position: 0.8, color: [221, 96, 73] }, + { position: 0.9, color: [192, 40, 40] }, + { position: 1.0, color: [180, 4, 38] }, +]; + +const COLOR_SCALES: Record = { + viridis: VIRIDIS_STOPS, + plasma: PLASMA_STOPS, + inferno: INFERNO_STOPS, + coolwarm: COOLWARM_STOPS, +}; + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +/** Get color from a color scale at a normalized position (0-1). */ +export function getColor(scheme: ColorScheme, t: number): RGB { + const stops = COLOR_SCALES[scheme]; + const normalizedT = clamp01(t); + + let lowerStop = stops[0]; + let upperStop = stops[stops.length - 1]; + + for (let i = 0; i < stops.length - 1; i++) { + if (normalizedT >= stops[i].position && normalizedT <= stops[i + 1].position) { + lowerStop = stops[i]; + upperStop = stops[i + 1]; + break; + } + } + + const range = upperStop.position - lowerStop.position; + const localT = range > 0 ? (normalizedT - lowerStop.position) / range : 0; + + return [ + Math.round(lerp(lowerStop.color[0], upperStop.color[0], localT)), + Math.round(lerp(lowerStop.color[1], upperStop.color[1], localT)), + Math.round(lerp(lowerStop.color[2], upperStop.color[2], localT)), + ]; +} + +/** Convert RGB to CSS color string. */ +export function rgbToCSS(rgb: RGB, alpha?: number): string { + if (alpha !== undefined) { + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`; + } + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; +} + +/** Get CSS color from color scale at normalized position. */ +export function getCSSColor(scheme: ColorScheme, t: number, alpha?: number): string { + return rgbToCSS(getColor(scheme, t), alpha); +} + +/** Create a color mapper for a given value range. */ +export function createColorMapper(scheme: ColorScheme, min: number, max: number): (value: number) => string { + const range = max - min; + return (value: number): string => { + const t = range > 0 ? (value - min) / range : 0.5; + return getCSSColor(scheme, t); + }; +} diff --git a/src/webui/static/features/calibration/ShaperPlotVisualizer.ts b/src/webui/static/features/calibration/ShaperPlotVisualizer.ts new file mode 100644 index 0000000..47c7818 --- /dev/null +++ b/src/webui/static/features/calibration/ShaperPlotVisualizer.ts @@ -0,0 +1,272 @@ +/** + * @fileoverview Canvas visualization for input shaper frequency analysis (WebUI copy). + * Browser-pure port of the desktop ShaperPlotVisualizer: renders power spectral + * density plots with peak markers and the recommended shaper frequency. + */ + +import type { AxisCalibration } from './types.js'; + +export interface ShaperPlotOptions { + width: number; + height: number; + padding: number; + backgroundColor: string; + axisColor: string; + gridColor: string; + lineColor: string; + peakColor: string; + recommendationColor: string; + textColor: string; + fontSize: number; +} + +const DEFAULT_OPTIONS: ShaperPlotOptions = { + width: 600, + height: 300, + padding: 40, + backgroundColor: '#1a1a1a', + axisColor: 'rgba(255, 255, 255, 0.7)', + gridColor: 'rgba(255, 255, 255, 0.08)', + lineColor: '#5cc8ff', + peakColor: '#ffb74d', + recommendationColor: '#4caf50', + textColor: '#e0e0e0', + fontSize: 11, +}; + +export class ShaperPlotVisualizer { + private readonly canvas: HTMLCanvasElement; + private readonly ctx: CanvasRenderingContext2D; + private readonly dpr: number; + private options: ShaperPlotOptions; + private calibration: AxisCalibration | null = null; + + constructor(canvas: HTMLCanvasElement, options: Partial = {}) { + this.canvas = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not get 2D rendering context'); + } + this.ctx = ctx; + this.dpr = window.devicePixelRatio || 1; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.setupCanvas(); + } + + private setupCanvas(): void { + const { width, height } = this.options; + this.canvas.width = width * this.dpr; + this.canvas.height = height * this.dpr; + this.canvas.style.width = `${width}px`; + this.canvas.style.height = `${height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + } + + updateOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + this.setupCanvas(); + this.render(); + } + + setCalibration(calibration: AxisCalibration | null): void { + this.calibration = calibration; + this.render(); + } + + private renderEmptyState(): void { + const { width, height, textColor } = this.options; + this.ctx.fillStyle = textColor; + this.ctx.font = '14px sans-serif'; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText('No input shaper data loaded', width / 2, height / 2); + } + + private getPlotBounds(): { minFreq: number; maxFreq: number; maxPower: number } { + if (!this.calibration) { + return { minFreq: 0, maxFreq: 1, maxPower: 1 }; + } + const freqs = this.calibration.frequencyBins; + const power = this.calibration.powerSpectralDensity; + const minFreq = freqs[0] ?? 0; + const maxFreq = freqs[freqs.length - 1] ?? 1; + const maxPower = Math.max(1e-6, ...power); + return { minFreq, maxFreq, maxPower }; + } + + private drawGrid(): void { + const { width, height, padding, gridColor } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + this.ctx.strokeStyle = gridColor; + this.ctx.lineWidth = 1; + + const verticalLines = 6; + for (let i = 0; i <= verticalLines; i++) { + const x = padding + (i / verticalLines) * plotWidth; + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, height - padding); + this.ctx.stroke(); + } + + const horizontalLines = 4; + for (let i = 0; i <= horizontalLines; i++) { + const y = padding + (i / horizontalLines) * plotHeight; + this.ctx.beginPath(); + this.ctx.moveTo(padding, y); + this.ctx.lineTo(width - padding, y); + this.ctx.stroke(); + } + } + + private drawAxes(bounds: { minFreq: number; maxFreq: number; maxPower: number }): void { + const { width, height, padding, axisColor, textColor, fontSize } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + this.ctx.strokeStyle = axisColor; + this.ctx.lineWidth = 1.5; + + this.ctx.beginPath(); + this.ctx.moveTo(padding, height - padding); + this.ctx.lineTo(width - padding, height - padding); + this.ctx.stroke(); + + this.ctx.beginPath(); + this.ctx.moveTo(padding, padding); + this.ctx.lineTo(padding, height - padding); + this.ctx.stroke(); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText('Frequency (Hz)', padding + plotWidth / 2, height - padding + 10); + + this.ctx.save(); + this.ctx.translate(padding - 28, padding + plotHeight / 2); + this.ctx.rotate(-Math.PI / 2); + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText('Normalized Power', 0, 0); + this.ctx.restore(); + + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + const freqTicks = 4; + for (let i = 0; i <= freqTicks; i++) { + const value = bounds.minFreq + (i / freqTicks) * (bounds.maxFreq - bounds.minFreq); + const x = padding + (i / freqTicks) * plotWidth; + this.ctx.fillText(value.toFixed(0), x, height - padding + 2); + } + + this.ctx.textAlign = 'right'; + this.ctx.textBaseline = 'middle'; + const powerTicks = 3; + for (let i = 0; i <= powerTicks; i++) { + const value = (1 - i / powerTicks) * bounds.maxPower; + const y = padding + (i / powerTicks) * plotHeight; + this.ctx.fillText(value.toFixed(2), padding - 6, y); + } + } + + private drawSpectrum(bounds: { minFreq: number; maxFreq: number; maxPower: number }): void { + if (!this.calibration) return; + + const { width, height, padding, lineColor } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + const freqs = this.calibration.frequencyBins; + const power = this.calibration.powerSpectralDensity; + + if (freqs.length === 0 || power.length === 0) return; + + const scaleX = plotWidth / (bounds.maxFreq - bounds.minFreq || 1); + const scaleY = plotHeight / (bounds.maxPower || 1); + + this.ctx.strokeStyle = lineColor; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + + freqs.forEach((freq, index) => { + const x = padding + (freq - bounds.minFreq) * scaleX; + const y = padding + plotHeight - power[index] * scaleY; + if (index === 0) { + this.ctx.moveTo(x, y); + } else { + this.ctx.lineTo(x, y); + } + }); + + this.ctx.stroke(); + } + + private drawMarkers(bounds: { minFreq: number; maxFreq: number; maxPower: number }): void { + if (!this.calibration) return; + + const { width, height, padding, peakColor, recommendationColor, textColor, fontSize } = this.options; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + const scaleX = plotWidth / (bounds.maxFreq - bounds.minFreq || 1); + + this.calibration.peakFrequencies.forEach((freq) => { + const x = padding + (freq - bounds.minFreq) * scaleX; + this.ctx.strokeStyle = peakColor; + this.ctx.lineWidth = 1.5; + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, padding + plotHeight); + this.ctx.stroke(); + + this.ctx.fillStyle = peakColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'bottom'; + this.ctx.fillText(`${freq.toFixed(1)} Hz`, x, padding - 4); + }); + + const recommendedFreq = this.calibration.recommendedShaper.frequency; + if (recommendedFreq > 0) { + const x = padding + (recommendedFreq - bounds.minFreq) * scaleX; + this.ctx.strokeStyle = recommendationColor; + this.ctx.lineWidth = 2; + this.ctx.setLineDash([4, 4]); + this.ctx.beginPath(); + this.ctx.moveTo(x, padding); + this.ctx.lineTo(x, padding + plotHeight); + this.ctx.stroke(); + this.ctx.setLineDash([]); + + this.ctx.fillStyle = textColor; + this.ctx.font = `${fontSize}px sans-serif`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'top'; + this.ctx.fillText(`Recommended ${recommendedFreq.toFixed(1)} Hz`, x, padding + plotHeight + 6); + } + } + + render(): void { + const { width, height, backgroundColor } = this.options; + + this.ctx.fillStyle = backgroundColor; + this.ctx.fillRect(0, 0, width, height); + + if (!this.calibration) { + this.renderEmptyState(); + return; + } + + const bounds = this.getPlotBounds(); + this.drawGrid(); + this.drawAxes(bounds); + this.drawSpectrum(bounds); + this.drawMarkers(bounds); + } + + toDataURL(format: 'png' | 'jpeg' = 'png'): string { + return this.canvas.toDataURL(`image/${format}`); + } +} diff --git a/src/webui/static/features/calibration/types.ts b/src/webui/static/features/calibration/types.ts new file mode 100644 index 0000000..0022f9f --- /dev/null +++ b/src/webui/static/features/calibration/types.ts @@ -0,0 +1,133 @@ +/** + * @fileoverview Local calibration type definitions for the WebUI static client. + * + * JSON-shaped mirrors of the calibration types in + * src/shared/types/calibration.ts. The static client compiles with its own + * tsconfig and cannot import @shared modules, so the REST payload shapes are + * duplicated here. Enums are represented as string-literal unions because the + * values arrive as plain JSON strings, and WorkflowData omits the `stages` + * Map (it does not survive JSON serialization; the client only reads the + * scalar/array fields). + */ + +/** Direction of screw rotation for bed leveling adjustments. */ +export type RotationDirection = 'CW' | 'CCW'; + +/** Corner positions on the print bed. */ +export type BedCorner = 'frontLeft' | 'frontRight' | 'rearLeft' | 'rearRight'; + +/** Stages in the calibration workflow. */ +export type WorkflowStage = + | 'initial' + | 'belt_sync' + | 'screw_adjust' + | 'tape_compensate' + | 'thermal_predict' + | 'complete'; + +/** Height values at the four corners of the bed. */ +export interface BedCorners { + frontLeft: number; + frontRight: number; + rearLeft: number; + rearRight: number; +} + +/** Parsed mesh data from printer configuration. */ +export interface MeshData { + matrix: number[][]; + minX: number; + maxX: number; + minY: number; + maxY: number; + pointsX: number; + pointsY: number; + profileName: string; +} + +/** Calculated adjustment for a single bed corner screw. */ +export interface ScrewAdjustment { + corner: BedCorner; + deviation: number; + direction: RotationDirection; + minutes: number; + degrees: number; + turns: number; + formattedAmount: string; + requiresAdjustment: boolean; +} + +/** Tape layer recommendation for a single corner. */ +export interface TapeRecommendation { + corner: BedCorner; + layers: number; + totalThickness: number; + deviation: number; +} + +/** Results from analyzing mesh deviations. */ +export interface AnalysisResult { + meshRange: number; + maxDeviation: number; + minDeviation: number; + averageDeviation: number; + standardDeviation: number; + cornerDeviations: BedCorners; + referenceCorner: BedCorner; + recommendations: { + needsBeltSync: boolean; + needsScrewAdjust: boolean; + needsTapeCompensation: boolean; + }; +} + +/** + * Workflow computation data as it arrives over REST (the desktop type's + * `stages` Map is dropped by JSON serialization and never read here). + */ +export interface WorkflowData { + currentStage: WorkflowStage; + startTime: number; + completedStages: WorkflowStage[]; + screwAdjustments?: ScrewAdjustment[]; + tapeRecommendations?: TapeRecommendation[]; + initialRange: number; + finalRange: number; + improvementPercent: number; +} + +/** Result of evaluating a single input shaper type. */ +export interface ShaperResult { + type: string; + frequency: number; + vibrationReduction: number; + smoothingTime: number; + maxAcceleration: number; + score: number; +} + +/** Input shaper calibration results for a single axis. */ +export interface AxisCalibration { + axis: 'x' | 'y'; + frequencyBins: number[]; + powerSpectralDensity: number[]; + peakFrequencies: number[]; + recommendedShaper: ShaperResult; + allShaperResults: ShaperResult[]; +} + +/** Calibration history entry. */ +export interface CalibrationHistoryEntry { + timestamp: number; + type: 'bed_level' | 'input_shaper'; + summary: string; + data: unknown; +} + +/** Active calibration workspace for a printer context (REST shape). */ +export interface CalibrationWorkspace { + contextId: string; + meshData: MeshData | null; + analysis: AnalysisResult | null; + workflow: WorkflowData | null; +} diff --git a/src/webui/static/features/file-manager.ts b/src/webui/static/features/file-manager.ts new file mode 100644 index 0000000..b7454e1 --- /dev/null +++ b/src/webui/static/features/file-manager.ts @@ -0,0 +1,918 @@ +/** + * @fileoverview SFTP printer file manager feature for the WebUI client. + * + * Browser port of the desktop file manager dialog: a thumbnail grid over the + * printer's internal storage and plugged-in USB drives with multi-select + * batch delete, rename, and USB folder navigation with breadcrumbs. Talks to + * the authenticated /api/file-manager/* REST surface (backed by the + * main-process FileManagerService over SSH/SFTP). + * + * Context pinning: the desktop dialog pins its printer context in the main + * process at window-open time; the WebUI equivalent captures the current + * context id when the modal opens and sends it explicitly with every request, + * so switching the printer selector while the modal is open never retargets + * operations onto a different printer. + * + * Key exports: + * - setupFileManager(): wire the topbar button + modal event handlers + * - refreshFileManagerButton(): show/hide the topbar button per model support + */ + +import { showToast } from '../shared/dom.js'; +import { hydrateLucideIcons } from '../shared/icons.js'; +import { apiRequest } from '../core/Transport.js'; +import { getCurrentContextId } from './context-switching.js'; + +// --------------------------------------------------------------------------- +// API payload types (REST mirror of @shared/types/file-manager.ts) +// --------------------------------------------------------------------------- + +type FileManagerStorageKind = 'internal' | 'usb'; + +interface PrinterFileEntry { + readonly name: string; + readonly path: string; + readonly size: number; + readonly modifiedAt: number; + readonly isDirectory: boolean; +} + +interface FileManagerListing { + readonly success: boolean; + readonly storage: FileManagerStorageKind; + readonly path: string; + readonly rootPath: string; + readonly entries: readonly PrinterFileEntry[]; + readonly error?: string; +} + +interface FileManagerCapabilities { + readonly success?: boolean; + readonly supported: boolean; + readonly reason?: string; + readonly printerName?: string; + readonly usbPresent: boolean; + readonly usbMounts: readonly string[]; + readonly error?: string; +} + +interface FileManagerDeleteResult { + readonly success: boolean; + readonly outcomes: readonly { readonly path: string; readonly success: boolean; readonly error?: string }[]; + readonly error?: string; +} + +interface FileManagerRenameResult { + readonly success: boolean; + readonly newPath?: string; + readonly error?: string; +} + +interface FileManagerThumbnailResult { + readonly success: boolean; + readonly thumbnail?: string; + readonly error?: string; +} + +interface FileManagerSupportResponse { + readonly success: boolean; + readonly supported?: boolean; + readonly error?: string; +} + +// --------------------------------------------------------------------------- +// Constants + state +// --------------------------------------------------------------------------- + +/** Lucide icons used by the static markup and dynamically rendered tiles. */ +const FM_ICONS = [ + 'folder', + 'hard-drive', + 'usb', + 'refresh-cw', + 'check-square', + 'square', + 'trash-2', + 'pencil', + 'alert-triangle', + 'file', + 'check', + 'chevron-right', +]; + +/** Maximum concurrent thumbnail requests in flight. */ +const THUMBNAIL_CONCURRENCY = 4; + +interface FileManagerState { + /** Context pinned when the modal opened; null when the modal is closed. */ + pinnedContextId: string | null; + capabilities: FileManagerCapabilities | null; + storage: FileManagerStorageKind; + path: string; + rootPath: string; + entries: readonly PrinterFileEntry[]; + selection: Set; + /** Monotonic token: bumps invalidate in-flight listings + thumbnail runs. */ + runId: number; + busy: boolean; +} + +const fmState: FileManagerState = { + pinnedContextId: null, + capabilities: null, + storage: 'internal', + path: '', + rootPath: '', + entries: [], + selection: new Set(), + runId: 0, + busy: false, +}; + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function el(id: string): HTMLElement { + const element = document.getElementById(id); + if (!element) { + throw new Error(`[FileManager] Missing element #${id}`); + } + return element; +} + +function show(element: HTMLElement): void { + element.classList.remove('hidden'); +} + +function hide(element: HTMLElement): void { + element.classList.add('hidden'); +} + +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return ''; + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let value = bytes; + let unit = ''; + for (const candidate of units) { + value /= 1024; + unit = candidate; + if (value < 1024) break; + } + return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${unit}`; +} + +function fileExtension(name: string): string { + const idx = name.lastIndexOf('.'); + return idx > 0 ? name.slice(idx) : ''; +} + +function baseName(path: string): string { + const idx = path.lastIndexOf('/'); + return idx >= 0 ? path.slice(idx + 1) : path; +} + +/** Build a query string carrying the pinned context id. */ +function fmQuery(params: Record): string { + const search = new URLSearchParams(params); + if (fmState.pinnedContextId) { + search.set('contextId', fmState.pinnedContextId); + } + return search.toString(); +} + +/** JSON body carrying the pinned context id. */ +function fmBody(payload: Record): string { + return JSON.stringify( + fmState.pinnedContextId ? { ...payload, contextId: fmState.pinnedContextId } : payload + ); +} + +// --------------------------------------------------------------------------- +// Topbar button gating +// --------------------------------------------------------------------------- + +/** + * Show or hide the topbar file manager button based on the active printer's + * model (cheap model-only probe; no SSH session is opened). + */ +export async function refreshFileManagerButton(): Promise { + const button = document.getElementById('file-manager-button'); + if (!button) { + return; + } + try { + const result = await apiRequest('/api/file-manager/support'); + button.classList.toggle('hidden', !(result.success && result.supported)); + } catch { + button.classList.add('hidden'); + } +} + +// --------------------------------------------------------------------------- +// Thumbnail queue (limited concurrency, cancellable via runId) +// --------------------------------------------------------------------------- + +interface ThumbnailJob { + readonly entry: PrinterFileEntry; + readonly runId: number; +} + +const thumbnailQueue: ThumbnailJob[] = []; +let activeThumbnailRequests = 0; + +function enqueueThumbnail(entry: PrinterFileEntry): void { + thumbnailQueue.push({ entry, runId: fmState.runId }); + pumpThumbnailQueue(); +} + +function pumpThumbnailQueue(): void { + while (activeThumbnailRequests < THUMBNAIL_CONCURRENCY && thumbnailQueue.length > 0) { + const job = thumbnailQueue.shift(); + if (!job || job.runId !== fmState.runId) { + continue; + } + activeThumbnailRequests++; + void fetchThumbnail(job).finally(() => { + activeThumbnailRequests--; + pumpThumbnailQueue(); + }); + } +} + +async function fetchThumbnail(job: ThumbnailJob): Promise { + try { + const query = fmQuery({ storage: fmState.storage, path: job.entry.path }); + const result = await apiRequest(`/api/file-manager/thumbnail?${query}`); + if (job.runId !== fmState.runId) { + return; + } + applyThumbnail(job.entry.path, result.success && result.thumbnail ? result.thumbnail : null); + } catch { + if (job.runId === fmState.runId) { + applyThumbnail(job.entry.path, null); + } + } +} + +function applyThumbnail(path: string, base64: string | null): void { + const tile = document.querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`); + const thumbnail = tile?.querySelector('.fm-thumbnail'); + if (!thumbnail) { + return; + } + + if (base64) { + thumbnail.innerHTML = ''; + const img = document.createElement('img'); + img.src = `data:image/png;base64,${base64}`; + img.alt = baseName(path); + thumbnail.appendChild(img); + } else { + thumbnail.innerHTML = ''; + hydrateLucideIcons(['file'], thumbnail as Element); + } +} + +// --------------------------------------------------------------------------- +// Views: loading / message / grid +// --------------------------------------------------------------------------- + +function showLoading(text: string): void { + const loading = el('fm-loading'); + const textElement = loading.querySelector('.fm-state-text'); + if (textElement) { + textElement.textContent = text; + } + show(loading); + hide(el('fm-message')); + hide(el('fm-grid')); +} + +function showMessage(text: string, allowRetry: boolean = true): void { + el('fm-message-text').textContent = text; + el('fm-retry').classList.toggle('hidden', !allowRetry); + show(el('fm-message')); + hide(el('fm-loading')); + hide(el('fm-grid')); +} + +function showGrid(): void { + show(el('fm-grid')); + hide(el('fm-loading')); + hide(el('fm-message')); +} + +// --------------------------------------------------------------------------- +// Capabilities + tabs +// --------------------------------------------------------------------------- + +async function loadCapabilities(): Promise { + fmState.runId++; + showLoading('Connecting to printer…'); + updateToolbar(); + + let capabilities: FileManagerCapabilities; + try { + capabilities = await apiRequest(`/api/file-manager/capabilities?${fmQuery({})}`); + } catch (error) { + showMessage(`Failed to reach the printer: ${error instanceof Error ? error.message : String(error)}`); + return; + } + + fmState.capabilities = capabilities; + + const title = el('file-manager-title'); + title.textContent = capabilities.printerName ? `File Manager — ${capabilities.printerName}` : 'File Manager'; + + if (!capabilities.supported) { + hide(el('fm-tab-usb')); + showMessage(capabilities.reason || capabilities.error || 'File management is not supported for this printer.', false); + return; + } + + if (capabilities.error) { + showMessage(capabilities.error); + return; + } + + el('fm-tab-usb').classList.toggle('hidden', !capabilities.usbPresent); + + // If the current tab is USB but the drive vanished, fall back to internal + if (fmState.storage === 'usb' && !capabilities.usbPresent) { + fmState.storage = 'internal'; + } + + updateTabSelection(); + await loadListing(fmState.storage, fmState.storage === 'usb' ? fmState.path : ''); +} + +function updateTabSelection(): void { + el('fm-tab-internal').classList.toggle('active', fmState.storage === 'internal'); + el('fm-tab-usb').classList.toggle('active', fmState.storage === 'usb'); +} + +async function switchStorage(storage: FileManagerStorageKind): Promise { + if (fmState.busy || fmState.storage === storage) { + return; + } + fmState.storage = storage; + fmState.path = ''; + updateTabSelection(); + await loadListing(storage, ''); +} + +// --------------------------------------------------------------------------- +// Listing +// --------------------------------------------------------------------------- + +async function loadListing(storage: FileManagerStorageKind, path: string): Promise { + const runId = ++fmState.runId; + fmState.selection.clear(); + showLoading('Loading files…'); + updateToolbar(); + + let listing: FileManagerListing; + try { + listing = await apiRequest(`/api/file-manager/files?${fmQuery({ storage, path })}`); + } catch (error) { + if (runId === fmState.runId) { + showMessage(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`); + } + return; + } + + if (runId !== fmState.runId) { + return; // A newer navigation superseded this request + } + + if (!listing.success) { + showMessage(listing.error || 'Failed to list files'); + return; + } + + fmState.entries = listing.entries; + fmState.path = listing.path; + fmState.rootPath = listing.rootPath; + + renderBreadcrumb(); + renderGrid(); + updateToolbar(); + updateSummary(); +} + +function renderGrid(): void { + const grid = el('fm-grid'); + grid.innerHTML = ''; + + if (fmState.entries.length === 0) { + showMessage('No files found', true); + return; + } + + const fragment = document.createDocumentFragment(); + for (const entry of fmState.entries) { + fragment.appendChild(entry.isDirectory ? createDirectoryTile(entry) : createFileTile(entry)); + } + grid.appendChild(fragment); + hydrateLucideIcons(FM_ICONS, grid); + showGrid(); + + // Queue thumbnails for files after the grid is visible + for (const entry of fmState.entries) { + if (!entry.isDirectory) { + enqueueThumbnail(entry); + } + } +} + +function createFileTile(entry: PrinterFileEntry): HTMLElement { + const tile = document.createElement('div'); + tile.className = 'fm-item'; + tile.dataset.path = entry.path; + + const check = document.createElement('button'); + check.className = 'fm-item-check'; + check.title = 'Select file'; + check.innerHTML = ''; + check.addEventListener('click', (event) => { + event.stopPropagation(); + toggleSelection(entry.path); + }); + + const controls = document.createElement('div'); + controls.className = 'fm-item-controls'; + + const renameBtn = document.createElement('button'); + renameBtn.className = 'fm-item-btn'; + renameBtn.title = 'Rename'; + renameBtn.innerHTML = ''; + renameBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openRenameOverlay(entry); + }); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'fm-item-btn delete'; + deleteBtn.title = 'Delete'; + deleteBtn.innerHTML = ''; + deleteBtn.addEventListener('click', (event) => { + event.stopPropagation(); + openDeleteConfirmation([entry.path]); + }); + + controls.appendChild(renameBtn); + controls.appendChild(deleteBtn); + + const thumbnail = document.createElement('div'); + thumbnail.className = 'fm-thumbnail'; + thumbnail.innerHTML = '
'; + + const name = document.createElement('div'); + name.className = 'fm-name'; + name.textContent = entry.name; + name.title = entry.name; + + const size = document.createElement('div'); + size.className = 'fm-size'; + size.textContent = formatBytes(entry.size); + if (entry.modifiedAt > 1_000_000_000) { + size.title = new Date(entry.modifiedAt * 1000).toLocaleString(); + } + + tile.appendChild(check); + tile.appendChild(controls); + tile.appendChild(thumbnail); + tile.appendChild(name); + tile.appendChild(size); + + tile.addEventListener('click', () => toggleSelection(entry.path)); + + return tile; +} + +function createDirectoryTile(entry: PrinterFileEntry): HTMLElement { + const tile = document.createElement('div'); + tile.className = 'fm-item directory'; + tile.dataset.path = entry.path; + + const thumbnail = document.createElement('div'); + thumbnail.className = 'fm-thumbnail'; + thumbnail.innerHTML = ''; + + const name = document.createElement('div'); + name.className = 'fm-name'; + name.textContent = entry.name; + name.title = entry.name; + + const size = document.createElement('div'); + size.className = 'fm-size'; + size.textContent = 'Folder'; + + tile.appendChild(thumbnail); + tile.appendChild(name); + tile.appendChild(size); + + tile.addEventListener('click', () => { + void loadListing(fmState.storage, entry.path); + }); + + return tile; +} + +// --------------------------------------------------------------------------- +// Breadcrumb (USB navigation) +// --------------------------------------------------------------------------- + +interface Crumb { + readonly label: string; + readonly path: string; +} + +function buildCrumbs(): Crumb[] { + const crumbs: Crumb[] = [{ label: 'USB', path: '' }]; + if (!fmState.path) { + return crumbs; + } + + const mounts = fmState.capabilities?.usbMounts ?? []; + const mount = mounts.find( + (candidate) => fmState.path === candidate || fmState.path.startsWith(`${candidate}/`) + ); + if (!mount) { + return crumbs; + } + + // With multiple drives the mount itself is a navigable crumb; with a single + // drive the 'USB' crumb already points at the mount root. + if (mounts.length > 1) { + crumbs.push({ label: baseName(mount) || mount, path: mount }); + } + + const relative = fmState.path.slice(mount.length).replace(/^\//, ''); + if (relative) { + let accumulated = mount; + for (const segment of relative.split('/')) { + accumulated = `${accumulated}/${segment}`; + crumbs.push({ label: segment, path: accumulated }); + } + } + + return crumbs; +} + +function renderBreadcrumb(): void { + const breadcrumb = el('fm-breadcrumb'); + + if (fmState.storage !== 'usb') { + hide(breadcrumb); + breadcrumb.innerHTML = ''; + return; + } + + const crumbs = buildCrumbs(); + breadcrumb.innerHTML = ''; + + crumbs.forEach((crumb, index) => { + const isLast = index === crumbs.length - 1; + if (index > 0) { + const separator = document.createElement('i'); + separator.setAttribute('data-lucide', 'chevron-right'); + separator.setAttribute('aria-hidden', 'true'); + breadcrumb.appendChild(separator); + } + + if (isLast) { + const current = document.createElement('span'); + current.className = 'crumb-current'; + current.textContent = crumb.label; + breadcrumb.appendChild(current); + } else { + const link = document.createElement('button'); + link.textContent = crumb.label; + link.addEventListener('click', () => { + void loadListing('usb', crumb.path); + }); + breadcrumb.appendChild(link); + } + }); + + hydrateLucideIcons(['chevron-right'], breadcrumb); + show(breadcrumb); +} + +// --------------------------------------------------------------------------- +// Selection + toolbar +// --------------------------------------------------------------------------- + +function toggleSelection(path: string): void { + if (fmState.selection.has(path)) { + fmState.selection.delete(path); + } else { + fmState.selection.add(path); + } + + const tile = document.querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`); + tile?.classList.toggle('selected', fmState.selection.has(path)); + + updateToolbar(); + updateSummary(); +} + +function clearSelection(): void { + fmState.selection.clear(); + document.querySelectorAll('#fm-grid .fm-item.selected').forEach((tile) => { + tile.classList.remove('selected'); + }); + updateToolbar(); + updateSummary(); +} + +function selectAllFiles(): void { + for (const entry of fmState.entries) { + if (!entry.isDirectory) { + fmState.selection.add(entry.path); + } + } + document.querySelectorAll('#fm-grid .fm-item:not(.directory)').forEach((tile) => { + tile.classList.add('selected'); + }); + updateToolbar(); + updateSummary(); +} + +function updateToolbar(): void { + const fileCount = fmState.entries.filter((entry) => !entry.isDirectory).length; + const selectedCount = fmState.selection.size; + + el('fm-select-all').classList.toggle('hidden', fileCount === 0 || selectedCount >= fileCount); + el('fm-clear-selection').classList.toggle('hidden', selectedCount === 0); + el('fm-delete-selected').classList.toggle('hidden', selectedCount === 0); + el('fm-delete-selected-label').textContent = + selectedCount > 0 ? `Delete Selected (${selectedCount})` : 'Delete Selected'; +} + +function updateSummary(extra?: string): void { + const files = fmState.entries.filter((entry) => !entry.isDirectory); + const totalSize = files.reduce((sum, entry) => sum + entry.size, 0); + const parts: string[] = []; + + if (files.length > 0) { + parts.push(`${files.length} file${files.length === 1 ? '' : 's'}`); + parts.push(formatBytes(totalSize)); + } + if (fmState.selection.size > 0) { + parts.push(`${fmState.selection.size} selected`); + } + if (extra) { + parts.push(extra); + } + + el('fm-summary').textContent = parts.join(' • '); +} + +// --------------------------------------------------------------------------- +// Delete flow +// --------------------------------------------------------------------------- + +let pendingDeletePaths: string[] = []; + +function openDeleteConfirmation(paths: string[]): void { + if (paths.length === 0) { + return; + } + pendingDeletePaths = paths; + + el('fm-confirm-title').textContent = paths.length === 1 ? 'Delete file?' : `Delete ${paths.length} files?`; + + const body = el('fm-confirm-body'); + body.innerHTML = ''; + const intro = document.createElement('div'); + intro.textContent = + paths.length === 1 + ? 'This will permanently delete the file from the printer:' + : 'This will permanently delete these files from the printer:'; + body.appendChild(intro); + + const list = document.createElement('ul'); + const shown = paths.slice(0, 8); + for (const path of shown) { + const item = document.createElement('li'); + item.textContent = baseName(path); + list.appendChild(item); + } + if (paths.length > shown.length) { + const item = document.createElement('li'); + item.textContent = `…and ${paths.length - shown.length} more`; + list.appendChild(item); + } + body.appendChild(list); + + show(el('fm-confirm-overlay')); +} + +function closeDeleteConfirmation(): void { + pendingDeletePaths = []; + hide(el('fm-confirm-overlay')); +} + +async function executeDelete(): Promise { + const paths = pendingDeletePaths; + closeDeleteConfirmation(); + if (paths.length === 0 || fmState.busy) { + return; + } + + fmState.busy = true; + for (const path of paths) { + document + .querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`) + ?.classList.add('pending'); + } + updateSummary('Deleting…'); + + try { + const result = await apiRequest('/api/file-manager/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fmBody({ storage: fmState.storage, paths }), + }); + const failures = (result.outcomes ?? []).filter((outcome) => !outcome.success); + if (result.error) { + showMessage(`Delete failed: ${result.error}`); + return; + } + + await loadListing(fmState.storage, fmState.path); + + if (failures.length > 0) { + const first = failures[0]; + updateSummary( + `Failed to delete ${failures.length} file${failures.length === 1 ? '' : 's'} (${first.error || 'unknown error'})` + ); + } + } catch (error) { + showMessage(`Delete failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + fmState.busy = false; + // Clear the per-tile "pending" visual state on every exit path (the + // success path rebuilds the grid, but error paths return early). + for (const path of paths) { + document + .querySelector(`#fm-grid .fm-item[data-path="${CSS.escape(path)}"]`) + ?.classList.remove('pending'); + } + } +} + +// --------------------------------------------------------------------------- +// Rename flow +// --------------------------------------------------------------------------- + +let renameTarget: PrinterFileEntry | null = null; + +function openRenameOverlay(entry: PrinterFileEntry): void { + renameTarget = entry; + + const extension = fileExtension(entry.name); + const stem = extension ? entry.name.slice(0, -extension.length) : entry.name; + + const input = el('fm-rename-input') as HTMLInputElement; + input.value = stem; + el('fm-rename-ext').textContent = extension; + hide(el('fm-rename-error')); + + show(el('fm-rename-overlay')); + input.focus(); + input.select(); +} + +function closeRenameOverlay(): void { + renameTarget = null; + hide(el('fm-rename-overlay')); +} + +function showRenameError(message: string): void { + const errorElement = el('fm-rename-error'); + errorElement.textContent = message; + show(errorElement); +} + +async function executeRename(): Promise { + const entry = renameTarget; + if (!entry || fmState.busy) { + return; + } + + const input = el('fm-rename-input') as HTMLInputElement; + const extension = fileExtension(entry.name); + const stem = input.value.trim(); + + if (!stem) { + showRenameError('Enter a file name'); + return; + } + if (stem.includes('/') || stem.includes('\\')) { + showRenameError('File names cannot contain slashes'); + return; + } + + const newName = `${stem}${extension}`; + if (newName === entry.name) { + closeRenameOverlay(); + return; + } + + fmState.busy = true; + try { + const result = await apiRequest('/api/file-manager/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: fmBody({ storage: fmState.storage, path: entry.path, newName }), + }); + if (!result.success) { + showRenameError(result.error || 'Rename failed'); + return; + } + closeRenameOverlay(); + await loadListing(fmState.storage, fmState.path); + } catch (error) { + showRenameError(error instanceof Error ? error.message : String(error)); + } finally { + fmState.busy = false; + } +} + +// --------------------------------------------------------------------------- +// Modal open/close + wiring +// --------------------------------------------------------------------------- + +function openFileManager(): void { + const contextId = getCurrentContextId(); + if (!contextId) { + showToast('No printer is connected', 'error'); + return; + } + + // Pin the context for the modal's lifetime (survives printer-selector + // switches while open; released on close). + fmState.pinnedContextId = contextId; + fmState.storage = 'internal'; + fmState.path = ''; + fmState.selection.clear(); + + show(el('file-manager-modal')); + hydrateLucideIcons(FM_ICONS, el('file-manager-modal')); + void loadCapabilities(); +} + +function closeFileManager(): void { + fmState.pinnedContextId = null; + fmState.runId++; // Cancel in-flight listings/thumbnails + hide(el('file-manager-modal')); +} + +/** Wire the topbar button and every modal control. Call once at startup. */ +export function setupFileManager(): void { + document.getElementById('file-manager-button')?.addEventListener('click', openFileManager); + + el('fm-close').addEventListener('click', closeFileManager); + el('fm-close-footer').addEventListener('click', closeFileManager); + + el('fm-tab-internal').addEventListener('click', () => void switchStorage('internal')); + el('fm-tab-usb').addEventListener('click', () => void switchStorage('usb')); + + el('fm-refresh').addEventListener('click', () => void loadCapabilities()); + el('fm-retry').addEventListener('click', () => void loadCapabilities()); + + el('fm-select-all').addEventListener('click', selectAllFiles); + el('fm-clear-selection').addEventListener('click', clearSelection); + el('fm-delete-selected').addEventListener('click', () => { + openDeleteConfirmation(Array.from(fmState.selection)); + }); + + el('fm-confirm-cancel').addEventListener('click', closeDeleteConfirmation); + el('fm-confirm-delete').addEventListener('click', () => void executeDelete()); + + el('fm-rename-cancel').addEventListener('click', closeRenameOverlay); + el('fm-rename-confirm').addEventListener('click', () => void executeRename()); + (el('fm-rename-input') as HTMLInputElement).addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + void executeRename(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || el('file-manager-modal').classList.contains('hidden')) { + return; + } + if (!el('fm-rename-overlay').classList.contains('hidden')) { + closeRenameOverlay(); + } else if (!el('fm-confirm-overlay').classList.contains('hidden')) { + closeDeleteConfirmation(); + } else { + closeFileManager(); + } + }); +} diff --git a/src/webui/static/features/ifs-station.ts b/src/webui/static/features/ifs-station.ts index f2abf34..e911994 100644 --- a/src/webui/static/features/ifs-station.ts +++ b/src/webui/static/features/ifs-station.ts @@ -194,7 +194,6 @@ export function setupIfsStationCard(): void { function openSlotEditor(slot: MaterialSlotInfo): void { const displaySlotId = slot.slotId + 1; - // Resolve the fixed palette for this printer model (AD5X vs Creator 5). const palette = getPaletteForModel(latestStation?.printerModelType); const paletteMaterials = palette.materials; const paletteColors = palette.colors; @@ -318,7 +317,9 @@ function openSlotEditor(slot: MaterialSlotInfo): void { applyBtn.addEventListener('click', () => { if (selectedHex) { - void applyManualSlot(slot, displaySlotId, selectedMaterial, selectedHex, close); + void applyManualSlot(displaySlotId, selectedMaterial, selectedHex, () => { + close(); + }); } }); @@ -330,7 +331,6 @@ function openSlotEditor(slot: MaterialSlotInfo): void { * slot-config route, then refresh the card. */ async function applyManualSlot( - slot: MaterialSlotInfo, displaySlotId: number, material: string, colorHex: string, @@ -345,7 +345,6 @@ async function applyManualSlot( slot: displaySlotId, materialName: material, colorHex, - currentMaterial: slot.materialType, }), }); diff --git a/src/webui/static/features/job-control.ts b/src/webui/static/features/job-control.ts index 56085bc..73e9620 100644 --- a/src/webui/static/features/job-control.ts +++ b/src/webui/static/features/job-control.ts @@ -9,6 +9,7 @@ import type { MaterialMapping, + SpoolAssignment, PendingJobStart, PrinterCommandResponse, PrinterFeaturesResponse, @@ -19,6 +20,7 @@ import { apiRequest, sendCommand } from '../core/Transport.js'; import { $, hideElement, showToast } from '../shared/dom.js'; import { isAD5XJobFile, isMultiColorJobFile } from '../shared/formatting.js'; import { loadFileList, showTemperatureDialog, type TemperatureTarget } from '../ui/dialogs.js'; +import { openJobUploadModal } from './job-upload.js'; import { applySettings, refreshSettingsUI } from './layout-theme.js'; import { openMaterialMatchingModal } from './material-matching.js'; @@ -97,6 +99,7 @@ interface JobStartOptions { leveling: boolean; startNow: boolean; materialMappings?: MaterialMapping[]; + spoolAssignments?: SpoolAssignment[]; } export async function startPrintJob(): Promise { @@ -160,6 +163,10 @@ export async function sendJobStartRequest(options: JobStartOptions): Promise 0 + ? options.spoolAssignments + : undefined, }), }); @@ -282,6 +289,9 @@ export function setupJobControlEventHandlers(): void { case 'btn-start-local': await loadFileList('local'); break; + case 'btn-upload-job': + openJobUploadModal(); + break; case 'btn-refresh': sendCommand({ command: 'REQUEST_STATUS' }); break; diff --git a/src/webui/static/features/job-upload.ts b/src/webui/static/features/job-upload.ts new file mode 100644 index 0000000..702f497 --- /dev/null +++ b/src/webui/static/features/job-upload.ts @@ -0,0 +1,803 @@ +/** + * @fileoverview Browser job uploader - the WebUI port of the desktop Upload Job dialog. + * + * Reproduces the desktop flow (`src/renderer/src/ui/job-uploader/`) end to end: + * pick a file, show its slicer metadata, route material-station printers + * (AD5X, Creator 5 / 5 Pro) through material matching for 3MF files, then + * upload with a progress overlay and auto-close on success. + * + * The one unavoidable difference is where the file lives. The desktop hands the + * backend a path from a native dialog; the browser has to ship the bytes to the + * WebUI server first (POST /api/jobs/upload/stage), which stages them and parses + * the metadata. Everything after that mirrors the desktop step for step, + * including the simulated upload progress the desktop's main process emits. + * + * Key exports: + * - setupJobUpload(): wire the dialog's controls (called once at startup) + * - openJobUploadModal(): open a freshly reset dialog + */ + +import type { + AD5XToolData, + JobUploadStageResponse, + JobUploadStartResponse, + MaterialMapping, + PendingJobStart, + SpoolAssignment, + UploadFilamentInfo, + UploadJobMetadata, + UploadSliceWarning, + WebUIJobFile, +} from '../app.js'; +import { state } from '../core/AppState.js'; +import { apiRequest, buildAuthHeaders } from '../core/Transport.js'; +import { $, hideElement, showElement, showToast } from '../shared/dom.js'; +import { openMaterialMatchingModal } from './material-matching.js'; + +/** Extensions the desktop file dialog offers, and all the staging route accepts. */ +const ALLOWED_EXTENSIONS = ['.gcode', '.gx', '.3mf']; + +interface UploadProgressStep { + readonly percentage: number; + readonly status: string; +} + +/** + * Progress steps emitted by the desktop main process during a material-station + * upload (`upload-file-ad5x` in job-handlers.ts). Replayed here so both surfaces + * show the same sequence; neither is real progress - ff-api reports none. + */ +const MATERIAL_STATION_PROGRESS_STEPS: readonly UploadProgressStep[] = [ + { percentage: 15, status: 'Validating material mappings...' }, + { percentage: 30, status: 'Connecting to material station...' }, + { percentage: 50, status: 'Uploading file to printer...' }, + { percentage: 70, status: 'Processing 3MF data...' }, + { percentage: 90, status: 'Finalizing upload...' }, +]; +const MATERIAL_STATION_STEP_DELAY_MS = 300; + +/** Progress steps emitted by the desktop `uploader:upload-job` handler. */ +const STANDARD_PROGRESS_STEPS: readonly UploadProgressStep[] = [10, 30, 50, 70, 90].map((percentage) => ({ + percentage, + status: 'Uploading file...', +})); +const STANDARD_STEP_DELAY_MS = 200; + +/** Delay before the dialog closes itself after a successful upload. */ +const AUTO_CLOSE_DELAY_MS = 2000; + +/** How long a failure stays on the progress overlay before the alert. */ +const ERROR_DISPLAY_DELAY_MS = 5000; + +let handlersRegistered = false; + +/** Handle for the file currently staged on the server, if any. */ +let stagedUploadId: string | null = null; +let stagedFileName: string | null = null; + +/** Mappings confirmed in the material matching modal, applied on upload. */ +let savedMaterialMappings: MaterialMapping[] | null = null; + +/** Spoolman spool choices from the matching modal (empty when not tracked). */ +let savedSpoolAssignments: SpoolAssignment[] = []; + +/** Guards against a second upload while one is in flight. */ +let uploadInFlight = false; + +/** Cancels the auto-close/error timers when the dialog is reopened. */ +let pendingTimer: number | null = null; + +function hasMaterialStationSupport(): boolean { + return Boolean(state.printerFeatures?.hasMaterialStation); +} + +function is3MF(fileName: string): boolean { + return fileName.toLowerCase().endsWith('.3mf'); +} + +function clearPendingTimer(): void { + if (pendingTimer !== null) { + window.clearTimeout(pendingTimer); + pendingTimer = null; + } +} + +// ============================================================================ +// DIALOG LIFECYCLE +// ============================================================================ + +export function openJobUploadModal(): void { + if (state.authRequired && !state.authToken) { + showToast('Not authenticated', 'error'); + return; + } + + clearPendingTimer(); + void discardStagedFile(); + savedMaterialMappings = null; + savedSpoolAssignments = []; + uploadInFlight = false; + + const input = $('job-upload-file-input') as HTMLInputElement | null; + if (input) { + input.value = ''; + } + + setFilePathDisplay('No file selected...', ''); + resetMetadata(); + setOkButtonState(false); + updatePrimaryButtonLabel(); + showLoading(false); + showUploadProgress(false); + showElement('job-upload-modal'); +} + +function closeJobUploadModal(): void { + clearPendingTimer(); + void discardStagedFile(); + savedMaterialMappings = null; + savedSpoolAssignments = []; + uploadInFlight = false; + showLoading(false); + showUploadProgress(false); + hideElement('job-upload-modal'); +} + +/** Tell the server to drop the scratch file backing the current selection. */ +async function discardStagedFile(): Promise { + const uploadId = stagedUploadId; + stagedUploadId = null; + stagedFileName = null; + + if (!uploadId) { + return; + } + + try { + await apiRequest('/api/jobs/upload/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uploadId }), + }); + } catch (error) { + console.error('Failed to discard staged upload:', error); + } +} + +// ============================================================================ +// FILE SELECTION +// ============================================================================ + +async function handleFileSelected(file: File | null): Promise { + // A new selection invalidates whatever the previous one produced. + savedMaterialMappings = null; + savedSpoolAssignments = []; + renderMappingSummary(); + await discardStagedFile(); + + if (!file) { + setFilePathDisplay('No file selected...', ''); + resetMetadata(); + setOkButtonState(false); + showLoading(false); + return; + } + + setFilePathDisplay(file.name, file.name); + resetMetadata(); + setOkButtonState(false); + + if (!ALLOWED_EXTENSIONS.some((extension) => file.name.toLowerCase().endsWith(extension))) { + setFilePathDisplay('Unsupported file type', ''); + alert('Select a .gcode, .gx or .3mf job file.'); + return; + } + + // Material-station printers (AD5X, Creator 5 / 5 Pro) only accept 3MF files, + // which carry the per-tool material data the firmware needs for matching. The + // desktop reports this after parsing; the browser checks first so a rejected + // file is never transferred to the server. + if (hasMaterialStationSupport() && !is3MF(file.name)) { + const fileExtension = file.name.split('.').pop()?.toUpperCase() || 'unknown'; + setFilePathDisplay('Unsupported file type for this printer', ''); + alert( + `This printer only supports 3MF files.\n\nThe selected ${fileExtension} file cannot be uploaded to this printer.\n\nPlease select a 3MF file sliced for this printer.` + ); + return; + } + + showLoading(true, 'Uploading file... 0%'); + + let staged: JobUploadStageResponse; + try { + staged = await stageFile(file); + } catch (error) { + showLoading(false); + const message = error instanceof Error ? error.message : 'Unknown error'; + setFilePathDisplay(`Error parsing file: ${message}`, ''); + resetMetadata(); + setOkButtonState(false); + alert(`Could not parse file metadata:\n${message}`); + return; + } + + showLoading(false); + + if (!staged.success || !staged.uploadId || !staged.metadata) { + const message = staged.error || 'Unknown error'; + setFilePathDisplay(`Error parsing file: ${message}`, ''); + resetMetadata(); + setOkButtonState(false); + alert(`Could not parse file metadata:\n${message}`); + return; + } + + stagedUploadId = staged.uploadId; + stagedFileName = staged.fileName ?? file.name; + + populateMetadata(staged.metadata); + + if (!staged.requiresMaterialMatching) { + setOkButtonState(true); + return; + } + + const filaments = staged.metadata.filaments; + if (filaments.length === 0) { + // No filament data - warn and fall back to the plain upload, exactly as the + // desktop does. The OK button is enabled so the user can still proceed. + showNoFilamentDataWarning(stagedFileName); + setOkButtonState(true); + return; + } + + // The desktop always shows material matching for a material-station 3MF, + // single-colour files included. + const confirmed = await requestMaterialMappings(stagedFileName, filaments); + savedMaterialMappings = confirmed?.mappings ?? null; + savedSpoolAssignments = confirmed?.spoolAssignments ?? []; + renderMappingSummary(); + setOkButtonState(Boolean(confirmed)); +} + +/** + * Ship the file to the WebUI server and get back a staging handle plus metadata. + * + * Uses XHR rather than fetch so the transfer can drive the loading overlay - on + * a phone over Wi-Fi a large 3MF is the slowest part of the whole flow, and the + * desktop equivalent (a local disk read) is instant. + */ +function stageFile(file: File): Promise { + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open('POST', `/api/jobs/upload/stage?filename=${encodeURIComponent(file.name)}`); + + const headers = buildAuthHeaders({ 'Content-Type': 'application/octet-stream' }); + Object.entries(headers).forEach(([key, value]) => { + request.setRequestHeader(key, value); + }); + + request.upload.addEventListener('progress', (event) => { + if (!event.lengthComputable) { + return; + } + const percentage = Math.round((event.loaded / event.total) * 100); + showLoading(true, percentage >= 100 ? 'Parsing file...' : `Uploading file... ${percentage}%`); + }); + + request.upload.addEventListener('load', () => { + showLoading(true, 'Parsing file...'); + }); + + request.addEventListener('load', () => { + if (!request.responseText) { + reject(new Error(`Server returned ${request.status}`)); + return; + } + try { + resolve(JSON.parse(request.responseText) as JobUploadStageResponse); + } catch { + reject(new Error('Failed to parse server response')); + } + }); + + request.addEventListener('error', () => reject(new Error('Network error while uploading the file'))); + request.addEventListener('abort', () => reject(new Error('Upload cancelled'))); + + request.send(file); + }); +} + +/** Show the desktop's "no filament data" warning for a 3MF that carries none. */ +function showNoFilamentDataWarning(fileName: string): void { + alert( + `The 3MF file "${fileName}" does not contain filament data.\n\nThis may happen with:` + + '\n• Files not sliced for multi-color printing' + + '\n• Older slicer versions' + + '\n• Corrupted or incomplete files' + + '\n\nThe file will be uploaded using the standard upload process.' + ); +} + +// ============================================================================ +// MATERIAL MATCHING +// ============================================================================ + +/** + * Convert parsed filament entries into the tool data the material matching + * modal expects, mirroring `convertFilamentsToToolData` in the desktop renderer. + */ +function convertFilamentsToToolData(filaments: readonly UploadFilamentInfo[]): AD5XToolData[] { + // The tool id is the filament's gcode tool (T0-T3), not its list position: + // a plate that uses filaments 1 and 3 prints with T0 and T2. + return filaments.map((filament, index) => ({ + toolId: Number.isInteger(filament.toolId) ? filament.toolId : index, + materialName: filament.type || 'Unknown', + materialColor: filament.color || '#FFFFFF', + filamentWeight: Number.parseFloat(filament.usedG || '0') || 0, + slotId: 0, + })); +} + +/** + * Open the shared material matching modal for a file that is only staged, and + * resolve with the confirmed mappings (or null if the user backed out). + */ +function requestMaterialMappings( + fileName: string, + filaments: readonly UploadFilamentInfo[] +): Promise<{ mappings: MaterialMapping[]; spoolAssignments: SpoolAssignment[] } | null> { + return new Promise((resolve) => { + let settled = false; + const settle = ( + confirmed: { mappings: MaterialMapping[]; spoolAssignments: SpoolAssignment[] } | null + ): void => { + if (!settled) { + settled = true; + resolve(confirmed); + } + }; + + const job: WebUIJobFile = { + fileName, + displayName: fileName, + metadataType: 'ad5x', + toolCount: filaments.length, + toolDatas: convertFilamentsToToolData(filaments), + }; + + const pending: PendingJobStart = { + filename: fileName, + leveling: getAutoLevel(), + startNow: getStartNow(), + job, + }; + + void openMaterialMatchingModal(pending, { + onConfirm: async (mappings, spoolAssignments) => { + settle({ mappings, spoolAssignments }); + return true; + }, + onCancel: () => settle(null), + }); + }); +} + +// ============================================================================ +// UPLOAD +// ============================================================================ + +async function handleUpload(): Promise { + if (!stagedUploadId || !stagedFileName || uploadInFlight) { + return; + } + + const fileName = stagedFileName; + const useMaterialStationUpload = hasMaterialStationSupport() && is3MF(fileName); + + if (hasMaterialStationSupport() && !is3MF(fileName)) { + // Unreachable via the UI (selection is blocked earlier); kept as the desktop + // renderer keeps its equivalent safety check. + alert('This printer only supports 3MF files. Please select a valid 3MF file.'); + return; + } + + uploadInFlight = true; + setOkButtonState(false); + + const steps = useMaterialStationUpload ? MATERIAL_STATION_PROGRESS_STEPS : STANDARD_PROGRESS_STEPS; + const delay = useMaterialStationUpload ? MATERIAL_STATION_STEP_DELAY_MS : STANDARD_STEP_DELAY_MS; + + updateUploadProgress(0, useMaterialStationUpload ? 'Preparing AD5X upload...' : 'Preparing upload...'); + const stopSimulation = simulateProgress(steps, delay); + + let result: JobUploadStartResponse; + try { + result = await apiRequest('/api/jobs/upload/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uploadId: stagedUploadId, + startNow: getStartNow(), + autoLevel: getAutoLevel(), + materialMappings: savedMaterialMappings ?? undefined, + spoolAssignments: savedSpoolAssignments.length > 0 ? savedSpoolAssignments : undefined, + }), + }); + } catch (error) { + result = { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + }; + } finally { + stopSimulation(); + } + + // The server discards the staged file once it has handed it to the printer, + // whether or not the printer accepted it. + stagedUploadId = null; + savedMaterialMappings = null; + savedSpoolAssignments = []; + uploadInFlight = false; + + handleUploadComplete(result, fileName); +} + +/** + * Replay a fixed progress sequence while the request is in flight. Returns a + * function that stops the replay. + */ +function simulateProgress(steps: readonly UploadProgressStep[], delayMs: number): () => void { + let index = 0; + const timer = window.setInterval(() => { + const step = steps[index]; + if (!step) { + window.clearInterval(timer); + return; + } + index += 1; + updateUploadProgress(step.percentage, step.status); + }, delayMs); + + return () => window.clearInterval(timer); +} + +function handleUploadComplete(result: JobUploadStartResponse, fileName: string): void { + clearPendingTimer(); + + if (result.success) { + updateUploadProgress(100, `Successfully uploaded ${result.fileName || fileName}`); + pendingTimer = window.setTimeout(() => { + pendingTimer = null; + closeJobUploadModal(); + }, AUTO_CLOSE_DELAY_MS); + return; + } + + const message = result.error || 'Unknown error'; + updateUploadProgress(0, `Upload failed: ${message}`); + setOkButtonState(false); + setFilePathDisplay('No file selected...', ''); + resetMetadata(); + + const input = $('job-upload-file-input') as HTMLInputElement | null; + if (input) { + input.value = ''; + } + + pendingTimer = window.setTimeout(() => { + pendingTimer = null; + showUploadProgress(false); + alert(`Upload failed: ${message}`); + }, ERROR_DISPLAY_DELAY_MS); +} + +// ============================================================================ +// METADATA RENDERING +// ============================================================================ + +/** Format seconds into a human-readable duration, matching the desktop dialog. */ +function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.round(seconds % 60); + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } + return `${minutes}m ${secs}s`; +} + +function setMetaText(id: string, value: string): void { + const element = $(id); + if (element) { + element.textContent = value; + } +} + +function populateMetadata(metadata: UploadJobMetadata): void { + setMetaText('job-upload-meta-printer', metadata.printerModel || '-'); + setMetaText('job-upload-meta-filament-type', metadata.filamentType || '-'); + + // Length carries the row; weight is appended only when a length is known, so + // the dialog never shows a weight with no context (as on desktop). + let filamentText = '-'; + if (metadata.filamentLengthM !== null) { + filamentText = `${metadata.filamentLengthM.toFixed(2)} m`; + if (metadata.filamentWeightG !== null) { + filamentText += ` • ${metadata.filamentWeightG.toFixed(2)} g`; + } + } + setMetaText('job-upload-meta-filament-len', filamentText); + + setMetaText('job-upload-meta-slicer-name', metadata.slicerName || '-'); + setMetaText('job-upload-meta-slicer-ver', metadata.slicerVersion || '-'); + setMetaText('job-upload-meta-slice-date', metadata.sliceDate || '-'); + setMetaText('job-upload-meta-slice-time', metadata.sliceTime || '-'); + setMetaText('job-upload-meta-eta', metadata.printEta || '-'); + setMetaText( + 'job-upload-meta-first-layer-time', + metadata.firstLayerTime !== null ? formatDuration(metadata.firstLayerTime) : '-' + ); + setMetaText('job-upload-meta-layer-height', metadata.layerHeight !== null ? `${metadata.layerHeight} mm` : '-'); + setMetaText('job-upload-meta-infill', metadata.infillDensity !== null ? `${metadata.infillDensity}%` : '-'); + setMetaText('job-upload-meta-layers', metadata.layerCount !== null ? metadata.layerCount.toString() : '-'); + setMetaText('job-upload-meta-support', metadata.supportUsed === null ? '-' : metadata.supportUsed ? 'Yes' : 'No'); + + renderWarnings(metadata.warnings); + renderThumbnail(metadata.thumbnail); +} + +function renderWarnings(warnings: readonly UploadSliceWarning[]): void { + const container = $('job-upload-warnings-container'); + const list = $('job-upload-meta-warnings'); + if (!container || !list) { + return; + } + + list.innerHTML = ''; + + if (warnings.length === 0) { + container.classList.add('hidden'); + list.textContent = '-'; + return; + } + + container.classList.remove('hidden'); + warnings.forEach((warning) => { + const item = document.createElement('div'); + item.className = 'job-upload-warning-item'; + + const icon = document.createElement('span'); + const levelClass = warning.level >= 2 ? 'level-error' : warning.level >= 1 ? 'level-warning' : 'level-info'; + icon.className = `job-upload-warning-icon ${levelClass}`; + icon.textContent = warning.level >= 2 ? '⚠' : 'ℹ'; + + const message = document.createElement('span'); + message.className = 'job-upload-warning-msg'; + message.textContent = warning.message; + + item.appendChild(icon); + item.appendChild(message); + list.appendChild(item); + }); +} + +function renderThumbnail(thumbnail: string | null): void { + const box = $('job-upload-thumbnail'); + if (!box) { + return; + } + + box.innerHTML = ''; + + if (!thumbnail) { + const placeholder = document.createElement('span'); + placeholder.className = 'job-upload-no-preview'; + placeholder.textContent = 'No Preview'; + box.appendChild(placeholder); + return; + } + + const image = document.createElement('img'); + image.src = thumbnail.startsWith('data:image') ? thumbnail : `data:image/png;base64,${thumbnail}`; + image.alt = 'Preview'; + box.appendChild(image); +} + +function resetMetadata(): void { + [ + 'job-upload-meta-printer', + 'job-upload-meta-filament-type', + 'job-upload-meta-filament-len', + 'job-upload-meta-slicer-name', + 'job-upload-meta-slicer-ver', + 'job-upload-meta-slice-date', + 'job-upload-meta-slice-time', + 'job-upload-meta-eta', + 'job-upload-meta-first-layer-time', + 'job-upload-meta-layer-height', + 'job-upload-meta-infill', + 'job-upload-meta-layers', + 'job-upload-meta-support', + ].forEach((id) => { + setMetaText(id, '-'); + }); + + renderWarnings([]); + renderThumbnail(null); + + // A cleared display has no file behind it, so any mappings are stale. + savedMaterialMappings = null; + savedSpoolAssignments = []; + renderMappingSummary(); +} + +// ============================================================================ +// DIALOG STATE HELPERS +// ============================================================================ + +function setFilePathDisplay(text: string, title: string): void { + const element = $('job-upload-file-path'); + if (element) { + element.textContent = text; + element.title = title; + } +} + +function setOkButtonState(enabled: boolean): void { + const button = $('job-upload-ok') as HTMLButtonElement | null; + if (button) { + button.disabled = !enabled; + } +} + +/** + * Name the primary button after what pressing it does: with "Start Now" ticked + * the printer begins the job, otherwise the file is only transferred. The + * desktop uploader uses the same two labels. + */ +function updatePrimaryButtonLabel(): void { + const button = $('job-upload-ok') as HTMLButtonElement | null; + if (button) { + button.textContent = getStartNow() ? 'Start Print' : 'Send File'; + } +} + +/** + * Show the confirmed tool -> slot pairs on the upload dialog so they can be + * checked at a glance before the job is sent. + */ +function renderMappingSummary(): void { + const list = $('job-upload-mappings-list'); + if (!list) { + return; + } + + list.textContent = ''; + + if (!savedMaterialMappings || savedMaterialMappings.length === 0) { + hideElement('job-upload-mappings'); + return; + } + + savedMaterialMappings.forEach((mapping) => { + const chip = document.createElement('div'); + chip.className = 'job-upload-mapping-chip'; + + chip.appendChild(createMappingSwatch(mapping.toolMaterialColor)); + chip.appendChild(createMappingText('job-upload-mapping-label', `Tool ${mapping.toolId + 1}`)); + chip.appendChild(createMappingText('job-upload-mapping-arrow', '→')); + chip.appendChild(createMappingSwatch(mapping.slotMaterialColor)); + chip.appendChild(createMappingText('job-upload-mapping-label', `Slot ${mapping.slotId}`)); + chip.appendChild(createMappingText('job-upload-mapping-material', mapping.materialName || 'Unknown')); + + const spoolChoice = savedSpoolAssignments.find((entry) => entry.toolId === mapping.toolId); + if (spoolChoice) { + chip.appendChild( + createMappingText( + 'job-upload-mapping-spool', + spoolChoice.spoolId === null ? 'Not tracked' : `Spool #${spoolChoice.spoolId}` + ) + ); + } + + list.appendChild(chip); + }); + + showElement('job-upload-mappings'); +} + +function createMappingSwatch(color: string): HTMLSpanElement { + const swatch = document.createElement('span'); + swatch.className = 'job-upload-mapping-swatch'; + swatch.style.backgroundColor = color || '#cccccc'; + return swatch; +} + +function createMappingText(className: string, text: string): HTMLSpanElement { + const element = document.createElement('span'); + element.className = className; + element.textContent = text; + return element; +} + +function showLoading(show: boolean, text = 'Parsing file...'): void { + const label = $('job-upload-loading-text'); + if (label) { + label.textContent = text; + } + if (show) { + showElement('job-upload-loading'); + } else { + hideElement('job-upload-loading'); + } +} + +function showUploadProgress(show: boolean): void { + if (show) { + showElement('job-upload-progress-overlay'); + } else { + hideElement('job-upload-progress-overlay'); + } +} + +function updateUploadProgress(percentage: number, status: string): void { + showUploadProgress(true); + + const bar = $('job-upload-progress-bar'); + if (bar) { + bar.style.width = `${percentage}%`; + } + + setMetaText('job-upload-progress-percentage', `${Math.round(percentage)}%`); + setMetaText('job-upload-progress-status', status); +} + +function getStartNow(): boolean { + return ($('job-upload-start-now') as HTMLInputElement | null)?.checked ?? true; +} + +function getAutoLevel(): boolean { + return ($('job-upload-auto-level') as HTMLInputElement | null)?.checked ?? false; +} + +// ============================================================================ +// WIRING +// ============================================================================ + +export function setupJobUpload(): void { + if (handlersRegistered) { + return; + } + handlersRegistered = true; + + const input = $('job-upload-file-input') as HTMLInputElement | null; + + $('job-upload-browse')?.addEventListener('click', () => { + input?.click(); + }); + + input?.addEventListener('change', () => { + void handleFileSelected(input.files?.[0] ?? null); + }); + + $('job-upload-start-now')?.addEventListener('change', () => { + updatePrimaryButtonLabel(); + }); + + $('job-upload-ok')?.addEventListener('click', () => { + void handleUpload(); + }); + + $('job-upload-cancel')?.addEventListener('click', () => { + closeJobUploadModal(); + }); + + $('job-upload-close')?.addEventListener('click', () => { + closeJobUploadModal(); + }); +} diff --git a/src/webui/static/features/material-matching.ts b/src/webui/static/features/material-matching.ts index c6276ac..fd0ea6e 100644 --- a/src/webui/static/features/material-matching.ts +++ b/src/webui/static/features/material-matching.ts @@ -13,14 +13,24 @@ import type { MaterialStationStatus, MaterialStationStatusResponse, PendingJobStart, + SpoolAssignment, + SpoolSearchResponse, + SpoolSummary, WebUIJobFile, } from '../app.js'; -import { getMaterialMatchingState, setMaterialMatchingState, state } from '../core/AppState.js'; +import { + getMaterialMatchingState, + type MaterialMatchingHooks, + setMaterialMatchingState, + state, +} from '../core/AppState.js'; import { apiRequest } from '../core/Transport.js'; import { $, hideElement, showElement, showToast } from '../shared/dom.js'; import { colorsDiffer, isAD5XJobFile, materialsMatch } from '../shared/formatting.js'; import { sendJobStartRequest } from './job-control.js'; +const DEFAULT_SLOT_GREY = 'var(--surface-muted)'; + type MaterialMessageType = 'error' | 'warning'; let materialHandlersRegistered = false; @@ -35,34 +45,81 @@ function getMaterialMessageElement(type: MaterialMessageType): HTMLDivElement | } export function clearMaterialMessages(): void { - (['error', 'warning'] as const).forEach((type) => { - const messageEl = getMaterialMessageElement(type); - if (messageEl) { - messageEl.classList.add('hidden'); - messageEl.textContent = ''; - } - }); + const errorEl = getMaterialMessageElement('error'); + if (errorEl) { + errorEl.classList.add('hidden'); + errorEl.textContent = ''; + } + hideMaterialWarnings(); } -export function showMaterialError(text: string): void { - const errorEl = getMaterialMessageElement('error'); +function hideMaterialWarnings(): void { const warningEl = getMaterialMessageElement('warning'); - if (warningEl) { - warningEl.classList.add('hidden'); - warningEl.textContent = ''; + const list = getMaterialMatchingElement('material-matching-warning-list'); + if (list) { + list.textContent = ''; } + warningEl?.classList.add('hidden'); +} + +export function showMaterialError(text: string): void { + const errorEl = getMaterialMessageElement('error'); if (errorEl) { errorEl.textContent = text; errorEl.classList.remove('hidden'); } } -export function showMaterialWarning(text: string): void { +/** + * One line per mapping whose tool colour does not match the slot it was mapped + * to. Rebuilt from the current mappings on every change, so the card always + * reflects the whole set rather than the last click. The wording is shared with + * the desktop dialog. + */ +export function refreshColorWarnings(): void { + const matchingState = getMaterialMatchingState(); const warningEl = getMaterialMessageElement('warning'); - if (warningEl) { - warningEl.textContent = text; - warningEl.classList.remove('hidden'); + const list = getMaterialMatchingElement('material-matching-warning-list'); + if (!warningEl || !list) { + return; + } + + list.textContent = ''; + + const mismatched = matchingState + ? Array.from(matchingState.mappings.values()).filter((mapping) => + colorsDiffer(mapping.toolMaterialColor, mapping.slotMaterialColor) + ) + : []; + + if (mismatched.length === 0) { + warningEl.classList.add('hidden'); + return; } + + mismatched.forEach((mapping) => { + const item = document.createElement('div'); + item.className = 'material-matching-warning-item'; + item.textContent = colorMismatchMessage( + mapping.toolId, + mapping.toolMaterialColor, + mapping.slotId, + mapping.slotMaterialColor + ); + list.appendChild(item); + }); + + warningEl.classList.remove('hidden'); +} + +/** Shared wording - keep in step with the desktop material matching dialog. */ +export function colorMismatchMessage( + toolId: number, + toolColor: string, + slotId: number, + slotColor: string +): string { + return `Tool ${toolId + 1} expects ${toolColor} but Slot ${slotId} has ${slotColor}.`; } export function updateMaterialMatchingConfirmState(): void { @@ -79,7 +136,104 @@ export function updateMaterialMatchingConfirmState(): void { const job = matchingState.pending.job; const requiredMappings = isAD5XJobFile(job) ? job.toolDatas.length : 0; - confirmButton.disabled = matchingState.mappings.size !== requiredMappings; + confirmButton.disabled = + matchingState.mappings.size !== requiredMappings || !allSpoolsChosen(); +} + +/** True when spool tracking does not apply, or every mapped tool has a spool choice. */ +function allSpoolsChosen(): boolean { + const matchingState = getMaterialMatchingState(); + if (!matchingState || matchingState.spools === null) { + return true; + } + for (const toolId of matchingState.mappings.keys()) { + if (!matchingState.spoolChoices.has(toolId)) { + return false; + } + } + return true; +} + +/** Spool choices for the confirmed mappings (empty when tracking does not apply). */ +function collectSpoolAssignments(mappings: readonly MaterialMapping[]): SpoolAssignment[] { + const matchingState = getMaterialMatchingState(); + if (!matchingState || matchingState.spools === null) { + return []; + } + return mappings.map((mapping) => ({ + toolId: mapping.toolId, + spoolId: matchingState.spoolChoices.get(mapping.toolId) ?? null, + })); +} + +const SPOOL_PLACEHOLDER_VALUE = ''; +const SPOOL_UNTRACKED_VALUE = 'none'; + +function describeSpool(spool: SpoolSummary): string { + const parts = [`#${spool.id}`, spool.vendor, spool.name].filter(Boolean); + const remaining = Number.isFinite(spool.remainingWeight) + ? ` (${Math.round(spool.remainingWeight)} g left)` + : ''; + return `${parts.join(' ')}${remaining}`; +} + +/** + * Spool picker for one mapping. Spools whose material matches the tool come + * first, so the usual choice is near the top. + */ +function createSpoolSelect(mapping: MaterialMapping, spools: readonly SpoolSummary[]): HTMLSelectElement { + const matchingState = getMaterialMatchingState(); + const select = document.createElement('select'); + select.className = 'material-mapping-spool'; + select.dataset.toolId = `${mapping.toolId}`; + select.setAttribute('aria-label', `Spoolman spool for tool ${mapping.toolId + 1}`); + + const placeholder = document.createElement('option'); + placeholder.value = SPOOL_PLACEHOLDER_VALUE; + placeholder.textContent = 'Choose a spool\u2026'; + placeholder.disabled = true; + select.appendChild(placeholder); + + const untracked = document.createElement('option'); + untracked.value = SPOOL_UNTRACKED_VALUE; + untracked.textContent = 'Do not track'; + select.appendChild(untracked); + + const sorted = [...spools].sort((a, b) => { + const aMatch = materialsMatch(mapping.materialName, a.material) ? 0 : 1; + const bMatch = materialsMatch(mapping.materialName, b.material) ? 0 : 1; + return aMatch - bMatch || a.id - b.id; + }); + for (const spool of sorted) { + const option = document.createElement('option'); + option.value = `${spool.id}`; + option.textContent = describeSpool(spool); + select.appendChild(option); + } + + const choice = matchingState?.spoolChoices.get(mapping.toolId); + select.value = + choice === undefined + ? SPOOL_PLACEHOLDER_VALUE + : choice === null + ? SPOOL_UNTRACKED_VALUE + : `${choice}`; + + select.addEventListener('change', () => { + const current = getMaterialMatchingState(); + if (!current) { + return; + } + if (select.value === SPOOL_UNTRACKED_VALUE) { + current.spoolChoices.set(mapping.toolId, null); + } else if (select.value === SPOOL_PLACEHOLDER_VALUE) { + current.spoolChoices.delete(mapping.toolId); + } else { + current.spoolChoices.set(mapping.toolId, Number(select.value)); + } + updateMaterialMatchingConfirmState(); + }); + return select; } export function renderMaterialMappings(): void { @@ -107,9 +261,21 @@ export function renderMaterialMappings(): void { item.classList.add('warning'); } + // Swatches on both sides, as the desktop dialog shows them: the tool colour + // and the slot colour are what a mismatch is actually about. + const content = document.createElement('span'); + content.className = 'material-mapping-content'; + const text = document.createElement('span'); text.className = 'material-mapping-text'; - text.innerHTML = `Tool ${mapping.toolId + 1} → Slot ${mapping.slotId}`; + const arrow = document.createElement('span'); + arrow.className = 'material-mapping-arrow'; + arrow.textContent = '→'; + text.append(`Tool ${mapping.toolId + 1} `, arrow, ` Slot ${mapping.slotId}`); + + content.appendChild(createMappingSwatch(mapping.toolMaterialColor)); + content.appendChild(text); + content.appendChild(createMappingSwatch(mapping.slotMaterialColor)); const removeBtn = document.createElement('button'); removeBtn.className = 'material-mapping-remove'; @@ -120,12 +286,22 @@ export function renderMaterialMappings(): void { handleRemoveMapping(mapping.toolId); }); - item.appendChild(text); + item.appendChild(content); + if (matchingState.spools !== null) { + item.appendChild(createSpoolSelect(mapping, matchingState.spools)); + } item.appendChild(removeBtn); container.appendChild(item); }); } +function createMappingSwatch(color: string): HTMLSpanElement { + const swatch = document.createElement('span'); + swatch.className = 'material-mapping-swatch'; + swatch.style.backgroundColor = color || DEFAULT_SLOT_GREY; + return swatch; +} + function handleRemoveMapping(toolId: number): void { const matchingState = getMaterialMatchingState(); if (!matchingState) { @@ -133,11 +309,13 @@ function handleRemoveMapping(toolId: number): void { } matchingState.mappings.delete(toolId); + matchingState.spoolChoices.delete(toolId); renderMaterialRequirements(matchingState.pending.job); renderMaterialSlots(matchingState.materialStation); renderMaterialMappings(); updateMaterialMatchingConfirmState(); clearMaterialMessages(); + refreshColorWarnings(); } export function renderMaterialRequirements(job: WebUIJobFile | undefined): void { @@ -247,7 +425,7 @@ export function renderMaterialSlots(status: MaterialStationStatus | null): void if (!status) { const empty = document.createElement('div'); empty.className = 'material-placeholder'; - empty.textContent = 'Material station status unavailable.'; + empty.textContent = 'Waiting for the material station status…'; container.appendChild(empty); return; } @@ -378,13 +556,8 @@ function handleSlotSelection(slotInfo: MaterialSlotInfo): void { matchingState.mappings.set(tool.toolId, mapping); matchingState.selectedToolId = null; - if (colorsDiffer(tool.materialColor, slotInfo.materialColor || '')) { - showMaterialWarning( - `Tool ${tool.toolId + 1} color (${tool.materialColor}) does not match Slot ${displaySlotId} color (${slotInfo.materialColor || 'unknown'}). The print will succeed, but appearance may differ.` - ); - } else { - clearMaterialMessages(); - } + clearMaterialMessages(); + refreshColorWarnings(); renderMaterialRequirements(job); renderMaterialSlots(matchingState.materialStation); @@ -412,25 +585,68 @@ async function fetchMaterialStationStatus(): Promise { + const config = state.spoolmanConfig; + if (!config?.enabled || !config.station || !pending.startNow) { + return null; + } + try { + const result = await apiRequest('/api/spoolman/spools'); + if (result.success) { + return (result.spools ?? []).filter((spool) => !spool.archived); + } + showToast(result.error || 'Could not load Spoolman spools. This print is not tracked.', 'error'); + } catch (error) { + console.error('Failed to load Spoolman spools:', error); + showToast('Could not load Spoolman spools. This print is not tracked.', 'error'); + } + return null; +} + +/** + * Material-station status reads before the dialog gives up. Right after a + * connect or a printer switch the server can take several poll cycles to + * have a status, so the dialog keeps asking for about 15 seconds. + */ +const STATION_STATUS_ATTEMPTS = 12; +const STATION_STATUS_RETRY_MS = 1250; + export function resetMaterialMatchingState(): void { + const matchingState = getMaterialMatchingState(); setMaterialMatchingState(null); state.pendingJobStart = null; clearMaterialMessages(); updateMaterialMatchingConfirmState(); + + // Let a hosting flow (the job uploader) know the user backed out, so it can + // put its own dialog back into the "needs matching" state. + if (matchingState && !matchingState.confirmed) { + matchingState.hooks?.onCancel?.(); + } } export function closeMaterialMatchingModal(): void { hideElement('material-matching-modal'); + hideElement('material-spool-hint'); resetMaterialMatchingState(); } -export async function openMaterialMatchingModal(pending: PendingJobStart): Promise { +export async function openMaterialMatchingModal( + pending: PendingJobStart, + hooks?: MaterialMatchingHooks +): Promise { const modal = getMaterialMatchingElement('material-matching-modal'); const title = getMaterialMatchingElement('material-matching-title'); if (!modal || !pending || !pending.job || !isAD5XJobFile(pending.job)) { showToast('Material matching is not available for this job.', 'error'); resetMaterialMatchingState(); + hooks?.onCancel?.(); return; } @@ -440,6 +656,9 @@ export async function openMaterialMatchingModal(pending: PendingJobStart): Promi materialStation: null, selectedToolId: null, mappings: new Map(), + spoolChoices: new Map(), + spools: null, + hooks, }); if (title) { @@ -453,13 +672,42 @@ export async function openMaterialMatchingModal(pending: PendingJobStart): Promi clearMaterialMessages(); showElement('material-matching-modal'); - const status = await fetchMaterialStationStatus(); + void loadTrackingSpools(pending).then((spools) => { + const current = getMaterialMatchingState(); + if (!current || current.pending !== pending) { + return; + } + current.spools = spools; + if (spools !== null) { + showElement('material-spool-hint'); + } + renderMaterialMappings(); + updateMaterialMatchingConfirmState(); + }); + + // The server may not have a station status yet right after a connect or a + // printer switch, so read it a few times before reporting a problem. + let status: MaterialStationStatus | null = null; + for (let attempt = 1; attempt <= STATION_STATUS_ATTEMPTS; attempt++) { + status = await fetchMaterialStationStatus(); + if (status?.connected && status.slots.length > 0) { + break; + } + if (attempt < STATION_STATUS_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, STATION_STATUS_RETRY_MS)); + } + if (getMaterialMatchingState()?.pending !== pending) { + return; + } + } + const matchingState = getMaterialMatchingState(); - if (!matchingState) { + if (!matchingState || matchingState.pending !== pending) { return; } matchingState.materialStation = status; + clearMaterialMessages(); renderMaterialSlots(status); if (!status || !status.connected) { @@ -481,26 +729,42 @@ export async function confirmMaterialMatching(): Promise { return; } + if (!allSpoolsChosen()) { + showMaterialError('Choose a Spoolman spool for each tool, or choose "Do not track".'); + return; + } + const mappings = Array.from(matchingState.mappings.values()); + const spoolAssignments = collectSpoolAssignments(mappings); const confirmButton = getMaterialMatchingElement('material-matching-confirm'); if (confirmButton) { confirmButton.disabled = true; } - const success = await sendJobStartRequest({ - filename: matchingState.pending.filename, - leveling: matchingState.pending.leveling, - startNow: true, - materialMappings: mappings, - }); + // The job uploader supplies its own confirm handler: the file is not on the + // printer yet, so the mappings are handed back to its dialog instead of being + // sent straight to /api/jobs/start. + const onConfirm = matchingState.hooks?.onConfirm; + const success = onConfirm + ? await onConfirm(mappings, spoolAssignments) + : await sendJobStartRequest({ + filename: matchingState.pending.filename, + leveling: matchingState.pending.leveling, + startNow: true, + materialMappings: mappings, + spoolAssignments, + }); if (confirmButton) { confirmButton.disabled = false; } if (success) { - hideElement('file-modal'); + matchingState.confirmed = true; + if (!onConfirm) { + hideElement('file-modal'); + } closeMaterialMatchingModal(); } } diff --git a/src/webui/static/features/printer-discovery.ts b/src/webui/static/features/printer-discovery.ts index b3729e6..cd49f6d 100644 --- a/src/webui/static/features/printer-discovery.ts +++ b/src/webui/static/features/printer-discovery.ts @@ -13,6 +13,49 @@ interface DiscoveredPrinter { model?: string; commandPort?: number; eventPort?: number; + productId?: number; +} + +/** + * Internal USB product IDs sent for manual connections so the server types the + * model from the product ID and skips the legacy TCP probe entirely. Every modern + * (new-API) model is included: the product ID is authoritative for model typing, + * the user also supplies the serial, and `FiveMClient.initialize()` provides the + * real capability flags — leaving the probe with nothing to contribute (and it is + * impossible for the HTTP-only Creator 5 series). Legacy selections carry no + * product ID and fall through to the TCP probe on the server. + * + * Keys must match the `
@@ -221,7 +265,6 @@ function displayDiscoveredPrinters(matches: SavedPrinterMatch[]): void { }) .join(''); - // Setup connect buttons const connectBtns = printerList.querySelectorAll('.connect-printer-btn'); connectBtns.forEach((btn) => { btn.addEventListener('click', () => { @@ -231,6 +274,7 @@ function displayDiscoveredPrinters(matches: SavedPrinterMatch[]): void { const model = btn.getAttribute('data-model'); const commandPort = btn.getAttribute('data-command-port'); const eventPort = btn.getAttribute('data-event-port'); + const productId = btn.getAttribute('data-product-id'); if (ip) { void connectToDiscoveredPrinter( @@ -239,7 +283,8 @@ function displayDiscoveredPrinters(matches: SavedPrinterMatch[]): void { name || '', model || '', commandPort ? Number(commandPort) : undefined, - eventPort ? Number(eventPort) : undefined + eventPort ? Number(eventPort) : undefined, + productId ? Number(productId) : undefined ); } }); @@ -295,7 +340,8 @@ async function connectToDiscoveredPrinter( name: string, _model: string, commandPort?: number, - httpPort?: number + httpPort?: number, + productId?: number ): Promise { try { showToast('Detecting printer type...', 'info'); @@ -317,6 +363,7 @@ async function connectToDiscoveredPrinter( ipAddress: ip, commandPort, httpPort, + productId, }), }); @@ -334,11 +381,9 @@ async function connectToDiscoveredPrinter( // STEP 2: Get check code if needed let checkCode: string | undefined; if (requiresCheckCode && is5MFamily) { - // Check if printer is already saved with a check code const savedPrinter = await getSavedPrinterBySerial(detectedSerial); if (savedPrinter?.CheckCode && savedPrinter.CheckCode !== '123') { - // Use saved check code checkCode = savedPrinter.CheckCode; console.log('[Discovery] Using saved check code'); } else { @@ -370,6 +415,7 @@ async function connectToDiscoveredPrinter( checkCode, commandPort, httpPort, + productId, }), }); @@ -380,7 +426,6 @@ async function connectToDiscoveredPrinter( showToast(`Connected to ${detectedName}!`, 'success'); hideElement('discovery-modal'); - // Reload page to refresh printer list window.location.reload(); } catch (error) { console.error('Connection failed:', error); @@ -398,19 +443,49 @@ async function connectManually(): Promise { const ipInput = $('discovery-manual-ip') as HTMLInputElement | null; const typeSelect = $('discovery-printer-type') as HTMLSelectElement | null; const checkCodeInput = $('discovery-check-code') as HTMLInputElement | null; + const serialInput = $('discovery-serial-number') as HTMLInputElement | null; if (!ipInput || !typeSelect) return; - const ip = ipInput.value.trim(); + const rawHost = ipInput.value.trim(); const userSelectedType = typeSelect.value; const userCheckCode = checkCodeInput?.value.trim(); + const userSerialNumber = serialInput?.value.trim() ?? ''; - // Validate IP - if (!ip) { + // Validate IP (an optional :port suffix is accepted, e.g. 192.168.1.184:80) + if (!rawHost) { showToast('Please enter an IP address', 'error'); return; } + const parsedHost = parseHostInput(rawHost); + if (!parsedHost) { + showToast('Invalid IP address - use e.g. 192.168.1.100 or 192.168.1.100:80', 'error'); + return; + } + + const ip = parsedHost.ipAddress; + // A :port suffix on a legacy printer overrides the TCP command port; on a + // modern printer it overrides the HTTP API port. + const commandPort = userSelectedType === 'legacy' ? parsedHost.port : undefined; + const httpPort = userSelectedType === 'legacy' ? undefined : parsedHost.port; + // The Creator 5 series is HTTP-only (no TCP server), so the server must skip + // the legacy TCP probe. The internal product ID triggers that short-circuit. + const productId = MANUAL_PRODUCT_ID_HINTS[userSelectedType]; + + // All modern printers authenticate with serial number + check code, so both + // are required up front for manual connections. + if (userSelectedType !== 'legacy') { + if (!/^[A-Za-z0-9\-_]{3,}$/.test(userSerialNumber)) { + showToast('Please enter the printer serial number (found in printer settings)', 'error'); + return; + } + if (!userCheckCode || userCheckCode.length !== 8) { + showToast('Please enter the 8-digit check code', 'error'); + return; + } + } + try { showToast('Detecting printer type...', 'info'); @@ -427,7 +502,13 @@ async function connectManually(): Promise { }>('/api/printers/detect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ipAddress: ip }), + body: JSON.stringify({ + ipAddress: ip, + commandPort, + httpPort, + productId, + serialNumber: userSerialNumber || undefined, + }), }); if (!detectResponse.success || !detectResponse.typeName) { @@ -436,16 +517,23 @@ async function connectManually(): Promise { const { typeName, serialNumber, is5MFamily, requiresCheckCode, clientType } = detectResponse; const detectedName = detectResponse.name || `Printer at ${ip}`; - const detectedSerial = serialNumber || ''; + // The TCP probe's serial (firmware-reported) wins when available; the + // user-entered serial covers HTTP-only models the probe cannot reach. + const detectedSerial = serialNumber || userSerialNumber; const detectedType = clientType || (is5MFamily ? 'new' : 'legacy'); console.log(`[Manual] Detected: ${typeName} (${is5MFamily ? '5M family' : 'legacy'})`); - // STEP 2: Validate user selection against detection (warn if mismatch) - if (userSelectedType !== detectedType) { + // STEP 2: Validate user selection against detection (warn if mismatch). + // Every modern model resolves to the "new" client type on the wire, so only + // a modern-vs-legacy disagreement is worth warning about. + const userClientType = userSelectedType === 'legacy' ? 'legacy' : 'new'; + if (userClientType !== detectedType) { + const selectedLabel = typeSelect.options[typeSelect.selectedIndex]?.text ?? userSelectedType; + const detectedLabel = detectedType === 'legacy' ? 'a legacy printer' : `a modern printer`; const proceed = confirm( - `Warning: You selected "${userSelectedType}" but the printer was detected as "${detectedType}".\n\n` + - `Using detected type: ${detectedType}\n\nContinue?` + `Warning: You selected "${selectedLabel}" but the printer reports itself as ` + + `${detectedLabel} (${typeName}).\n\nConnect using the detected type instead?` ); if (!proceed) { return; @@ -455,22 +543,24 @@ async function connectManually(): Promise { // STEP 3: Get check code if needed let checkCode: string | undefined; if (requiresCheckCode && is5MFamily) { - // Check if printer is already saved with a check code - const savedPrinter = await getSavedPrinterBySerial(detectedSerial); - - if (savedPrinter?.CheckCode && savedPrinter.CheckCode !== '123') { - // Use saved check code - checkCode = savedPrinter.CheckCode; - console.log('[Manual] Using saved check code'); - } else if (userCheckCode && userCheckCode.length === 8) { - // Use user-provided check code from form + // The form's check code is required for modern printers, so it wins over + // any saved value — otherwise a stale saved code would silently override + // a freshly re-paired printer's new one. + if (userCheckCode && userCheckCode.length === 8) { checkCode = userCheckCode; } else { - // Prompt user for check code - checkCode = prompt(`Enter the check code for ${detectedName} (8 digits):`) || undefined; - if (!checkCode || checkCode.length !== 8) { - showToast('Invalid check code - must be 8 digits', 'error'); - return; + const savedPrinter = await getSavedPrinterBySerial(detectedSerial); + + if (savedPrinter?.CheckCode && savedPrinter.CheckCode !== '123') { + checkCode = savedPrinter.CheckCode; + console.log('[Manual] Using saved check code'); + } else { + // Prompt user for check code + checkCode = prompt(`Enter the check code for ${detectedName} (8 digits):`) || undefined; + if (!checkCode || checkCode.length !== 8) { + showToast('Invalid check code - must be 8 digits', 'error'); + return; + } } } } @@ -492,6 +582,9 @@ async function connectManually(): Promise { model: typeName, type: detectedType, checkCode, + commandPort, + httpPort, + productId, }), }); @@ -502,7 +595,6 @@ async function connectManually(): Promise { showToast('Connected successfully!', 'success'); hideElement('discovery-modal'); - // Reload page to refresh printer list window.location.reload(); } catch (error) { console.error('Manual connection failed:', error); @@ -560,7 +652,6 @@ async function loadSavedPrinters(): Promise { ) .join(''); - // Setup reconnect buttons const reconnectBtns = savedList.querySelectorAll('.reconnect-btn'); reconnectBtns.forEach((btn) => { btn.addEventListener('click', () => { @@ -571,7 +662,6 @@ async function loadSavedPrinters(): Promise { }); }); - // Setup delete buttons const deleteBtns = savedList.querySelectorAll('.delete-btn'); deleteBtns.forEach((btn) => { btn.addEventListener('click', () => { @@ -611,7 +701,6 @@ async function reconnectToSavedPrinter(serialNumber: string): Promise { showToast('Reconnected successfully!', 'success'); hideElement('discovery-modal'); - // Reload page window.location.reload(); } catch (error) { console.error('Reconnection failed:', error); @@ -640,7 +729,6 @@ async function deleteSavedPrinter(serialNumber: string): Promise { showToast('Printer deleted', 'success'); - // Reload saved printers list await loadSavedPrinters(); } catch (error) { console.error('Delete failed:', error); diff --git a/src/webui/static/features/reboot.ts b/src/webui/static/features/reboot.ts new file mode 100644 index 0000000..02c35ad --- /dev/null +++ b/src/webui/static/features/reboot.ts @@ -0,0 +1,217 @@ +/** + * @fileoverview Remote "Reboot Printer" feature for the WebUI client. + * + * Browser port of the desktop reboot flow: a topbar button (shown only for + * SSH-capable models: Adventurer 5M / 5M Pro / AD5X) opens a confirmation + * modal, then POSTs /api/printer/reboot. Reboot lifecycle updates arrive over + * REBOOT_STATUS WebSocket broadcasts and drive a full-screen overlay through + * the phases rebooting -> reconnecting -> reconnecting-services -> success + * (or terminal timeout / failed with a Retry option). + * + * Key exports: + * - setupReboot(): wire the topbar button, modal, overlay, and WS listener + * - refreshRebootButton(): show/hide the topbar button per model support + */ + +import type { RebootStatusPayload } from '../app.js'; +import { apiRequest, onRebootStatus } from '../core/Transport.js'; +import { showToast } from '../shared/dom.js'; +import { hydrateLucideIcons } from '../shared/icons.js'; +import { getCurrentContextId } from './context-switching.js'; + +interface RebootSupportResponse { + readonly success: boolean; + readonly supported?: boolean; + readonly printerName?: string; + readonly error?: string; +} + +interface RebootResponse { + readonly success: boolean; + readonly error?: string; +} + +/** Context of the reboot this client initiated; null when idle. */ +let rebootContextId: string | null = null; + +function el(id: string): HTMLElement { + const element = document.getElementById(id); + if (!element) { + throw new Error(`[Reboot] Missing element #${id}`); + } + return element; +} + +function show(element: HTMLElement): void { + element.classList.remove('hidden'); +} + +function hide(element: HTMLElement): void { + element.classList.add('hidden'); +} + +/** + * Show or hide the topbar reboot button based on the active printer's model. + */ +export async function refreshRebootButton(): Promise { + const button = document.getElementById('reboot-button'); + if (!button) { + return; + } + try { + const result = await apiRequest('/api/printer/reboot/support'); + button.classList.toggle('hidden', !(result.success && result.supported)); + } catch { + button.classList.add('hidden'); + } +} + +function setOverlay(options: { + title: string; + message: string; + spinning: boolean; + result?: 'success' | 'error'; + showRetry?: boolean; + showDismiss?: boolean; +}): void { + el('reboot-overlay-title').textContent = options.title; + el('reboot-overlay-message').textContent = options.message; + + el('reboot-spinner').classList.toggle('hidden', !options.spinning); + el('reboot-success-icon').classList.toggle('hidden', options.result !== 'success'); + el('reboot-error-icon').classList.toggle('hidden', options.result !== 'error'); + el('reboot-overlay-retry').classList.toggle('hidden', !options.showRetry); + el('reboot-overlay-dismiss').classList.toggle('hidden', !options.showDismiss); + + hydrateLucideIcons(['check-circle', 'alert-triangle'], el('reboot-overlay')); + show(el('reboot-overlay')); +} + +function closeOverlay(): void { + rebootContextId = null; + hide(el('reboot-overlay')); +} + +function handleRebootStatus(contextId: string, payload: RebootStatusPayload): void { + // Only surface updates for the reboot this client is watching. (A reboot + // triggered from the desktop is intentionally ignored here to avoid taking + // over every connected browser; parity with the desktop overlay, which is + // likewise scoped to its own window.) + if (!rebootContextId || contextId !== rebootContextId) { + return; + } + + const printerName = payload.printerName || 'Printer'; + + switch (payload.phase) { + case 'rebooting': + setOverlay({ + title: `Rebooting ${printerName}…`, + message: payload.message || 'Sending reboot command over SSH.', + spinning: true, + }); + break; + case 'reconnecting': + setOverlay({ + title: 'Waiting for printer…', + message: payload.message || `Waiting for ${printerName} to come back online.`, + spinning: true, + }); + break; + case 'reconnecting-services': + setOverlay({ + title: 'Reconnecting services…', + message: payload.message || 'Printer is back; waiting for services to stabilize.', + spinning: true, + }); + break; + case 'success': + setOverlay({ + title: 'Reboot complete', + message: payload.message || `${printerName} is back online.`, + spinning: false, + result: 'success', + showDismiss: true, + }); + break; + case 'timeout': + setOverlay({ + title: 'Reboot timed out', + message: + payload.message || `${printerName} didn't come back online. Check that it's powered on and reachable.`, + spinning: false, + result: 'error', + showRetry: true, + showDismiss: true, + }); + break; + case 'failed': + setOverlay({ + title: 'Reboot failed', + message: payload.message || 'The printer was disconnected during the reboot.', + spinning: false, + result: 'error', + showDismiss: true, + }); + break; + } +} + +async function executeReboot(): Promise { + const contextId = getCurrentContextId(); + if (!contextId) { + showToast('No printer is connected', 'error'); + return; + } + + hide(el('reboot-confirm-modal')); + rebootContextId = contextId; + setOverlay({ + title: 'Rebooting printer…', + message: 'Dispatching reboot command over SSH.', + spinning: true, + }); + + try { + const result = await apiRequest('/api/printer/reboot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contextId }), + }); + if (!result.success) { + setOverlay({ + title: 'Reboot failed', + message: result.error || 'The reboot could not be dispatched.', + spinning: false, + result: 'error', + showRetry: true, + showDismiss: true, + }); + } + } catch (error) { + setOverlay({ + title: 'Reboot failed', + message: error instanceof Error ? error.message : 'The reboot could not be dispatched.', + spinning: false, + result: 'error', + showRetry: true, + showDismiss: true, + }); + } +} + +/** Wire the topbar button, confirmation modal, overlay, and WS listener. */ +export function setupReboot(): void { + document.getElementById('reboot-button')?.addEventListener('click', () => { + show(el('reboot-confirm-modal')); + }); + + el('reboot-confirm-close').addEventListener('click', () => hide(el('reboot-confirm-modal'))); + el('reboot-cancel').addEventListener('click', () => hide(el('reboot-confirm-modal'))); + el('reboot-confirm').addEventListener('click', () => void executeReboot()); + + el('reboot-overlay-dismiss').addEventListener('click', closeOverlay); + el('reboot-overlay-retry').addEventListener('click', () => void executeReboot()); + + onRebootStatus(handleRebootStatus); +} diff --git a/src/webui/static/features/ssh.ts b/src/webui/static/features/ssh.ts new file mode 100644 index 0000000..dc69421 --- /dev/null +++ b/src/webui/static/features/ssh.ts @@ -0,0 +1,177 @@ +/** + * @fileoverview Per-printer SSH settings section for the WebUI settings modal. + * + * Mirrors the desktop Settings -> SSH tab against the /api/ssh-settings REST + * surface. Settings apply to the ACTIVE printer context and are stored by + * serial number, so they follow the printer across IP changes. + * + * Passwords are write-only over the WebUI: the server never returns the + * stored value. The password field stays blank on load; a placeholder hints + * whether a custom password is set. Typing a new value overwrites it, + * clearing the field (submitting an empty string) resets it to the easy-SSH + * default, and leaving it untouched keeps the current one. + * + * Key exports: + * - setupSSHSettings(): wire the save/reset buttons + * - refreshSSHSettings(): (re)load the section for the active printer + */ + +import { showToast } from '../shared/dom.js'; +import { apiRequest } from '../core/Transport.js'; + +interface WebUISSHSettings { + readonly username: string; + readonly port: number; + readonly keyPath?: string; + readonly isCustom: boolean; + readonly passwordIsCustom: boolean; +} + +interface SSHSettingsGetResponse { + readonly success: boolean; + readonly settings?: WebUISSHSettings; + readonly printerName?: string; + readonly error?: string; +} + +interface SSHSettingsSaveResponse { + readonly success: boolean; + readonly error?: string; +} + +const DEFAULT_HINT = 'Using the easy-SSH default password (flashforge). Leave blank to keep it.'; +const CUSTOM_HINT = 'A custom password is saved (not shown). Leave blank to keep it, or type a new one to replace it. Clear and save resets to the default.'; + +/** Tracks whether the password field was intentionally edited this session. */ +let passwordDirty = false; + +function input(id: string): HTMLInputElement { + const element = document.getElementById(id); + if (!(element instanceof HTMLInputElement)) { + throw new Error(`[SSHSettings] Missing input #${id}`); + } + return element; +} + +function setUnavailable(unavailable: boolean, reason?: string): void { + const form = document.getElementById('ssh-settings-form'); + const message = document.getElementById('ssh-settings-unavailable'); + const buttons = [document.getElementById('ssh-save-btn'), document.getElementById('ssh-reset-btn')]; + + form?.classList.toggle('hidden', unavailable); + if (message) { + message.classList.toggle('hidden', !unavailable); + message.textContent = reason || 'No printer is connected.'; + } + buttons.forEach((button) => { + if (button instanceof HTMLButtonElement) { + button.disabled = unavailable; + } + }); +} + +/** + * (Re)load the SSH section for the active printer. Call when the settings + * modal opens and after context switches. + */ +export async function refreshSSHSettings(): Promise { + passwordDirty = false; + + let response: SSHSettingsGetResponse; + try { + response = await apiRequest('/api/ssh-settings'); + } catch { + setUnavailable(true, 'Failed to load SSH settings.'); + return; + } + + if (!response.success || !response.settings) { + setUnavailable(true, response.error || 'No printer is connected.'); + return; + } + + setUnavailable(false); + + const settings = response.settings; + input('ssh-username').value = settings.username; + input('ssh-port').value = String(settings.port); + input('ssh-keypath').value = settings.keyPath ?? ''; + + const password = input('ssh-password'); + password.value = ''; + password.placeholder = settings.passwordIsCustom ? '•••••••• (custom saved)' : 'flashforge (default)'; + + const hint = document.getElementById('ssh-password-hint'); + if (hint) { + hint.textContent = settings.passwordIsCustom ? CUSTOM_HINT : DEFAULT_HINT; + } + + const heading = document.querySelector('#ssh-settings-section h3'); + if (heading) { + heading.textContent = response.printerName ? `SSH — ${response.printerName}` : 'SSH (Current Printer)'; + } +} + +async function saveSSHSettings(): Promise { + const port = Number(input('ssh-port').value); + const payload: Record = { + username: input('ssh-username').value, + keyPath: input('ssh-keypath').value, + }; + if (Number.isInteger(port) && port > 0) { + payload.port = port; + } else if (input('ssh-port').value.trim() === '') { + payload.port = 0; // 0 = reset to default on the server + } else { + showToast('Port must be a number between 1 and 65535', 'error'); + return; + } + + // Write-only password: only send it when the user actually touched the field. + if (passwordDirty) { + payload.password = input('ssh-password').value; + } + + try { + const result = await apiRequest('/api/ssh-settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!result.success) { + showToast(result.error || 'Failed to save SSH settings', 'error'); + return; + } + showToast('SSH settings saved', 'success'); + await refreshSSHSettings(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to save SSH settings', 'error'); + } +} + +async function resetSSHSettings(): Promise { + try { + const result = await apiRequest('/api/ssh-settings/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!result.success) { + showToast(result.error || 'Failed to reset SSH settings', 'error'); + return; + } + showToast('SSH settings reset to defaults', 'success'); + await refreshSSHSettings(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to reset SSH settings', 'error'); + } +} + +/** Wire the SSH section buttons. Call once at startup. */ +export function setupSSHSettings(): void { + input('ssh-password').addEventListener('input', () => { + passwordDirty = true; + }); + document.getElementById('ssh-save-btn')?.addEventListener('click', () => void saveSSHSettings()); + document.getElementById('ssh-reset-btn')?.addEventListener('click', () => void resetSSHSettings()); +} diff --git a/src/webui/static/grid/WebUIComponentRegistry.ts b/src/webui/static/grid/WebUIComponentRegistry.ts index 0a19539..bd507c4 100644 --- a/src/webui/static/grid/WebUIComponentRegistry.ts +++ b/src/webui/static/grid/WebUIComponentRegistry.ts @@ -137,6 +137,9 @@ const COMPONENT_TEMPLATES: Record = { +
+ +
`, @@ -307,6 +310,11 @@ const COMPONENT_TEMPLATES: Record = { +
+ + + +
+
+

SSH (Current Printer)

+

+ Credentials used by the file manager, calibration assistant, and reboot. + Defaults match the FlashForge-EasySSH provisioning tool. +

+ +
+ + + + +
+

+
+ + +
+

Reset

@@ -178,6 +247,128 @@ + + + @@ -249,7 +446,7 @@

Set Temperature

+ + + + + + + + + + + + + + + diff --git a/src/webui/static/shared/__tests__/formatting.test.ts b/src/webui/static/shared/__tests__/formatting.test.ts new file mode 100644 index 0000000..688aec6 --- /dev/null +++ b/src/webui/static/shared/__tests__/formatting.test.ts @@ -0,0 +1,63 @@ +/** + * @fileoverview Tests for the WebUI wall-clock completion time formatter. + * + * `formatCompletionTime` formats the absolute completion timestamp that the + * ff-api library supplies (null while the print is not advancing). It does not + * re-derive the time from a remaining duration, so it does not drift while the + * print is paused. `isPrintAdvancing` is kept as a browser-side mirror of the + * app-side predicate. + */ + +import { describe, expect, it } from '@jest/globals'; +import { formatCompletionTime, isPrintAdvancing } from '../formatting'; + +describe('isPrintAdvancing', () => { + it('is true only for the state that counts estimatedTime down', () => { + expect(isPrintAdvancing('Printing')).toBe(true); + }); + + // 'Heating' is not advancing: the pre-print warmup does not move the job on + // either, so it drifts the same way, just for minutes instead of hours. + it('is false for paused, heating, and every other non-advancing state', () => { + for (const state of [ + 'Heating', + 'Paused', + 'Pausing', + 'Ready', + 'Error', + 'Completed', + 'Cancelled', + 'Busy', + 'Calibrating', + ]) { + expect(isPrintAdvancing(state)).toBe(false); + } + expect(isPrintAdvancing(undefined)).toBe(false); + }); + + // Parity with the app-side copy in src/types/polling.ts is asserted in + // src/types/__tests__/polling.test.ts - this bundle's tsconfig pins rootDir + // to src/webui/static, so it cannot import across the boundary. +}); + +describe('formatCompletionTime', () => { + const completion = new Date('2026-01-01T14:30:00.000Z'); + const expected = completion.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + it('formats a fixed Date completion time as a wall-clock string', () => { + expect(formatCompletionTime(completion)).toBe(expected); + }); + + it('formats an ISO string completion time the same way', () => { + expect(formatCompletionTime(completion.toISOString())).toBe(expected); + }); + + it('returns "--:--" for null and invalid values', () => { + expect(formatCompletionTime(null)).toBe('--:--'); + expect(formatCompletionTime('not-a-date')).toBe('--:--'); + }); +}); diff --git a/src/webui/static/shared/__tests__/stored-file-tracking.test.ts b/src/webui/static/shared/__tests__/stored-file-tracking.test.ts new file mode 100644 index 0000000..0c24b69 --- /dev/null +++ b/src/webui/static/shared/__tests__/stored-file-tracking.test.ts @@ -0,0 +1,72 @@ +/** + * @fileoverview Tests for the browser-side stored-file tracking indicator. + * + * `describeStoredFileTracking` mirrors the server-side rule + * (services/job-tracking.ts) for the file modal: a multi-material file with + * printer-reported weights opens the matching dialog, where spools are + * chosen, so it can be tracked; a single-material file is not tracked. + * The predicate is duplicated across bundles on purpose; change both. + */ + +import { describe, expect, it } from '@jest/globals'; +import type { AD5XToolData, WebUIJobFile } from '../../app'; +import { describeStoredFileTracking } from '../stored-file-tracking'; + +const TRACKED = { spoolmanEnabled: true, hasStation: true }; + +function tool(toolId: number, filamentWeight: number): AD5XToolData { + return { toolId, filamentWeight, materialName: 'PLA', materialColor: '#ffffff' }; +} + +function ad5xFile(overrides: Partial = {}): WebUIJobFile { + return { + fileName: 'stored-single.3mf', + displayName: 'stored-single.3mf', + metadataType: 'ad5x', + toolCount: 1, + toolDatas: [], + totalFilamentWeight: 130, + ...overrides, + }; +} + +describe('describeStoredFileTracking', () => { + it('shows nothing when Spoolman is disabled', () => { + expect( + describeStoredFileTracking(ad5xFile(), { ...TRACKED, spoolmanEnabled: false }) + ).toBeNull(); + }); + + it('shows nothing for printers without a material station', () => { + expect(describeStoredFileTracking(ad5xFile(), { ...TRACKED, hasStation: false })).toBeNull(); + }); + + it('shows nothing for files without AD5X metadata (Creator 5 upload-only)', () => { + expect( + describeStoredFileTracking(ad5xFile({ metadataType: 'basic', toolDatas: undefined }), TRACKED) + ).toBeNull(); + }); + + it('marks single-material files as not tracked', () => { + const hint = describeStoredFileTracking(ad5xFile({ toolDatas: [tool(0, 40)] }), TRACKED); + expect(hint?.tracked).toBe(false); + expect(hint?.label).toContain('upload via app'); + }); + + it('marks multi-material files with weights as trackable through the dialog', () => { + const hint = describeStoredFileTracking( + ad5xFile({ toolCount: 2, toolDatas: [tool(0, 60), tool(1, 70)] }), + TRACKED + ); + expect(hint).toEqual({ tracked: true, label: 'Spoolman: choose spools when you match materials' }); + }); + + it('marks multi-material files without weights as not tracked', () => { + const hint = describeStoredFileTracking( + ad5xFile({ toolCount: 2, toolDatas: [tool(0, 0), tool(1, 0)] }), + TRACKED + ); + expect(hint?.tracked).toBe(false); + expect(hint?.tooltip).toContain('no filament weight'); + }); +}); diff --git a/src/webui/static/shared/formatting.ts b/src/webui/static/shared/formatting.ts index e0fd6cb..b660561 100644 --- a/src/webui/static/shared/formatting.ts +++ b/src/webui/static/shared/formatting.ts @@ -82,9 +82,38 @@ export function formatTime(minutes: number): string { return `${mins}:00`; } -export function formatETA(remainingMinutes: number): string { - const now = new Date(); - const completionTime = new Date(now.getTime() + remainingMinutes * 60 * 1000); +/** + * Whether the print is advancing, i.e. whether the firmware is still counting + * `estimatedTime` down. + * + * Browser-side mirror of `isPrintAdvancing` in src/types/polling.ts - the WebUI + * static bundle has its own module graph and cannot import from there. + * + * Outside 'Printing' the field freezes while the wall clock keeps moving, so the + * `now() + remaining` conversions below walk forward a minute every minute + * instead of holding still. Gate them on this; the remaining *duration* stays + * correct in every state and needs no gate. 'Heating' is excluded for the same + * reason as the paused states - the warmup does not advance the job either. + */ +export function isPrintAdvancing(printerState: string | undefined): boolean { + return printerState === 'Printing'; +} + +/** + * Format the library completion time as a wall-clock string. + * + * The value is an absolute timestamp from ff-api (null when the print is not + * advancing). Do not re-derive it from a remaining duration. + */ +export function formatCompletionTime(value: Date | string | null): string { + if (value === null || value === undefined) { + return '--:--'; + } + + const completionTime = new Date(value); + if (Number.isNaN(completionTime.getTime())) { + return '--:--'; + } return completionTime.toLocaleTimeString('en-US', { hour: 'numeric', @@ -133,16 +162,4 @@ export function formatElapsedSeconds(seconds: number): string { return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; } -/** - * Convert a firmware ETA string (HH:MM remaining) to a wall-clock completion time. - */ -export function formatETAFromString(hhmm: string): string { - const [hours, minutes] = hhmm.split(':').map(Number); - const completionTime = new Date(Date.now() + (hours * 60 + minutes) * 60_000); - return completionTime.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); -} diff --git a/src/webui/static/shared/icons.ts b/src/webui/static/shared/icons.ts index ec18d95..f168ebc 100644 --- a/src/webui/static/shared/icons.ts +++ b/src/webui/static/shared/icons.ts @@ -1,4 +1,4 @@ -/** +/** * @fileoverview Lucide icon utilities for the WebUI static client. * * Handles converting icon names to PascalCase, hydrating Lucide icons inside @@ -75,5 +75,28 @@ export function hydrateLucideIcons( } export function initializeLucideIcons(): void { - hydrateLucideIcons(['settings', 'lock', 'package', 'search', 'circle'], document); + // Pre-hydrate every icon referenced by static `data-lucide` markup in + // index.html so it renders at boot. The vendored lucide build (v0.552.0) + // dropped its legacy kebab-case aliases, so lucide's own bare `createIcons()` + // scan can no longer resolve names like `hard-drive` or `alert-triangle`. + // `hydrateLucideIcons` converts each name to PascalCase (e.g. `alert-triangle` + // -> `AlertTriangle`, which still exists in the registry), registers the node, + // and renders it in one pass. Icons only used inside dynamically rendered + // markup (file-manager tiles, calibration tabs, reboot overlay) are hydrated + // by their own scoped `hydrateLucideIcons(...)` calls at render time. + hydrateLucideIcons( + [ + // Header / global chrome + 'settings', 'lock', 'package', 'search', 'circle', 'folder', 'gauge', 'power', 'info', + // Printer discovery modal + 'wifi', 'keyboard', 'archive', 'link', + // File manager / storage browser + 'hard-drive', 'usb', 'refresh-cw', 'check-square', 'square', 'trash-2', + 'pencil', 'alert-triangle', + // Calibration modal + shared action icons + 'grid-3x3', 'activity', 'terminal', 'history', 'file-up', 'download', 'play', + 'sparkles', 'copy', 'upload', 'plug', 'unplug', 'check-circle', + ], + document, + ); } diff --git a/src/webui/static/shared/stored-file-tracking.ts b/src/webui/static/shared/stored-file-tracking.ts new file mode 100644 index 0000000..e9f337a --- /dev/null +++ b/src/webui/static/shared/stored-file-tracking.ts @@ -0,0 +1,70 @@ +/** + * @fileoverview Tracked/untracked indicator for stored-file prints on + * material-station printers (AD5X with station). + * + * Mirrors the server-side rule in services/job-tracking.ts so the file modal + * can tell the user, before they start a job stored on the printer, whether + * Spoolman will track it: + * - a multi-material file opens the matching dialog, where the user picks a + * spool per tool, so it can be tracked; + * - a single-material file starts without the dialog, so it is not tracked. + * + * The two run in separate bundles, so the predicate is duplicated on purpose + * (same situation as isPrintAdvancing); change both together. + */ + +import type { WebUIJobFile } from '../app.js'; +import { isAD5XJobFile } from './formatting.js'; + +/** Rendered indicator for one stored file. Null = show nothing. */ +export interface StoredFileTrackingHint { + readonly tracked: boolean; + readonly label: string; + readonly tooltip?: string; +} + +const TRACKABLE_LABEL = 'Spoolman: choose spools when you match materials'; +const UNTRACKED_LABEL = 'Spoolman: not tracked · upload via app for tracking'; + +/** + * Compute the tracking indicator for a stored file. + * + * @param job - File metadata from the jobs/local|recent list + * @param options - Spoolman enablement and station support for the context + * @returns the hint to render, or null when tracking is not applicable + */ +export function describeStoredFileTracking( + job: WebUIJobFile | undefined, + options: { + spoolmanEnabled: boolean; + hasStation: boolean; + } +): StoredFileTrackingHint | null { + if (!options.spoolmanEnabled || !options.hasStation) { + return null; + } + + // Only AD5X file metadata is rich enough; Creator 5 stored files cannot be + // started from the app and non-station printers use the single-spool flow. + if (!isAD5XJobFile(job)) { + return null; + } + + const toolCount = job.toolCount ?? job.toolDatas.length; + if (toolCount > 1 || job.toolDatas.length > 1) { + const weighed = job.toolDatas.some((tool) => tool.filamentWeight > 0); + return weighed + ? { tracked: true, label: TRACKABLE_LABEL } + : { + tracked: false, + label: UNTRACKED_LABEL, + tooltip: 'the printer reports no filament weight for this file', + }; + } + + return { + tracked: false, + label: UNTRACKED_LABEL, + tooltip: 'single-material files start without the matching dialog, so no spool is chosen', + }; +} diff --git a/src/webui/static/ui/dialogs.ts b/src/webui/static/ui/dialogs.ts index 8e9a073..3a42005 100644 --- a/src/webui/static/ui/dialogs.ts +++ b/src/webui/static/ui/dialogs.ts @@ -7,7 +7,7 @@ * material matching, printer commands) through dependency callbacks. */ -import type { FileListResponse, WebUIJobFile } from '../app.js'; +import type { ApiResponse, FileListResponse, WebUIJobFile } from '../app.js'; import { state } from '../core/AppState.js'; import { apiRequest } from '../core/Transport.js'; import { $, hideElement, showElement, showToast } from '../shared/dom.js'; @@ -17,6 +17,8 @@ import { isAD5XJobFile, isMultiColorJobFile, } from '../shared/formatting.js'; +import { describeStoredFileTracking } from '../shared/stored-file-tracking.js'; +import { loadSpoolmanConfig } from '../features/spoolman.js'; /** * A settable heater target. Single-nozzle printers use `bed`/`extruder`; the @@ -75,11 +77,29 @@ function currentTargetTemperature(target: TemperatureTarget): number { let dialogHandlers: DialogHandlers = {}; +/** Monotonic token identifying the current thumbnail-loading batch (for cancellation). */ +let thumbnailRunId = 0; + export async function loadFileList(source: 'recent' | 'local'): Promise { if (state.authRequired && !state.authToken) { return; } + // Defensive: Creator 5 series exposes no resident-file material mappings over + // the local API, so local/recent jobs can't be started. The entry buttons are + // also disabled for these printers (see updateButtonStates in panels.ts); this + // guard keeps any other caller from dead-ending too. + if (state.printerFeatures?.hasMultiTool) { + showToast('Local job management is not available on this printer.', 'error'); + return; + } + + // The stored-file tracking indicator needs the Spoolman slot assignments; + // fetch them lazily if the Spoolman panel has never loaded the config. + if (state.spoolmanConfig === null) { + void loadSpoolmanConfig(); + } + try { const result = await apiRequest(`/api/jobs/${source}`); @@ -122,6 +142,14 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') item.className = 'file-item'; item.dataset.filename = file.fileName; + const thumbnail = document.createElement('div'); + thumbnail.className = 'file-thumbnail loading'; + thumbnail.textContent = 'Loading…'; + item.appendChild(thumbnail); + + const body = document.createElement('div'); + body.className = 'file-item-body'; + const header = document.createElement('div'); header.className = 'file-item-header'; @@ -138,7 +166,7 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') header.appendChild(badge); } - item.appendChild(header); + body.appendChild(header); const meta = document.createElement('div'); meta.className = 'file-meta'; @@ -158,6 +186,22 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') meta.appendChild(material); } + const trackingHint = describeStoredFileTracking(file, { + spoolmanEnabled: Boolean(state.spoolmanConfig?.enabled), + hasStation: Boolean(state.printerFeatures?.hasMaterialStation), + }); + if (trackingHint) { + const indicator = document.createElement('span'); + indicator.className = `file-meta-item spoolman-track-hint ${ + trackingHint.tracked ? 'spoolman-track-hint--tracked' : 'spoolman-track-hint--untracked' + }`; + indicator.textContent = trackingHint.label; + if (trackingHint.tooltip) { + indicator.title = trackingHint.tooltip; + } + meta.appendChild(indicator); + } + if (isAD5XJobFile(file) && file.toolDatas.length > 0) { const requirementSummary = document.createElement('div'); requirementSummary.className = 'file-material-requirements'; @@ -183,9 +227,11 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') } if (meta.childElementCount > 0) { - item.appendChild(meta); + body.appendChild(meta); } + item.appendChild(body); + item.addEventListener('click', () => { fileList.querySelectorAll('.file-item').forEach((el) => { el.classList.remove('selected'); @@ -203,6 +249,76 @@ export function showFileModal(files: WebUIJobFile[], source: 'recent' | 'local') }); showElement('file-modal'); + + // Lazily fetch preview thumbnails once the list is on screen. The run token lets an + // in-flight batch bail out when the modal is closed or a different list is opened. + void loadThumbnails(files, ++thumbnailRunId); +} + +interface JobThumbnailResponse extends ApiResponse { + thumbnail?: string | null; +} + +/** + * Sequentially request a preview thumbnail for each file and paint it into the matching + * card. Requests are serialized (one in flight at a time) to stay gentle on the printer's + * single connection; the server caches results per printer so re-opening the dialog is + * instant. Aborts as soon as `runId` no longer matches the active batch. + */ +async function loadThumbnails(files: WebUIJobFile[], runId: number): Promise { + for (const file of files) { + if (runId !== thumbnailRunId) { + return; + } + + let thumbnail: string | null = null; + try { + const result = await apiRequest( + `/api/jobs/thumbnail?filename=${encodeURIComponent(file.fileName)}` + ); + thumbnail = result.success ? result.thumbnail ?? null : null; + } catch (error) { + console.error(`Failed to load thumbnail for ${file.fileName}:`, error); + } + + if (runId !== thumbnailRunId) { + return; + } + + applyThumbnail(file.fileName, thumbnail); + } +} + +/** Replace a card's loading placeholder with the fetched image or a "No Preview" note. */ +function applyThumbnail(fileName: string, thumbnail: string | null): void { + const fileList = $('file-list'); + if (!fileList) { + return; + } + + const item = Array.from(fileList.querySelectorAll('.file-item')).find( + (el) => el.dataset.filename === fileName + ); + const slot = item?.querySelector('.file-thumbnail'); + if (!slot) { + return; + } + + slot.classList.remove('loading'); + + if (thumbnail) { + const image = document.createElement('img'); + image.src = thumbnail.startsWith('data:image/') + ? thumbnail + : `data:image/png;base64,${thumbnail}`; + image.alt = fileName; + slot.textContent = ''; + slot.appendChild(image); + slot.classList.add('has-image'); + } else { + slot.textContent = 'No Preview'; + slot.classList.add('no-preview'); + } } export function showTemperatureDialog(target: TemperatureTarget): void { @@ -321,6 +437,8 @@ export function setupDialogEventHandlers(handlers: DialogHandlers = {}): void { function closeFileModal(): void { hideElement('file-modal'); state.selectedFile = null; + // Cancel any in-flight thumbnail batch for the now-closed list. + thumbnailRunId++; if (isMaterialMatchingVisible()) { dialogHandlers.onMaterialMatchingClosed?.(); diff --git a/src/webui/static/ui/panels.ts b/src/webui/static/ui/panels.ts index 8e295fc..2a1ef6a 100644 --- a/src/webui/static/ui/panels.ts +++ b/src/webui/static/ui/panels.ts @@ -8,14 +8,13 @@ * actions. */ -import type { PrinterStatus } from '../app.js'; +import type { DeductionSummary, PrinterStatus, SpoolmanStationTracking } from '../app.js'; import { state } from '../core/AppState.js'; import { isSpoolmanAvailableForCurrentContext } from '../features/layout-theme.js'; import { $, hideElement, setTextContent, showElement } from '../shared/dom.js'; import { + formatCompletionTime, formatElapsedSeconds, - formatETA, - formatETAFromString, formatLifetimeFilament, formatLifetimePrintTime, formatTime, @@ -98,13 +97,9 @@ export function updatePrinterStatus(status: PrinterStatus | null): void { setTextContent('elapsed-time', '--:--'); } - if (status.formattedEta && status.formattedEta !== '--:--') { - setTextContent('time-remaining', formatETAFromString(status.formattedEta)); - } else if (status.timeRemaining !== undefined && !Number.isNaN(status.timeRemaining)) { - setTextContent('time-remaining', formatETA(status.timeRemaining)); - } else { - setTextContent('time-remaining', '--:--'); - } + // The library sets completionTime to null while the print is not + // advancing, so the display falls back to '--:--' automatically. + setTextContent('time-remaining', formatCompletionTime(status.completionTime ?? null)); if (status.estimatedWeight !== undefined && !Number.isNaN(status.estimatedWeight)) { setTextContent('job-weight', `${Math.round(status.estimatedWeight)}g`); @@ -228,9 +223,11 @@ export function updateCreator5TemperatureCard(status: PrinterStatus | null): voi } /** - * Show the read-only TVOC air-quality reading for the Creator 5 Pro and hide its - * (non-functional) filtration controls. Other printers keep their filtration - * controls and hide the TVOC block. + * Populate the read-only TVOC air-quality reading and gate the filtration + * controls. Every printer that mounts this card (filtration-capable printers and + * the Creator 5 Pro) shows both its filtration mode/controls and its TVOC level. + * The Creator 5 Pro reports TVOC but cannot switch filtration modes, so its + * control buttons are shown but disabled. */ export function updateTvocDisplay(status: PrinterStatus | null): void { const tvocInfo = $('tvoc-info'); @@ -239,17 +236,25 @@ export function updateTvocDisplay(status: PrinterStatus | null): void { return; } - const isCreator5Pro = Boolean(state.printerFeatures?.isCreator5Pro); + // The card itself is only mounted for filtration-capable printers or the + // Creator 5 Pro (see layout-theme component gating), so whenever it exists we + // show both the filtration controls and the TVOC readout together. + filtrationSection?.classList.remove('hidden'); + tvocInfo?.classList.remove('hidden'); - filtrationSection?.classList.toggle('hidden', isCreator5Pro); - tvocInfo?.classList.toggle('hidden', !isCreator5Pro); + const tvoc = status?.tvocLevel; + setTextContent( + 'tvoc-status', + tvoc !== undefined && !Number.isNaN(tvoc) ? `${Math.round(tvoc)}` : '--' + ); - if (isCreator5Pro) { - const tvoc = status?.tvocLevel; - setTextContent( - 'tvoc-status', - tvoc !== undefined && !Number.isNaN(tvoc) ? `${Math.round(tvoc)}` : '--' - ); + // The Creator 5 Pro exposes a TVOC sensor but no filtration-mode control. + const isCreator5Pro = Boolean(state.printerFeatures?.isCreator5Pro); + for (const id of ['btn-external-filtration', 'btn-internal-filtration', 'btn-no-filtration']) { + const btn = $(id) as HTMLButtonElement | null; + if (btn) { + btn.disabled = isCreator5Pro; + } } } @@ -327,11 +332,24 @@ export function updateSpoolmanPanelState(): void { const disabled = $('spoolman-disabled'); const noSpool = $('spoolman-no-spool'); const active = $('spoolman-active'); + const station = $('spoolman-station'); if (!disabled || !noSpool || !active) { return; } + // Material-station contexts: per-job tracking view instead of the + // single active-spool view. + if (station && state.spoolmanConfig?.station) { + hideElement('spoolman-disabled'); + hideElement('spoolman-no-spool'); + hideElement('spoolman-active'); + showElement('spoolman-station'); + renderStationTracking(state.spoolmanConfig.station); + return; + } + hideElement('spoolman-station'); + if (!isSpoolmanAvailableForCurrentContext()) { showElement('spoolman-disabled'); hideElement('spoolman-no-spool'); @@ -393,6 +411,82 @@ export function updateSpoolmanPanelState(): void { } } +/** + * Render the per-job tracking view for material-station contexts: the + * explanation, the job tracked now with its spool per tool, and the last + * deduction summary. + */ +function renderStationTracking(station: SpoolmanStationTracking): void { + const note = $('spoolman-station-note'); + if (note) { + note.textContent = station.note; + } + + const jobView = $('spoolman-station-job'); + if (jobView) { + jobView.textContent = ''; + const job = station.activeJob; + if (!job) { + jobView.appendChild(createStatRow('Tracked print:', 'None')); + } else { + const progress = job.started + ? job.lastProgress !== null + ? `${Math.round(job.lastProgress)}%` + : 'printing' + : 'waiting to start'; + jobView.appendChild(createStatRow('Tracked print:', `${job.fileName} (${progress})`)); + for (const tool of job.tools) { + const estimate = tool.usedG !== null ? ` · ${tool.usedG.toFixed(1)} g` : ''; + jobView.appendChild( + createStatRow(`Tool ${tool.toolId + 1} (slot ${tool.slotId}):`, `Spool #${tool.spoolId}${estimate}`) + ); + } + } + } + + const summary = $('spoolman-station-summary'); + if (summary) { + summary.textContent = describeDeduction(station.lastDeduction); + } +} + +function createStatRow(label: string, value: string): HTMLDivElement { + const row = document.createElement('div'); + row.className = 'stat-row'; + const labelEl = document.createElement('span'); + labelEl.textContent = label; + const valueEl = document.createElement('span'); + valueEl.textContent = value; + row.append(labelEl, valueEl); + return row; +} + +function describeDeduction(deduction: DeductionSummary | null): string { + if (!deduction) { + return 'No deduction recorded this session.'; + } + const ending: Record = { + completed: 'completed', + cancelled: 'was cancelled', + error: 'stopped with an error', + interrupted: 'ended while the app was not watching', + }; + const approximate = deduction.approximate ? ' (approximate)' : ''; + const charged = deduction.tools + .filter((tool) => tool.status === 'deducted' && tool.amount !== null) + .map( + (tool) => + `#${tool.spoolId} ${tool.amount}${tool.mode === 'weight' ? ' g' : ' mm'}` + ) + .join(', '); + return ( + `${deduction.fileName} ${ending[deduction.terminal]} at ${Math.round(deduction.progress)}%${approximate}: ` + + `${deduction.deductedCount} spool(s) charged` + + (charged ? ` (${charged})` : '') + + (deduction.skippedCount > 0 ? `, ${deduction.skippedCount} skipped` : '') + ); +} + function updateButtonStates(printerState: string): void { const isPrintingActive = printerState === 'Printing' || @@ -424,9 +518,32 @@ function updateButtonStates(printerState: string): void { const localBtn = $('btn-start-local') as HTMLButtonElement | null; const homeAxesBtn = $('btn-home-axes') as HTMLButtonElement | null; - if (recentBtn) recentBtn.disabled = !isReadyForNewJob; - if (localBtn) localBtn.disabled = !isReadyForNewJob; - if (homeAxesBtn) homeAxesBtn.disabled = isPrintingActive; + // Creator 5 series firmware neither sends nor accepts material mappings + // over the local API, so starting a previously-uploaded local/recent job + // dead-ends. Disable the resident-file entry points on those printers; a + // fresh 3mf upload + start still works and is left untouched. + const isCreator5Series = Boolean(state.printerFeatures?.hasMultiTool); + const LOCAL_JOB_UNAVAILABLE_MSG = 'Local job management is not available on this printer.'; + if (recentBtn) { + recentBtn.disabled = !isReadyForNewJob || isCreator5Series; + recentBtn.title = isCreator5Series ? LOCAL_JOB_UNAVAILABLE_MSG : ''; + } + if (localBtn) { + localBtn.disabled = !isReadyForNewJob || isCreator5Series; + localBtn.title = isCreator5Series ? LOCAL_JOB_UNAVAILABLE_MSG : ''; + } + // Uploading a fresh file works on every model, Creator 5 series included - + // the 3mf is parsed here, so material mappings are available on upload even + // where the resident-job lists are not. + const uploadJobBtn = $('btn-upload-job') as HTMLButtonElement | null; + if (uploadJobBtn) { + uploadJobBtn.disabled = !isReadyForNewJob; + } + // Home Axes (~G28) is a raw G-code command. HTTP-only printers (Creator 5 + // series) expose no TCP/G-code passthrough, so disable the button while printing + // OR when the active printer explicitly reports G-code commands unavailable. + const gcodeUnavailable = state.printerFeatures?.gcodeCommands?.available === false; + if (homeAxesBtn) homeAxesBtn.disabled = isPrintingActive || gcodeUnavailable; const clearStatusBtn = $('btn-clear-status') as HTMLButtonElement | null; if (clearStatusBtn) clearStatusBtn.disabled = isPrintingActive; diff --git a/src/webui/static/webui.css b/src/webui/static/webui.css index 58f034a..a93cac0 100644 --- a/src/webui/static/webui.css +++ b/src/webui/static/webui.css @@ -1,4 +1,4 @@ -/** +/** * Web UI Stylesheet * Theme colors are set dynamically via JavaScript from --theme-* CSS variables */ @@ -384,7 +384,7 @@ body { } .grid-stack.edit-mode .panel-header::before { - content: "⋮⋮"; + content: "⋮⋮"; margin-right: 8px; opacity: 0.5; font-weight: 400; @@ -731,6 +731,32 @@ body { max-width: 350px; } +.about-details { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 16px; + margin: 0 0 16px; +} + +.about-details dt { + color: var(--text-color-secondary); +} + +.about-details dd { + margin: 0; + user-select: all; +} + +.about-links { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.about-links a { + color: var(--accent-color); +} + .modal-content.material-matching { max-width: 860px; } @@ -802,6 +828,38 @@ body { color: var(--warning-color); } +.material-matching-warning-card { + margin-bottom: 16px; + padding: 12px 16px; + border: 1px solid var(--warning-alpha-40, var(--border-color)); + border-radius: 10px; + background-color: var(--surface-muted); +} + +.material-matching-warning-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--warning-color, var(--text-color-secondary)); +} + +.material-matching-warning-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 3px 0; + font-size: 14px; + color: var(--text-color); +} + +.material-matching-warning-item::before { + content: "⚠"; + color: var(--warning-color, var(--text-color-secondary)); + flex-shrink: 0; +} + .material-matching-layout { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -978,6 +1036,21 @@ body { background-color: rgba(255, 152, 0, 0.1); } +.material-mapping-content { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.material-mapping-swatch { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + .material-mapping-text { font-size: 14px; color: var(--text-color); @@ -1178,8 +1251,9 @@ body { background-color var(--transition-fast), transform var(--transition-fast); display: flex; - flex-direction: column; - gap: 8px; + flex-direction: row; + align-items: center; + gap: 12px; border: 1px solid var(--border-color); } @@ -1194,6 +1268,46 @@ body { color: #ffffff; } +.file-item-body { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 0; +} + +.file-thumbnail { + flex: 0 0 auto; + width: 72px; + height: 72px; + border-radius: 8px; + border: 1px solid var(--border-color); + background-color: var(--card-bg-hover); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + font-size: 11px; + text-align: center; + color: var(--text-color-secondary); + padding: 4px; +} + +.file-thumbnail img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.file-thumbnail.loading { + opacity: 0.7; +} + +.file-item.selected .file-thumbnail { + border-color: #ffffff; +} + .file-item-header { display: flex; align-items: center; @@ -1239,6 +1353,16 @@ body { gap: 6px; } +/* Spoolman tracked/untracked indicator for stored files (station printers). */ +.spoolman-track-hint--tracked { + color: var(--success-color, #4caf50); +} + +.spoolman-track-hint--untracked { + color: var(--text-muted, #888); + cursor: help; +} + .material-preview { display: flex; flex-wrap: wrap; @@ -2085,3 +2209,1751 @@ body { .grid-item-hidden { display: none; } + + +/* ============================================================================ + SSH FEATURES (file manager / calibration / reboot) - ported from FlashForgeUI + ============================================================================ */ + +:root { + --primary-alpha-10: color-mix(in srgb, var(--theme-primary) 10%, transparent); + --primary-alpha-15: color-mix(in srgb, var(--theme-primary) 15%, transparent); + --primary-alpha-20: color-mix(in srgb, var(--theme-primary) 20%, transparent); + --primary-alpha-35: color-mix(in srgb, var(--theme-primary) 35%, transparent); + --primary-alpha-40: color-mix(in srgb, var(--theme-primary) 40%, transparent); + --primary-alpha-60: color-mix(in srgb, var(--theme-primary) 60%, transparent); + --overlay-bg: rgba(0, 0, 0, 0.6); + --surface-muted: color-mix(in srgb, var(--theme-text) 10%, var(--theme-surface)); + --text-on-primary: #ffffff; +} +/* FILE MANAGER (SFTP printer files) */ + +.fm-modal-content { + position: relative; + display: flex; + flex-direction: column; + height: min(720px, 90vh); +} + +.fm-modal-body { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.fm-tabs { + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border-color); + padding-bottom: 8px; + flex-shrink: 0; +} + +.fm-tab { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-color-secondary); + font-size: 0.95rem; + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.fm-tab:hover { + background: var(--card-bg-hover); + color: var(--text-color); +} + +.fm-tab.active { + background: var(--primary-alpha-20); + border-color: var(--theme-primary); + color: var(--text-color); +} + +.fm-tab .lucide-icon { + width: 16px; + height: 16px; +} + +.fm-tabs-spacer { + flex: 1; +} + +.fm-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + flex-shrink: 0; + min-height: 48px; +} + +.fm-toolbar-actions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.fm-toolbar-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 0.85rem; + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.fm-toolbar-btn:hover:not(:disabled) { + background: var(--card-bg-hover); + border-color: var(--border-color-focus); +} + +.fm-toolbar-btn:disabled { + opacity: 0.5; + cursor: default; +} + +.fm-toolbar-btn .lucide-icon { + width: 15px; + height: 15px; +} + +.fm-toolbar-btn.danger { + background: color-mix(in srgb, var(--error-color) 18%, var(--theme-surface)); + border-color: color-mix(in srgb, var(--error-color) 60%, var(--border-color)); +} + +.fm-toolbar-btn.danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--error-color) 32%, var(--theme-surface)); +} + +.fm-breadcrumb { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--text-color-secondary); + min-width: 0; +} + +.fm-breadcrumb button { + background: none; + border: none; + color: var(--theme-primary); + cursor: pointer; + font-size: 0.85rem; + padding: 4px 6px; + border-radius: 4px; +} + +.fm-breadcrumb button:hover { + background: var(--card-bg-hover); +} + +.fm-breadcrumb .crumb-current { + padding: 4px 6px; + color: var(--text-color); + font-weight: 500; +} + +.fm-breadcrumb .lucide-icon { + width: 13px; + height: 13px; + opacity: 0.6; +} + +.fm-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 12px; +} + +.fm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 14px; + padding: 4px 4px 12px 4px; + overflow-y: auto; + flex: 1; + align-content: start; +} + +.fm-item { + background-color: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 10px 10px 10px; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.fm-item:hover { + background-color: var(--card-bg-hover); + border-color: var(--border-color-focus); +} + +.fm-item.selected { + background-color: var(--primary-alpha-15); + border-color: var(--theme-primary); +} + +.fm-item.directory { + cursor: pointer; +} + +.fm-item.pending { + opacity: 0.45; + pointer-events: none; +} + +.fm-item-controls { + position: absolute; + top: 6px; + right: 6px; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity var(--transition-fast); + z-index: 5; +} + +.fm-item:hover .fm-item-controls, +.fm-item.selected .fm-item-controls { + opacity: 1; +} + +.fm-item-btn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--theme-background) 80%, transparent); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); + padding: 0; +} + +.fm-item-btn:hover { + background: var(--card-bg-hover); + border-color: var(--border-color-focus); +} + +.fm-item-btn.delete:hover { + background: color-mix(in srgb, var(--error-color) 30%, var(--theme-surface)); + border-color: var(--error-color); +} + +.fm-item-btn .lucide-icon { + width: 14px; + height: 14px; +} + +.fm-item-check { + position: absolute; + top: 6px; + left: 6px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--theme-background) 80%, transparent); + border: 1px solid var(--border-color); + border-radius: 6px; + color: transparent; + cursor: pointer; + opacity: 0; + transition: opacity var(--transition-fast), background-color var(--transition-fast); + z-index: 5; + padding: 0; +} + +.fm-item:hover .fm-item-check, +.fm-item.selected .fm-item-check { + opacity: 1; +} + +.fm-item.selected .fm-item-check { + background: var(--theme-primary); + border-color: var(--theme-primary); + color: var(--text-on-primary); +} + +.fm-item-check .lucide-icon { + width: 13px; + height: 13px; +} + +.fm-thumbnail { + width: 110px; + height: 110px; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--surface-muted); + border-radius: 6px; + margin-bottom: 8px; + overflow: hidden; +} + +.fm-thumbnail img { + max-width: 100%; + max-height: 100%; +} + +.fm-thumbnail .lucide-icon { + width: 42px; + height: 42px; + opacity: 0.35; +} + +.fm-thumbnail .thumb-spinner { + width: 22px; + height: 22px; + border: 2px solid color-mix(in srgb, var(--text-color) 20%, transparent); + border-top-color: var(--theme-primary); + border-radius: 50%; + animation: fm-spin 0.9s linear infinite; +} + +.fm-name { + font-size: 0.82rem; + color: var(--text-color); + text-align: center; + word-break: break-word; + line-height: 1.3; + max-height: 2.6em; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.fm-size { + font-size: 0.72rem; + color: var(--text-color-muted); + margin-top: 4px; +} + +.fm-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + padding: 24px; + text-align: center; +} + +.fm-state-text { + color: var(--text-color-secondary); + font-size: 0.95rem; + max-width: 480px; + line-height: 1.5; +} + +.fm-state-icon.lucide-icon, +.fm-state .fm-state-icon { + width: 40px; + height: 40px; + color: var(--text-color-muted); +} + +.fm-state .loading-spinner, +.reboot-overlay-panel .loading-spinner { + width: 36px; + height: 36px; + border: 3px solid color-mix(in srgb, var(--text-color) 15%, transparent); + border-top-color: var(--theme-primary); + border-radius: 50%; + animation: fm-spin 0.9s linear infinite; +} + +@keyframes fm-spin { + to { + transform: rotate(360deg); + } +} + +.fm-footer { + justify-content: space-between; +} + +.fm-summary { + font-size: 0.85rem; + color: var(--text-color-secondary); +} + +.fm-overlay { + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--theme-background) 65%, transparent); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border-radius: inherit; +} + +.fm-overlay.hidden { + display: none; +} + +.fm-overlay-panel { + background: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: var(--shadow-lg); + width: min(440px, calc(100% - 48px)); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.fm-overlay-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-color); +} + +.fm-overlay-title .lucide-icon { + width: 18px; + height: 18px; +} + +.fm-overlay-body { + color: var(--text-color-secondary); + font-size: 0.9rem; + line-height: 1.5; + max-height: 220px; + overflow-y: auto; +} + +.fm-overlay-body ul { + margin: 8px 0 0 0; + padding-left: 18px; +} + +.fm-overlay-body li { + word-break: break-word; +} + +.fm-overlay-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.fm-rename-row { + display: flex; + align-items: center; + gap: 6px; +} + +.fm-rename-row input { + flex: 1; + padding: 8px 10px; + background: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 0.9rem; + outline: none; +} + +.fm-rename-row input:focus { + border-color: var(--border-color-focus); +} + +.fm-rename-ext { + color: var(--text-color-muted); + font-size: 0.9rem; + white-space: nowrap; +} + +.fm-rename-error { + margin-top: 8px; + color: var(--error-color); + font-size: 0.82rem; +} + +/* Destructive variant of the shared primary button (delete / reboot). */ +.primary-btn.danger { + background-color: var(--error-color); + border-color: var(--error-color); + color: var(--text-on-primary); +} + +.primary-btn.danger:hover:not(:disabled) { + background-color: color-mix(in srgb, var(--error-color) 85%, black); +} + +/* ============================================================================ + SSH SETTINGS SECTION (settings modal) + ============================================================================ */ + +.ssh-settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + margin: 12px 0; +} + +.ssh-settings-grid label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.88rem; + color: var(--text-color-secondary); +} + +.ssh-settings-grid input { + padding: 8px 10px; + background: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 0.9rem; + outline: none; +} + +.ssh-settings-grid input:focus { + border-color: var(--border-color-focus); +} + +/* ============================================================================ + REBOOT OVERLAY + ============================================================================ */ + +.reboot-overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 1200; +} + +.reboot-overlay.hidden { + display: none; +} + +.reboot-overlay-panel { + background: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: var(--shadow-lg); + width: min(420px, calc(100% - 48px)); + padding: 28px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + text-align: center; +} + +.reboot-overlay-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-color); +} + +.reboot-overlay-message { + font-size: 0.9rem; + color: var(--text-color-secondary); + line-height: 1.5; + min-height: 1.4em; +} + +.reboot-result-icon.lucide-icon, +.reboot-result-icon { + width: 40px; + height: 40px; +} + +.reboot-result-icon.success { + color: var(--success-color); +} + +.reboot-result-icon.error { + color: var(--error-color); +} + +.reboot-overlay-actions { + display: flex; + gap: 10px; + justify-content: center; +} + + +/* ============================================================================ + CALIBRATION ASSISTANT + ============================================================================ */ + +.cal-modal-content { + position: relative; + display: flex; + flex-direction: column; + width: min(1100px, 96vw); + max-width: min(1100px, 96vw); + height: min(820px, 94vh); + max-height: 94vh; +} + +.cal-modal-body { + display: flex; + flex-direction: column; + flex: 1; + gap: 12px; + overflow: hidden; +} + +/* Header indicators */ +.cal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: var(--surface-muted); + border-radius: 8px; + flex-shrink: 0; +} + +.cal-context-indicator { + font-size: 14px; + color: var(--text-color-secondary); +} + +.cal-ssh-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-color-secondary); +} + +.cal-ssh-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--error-color); +} + +.cal-ssh-dot.connected { + background-color: var(--success-color); +} + +/* Tabs */ +.cal-tabs { + display: flex; + gap: 4px; + background-color: var(--surface-muted); + padding: 4px; + border-radius: 8px; + flex-shrink: 0; +} + +.cal-tab-button { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-color-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.cal-tab-button svg { + width: 16px; + height: 16px; +} + +.cal-tab-button:hover { + background-color: var(--card-bg-hover); + color: var(--text-color); +} + +.cal-tab-button[aria-selected="true"] { + background-color: var(--theme-primary); + color: var(--text-on-primary); +} + +/* Panels */ +.cal-tab-panels { + flex: 1; + overflow: hidden; +} + +.cal-tab-panel { + height: 100%; + overflow: auto; +} + +.cal-tab-panel[hidden] { + display: none; +} + +.cal-panel-layout { + display: grid; + grid-template-columns: 1fr 300px; + gap: 12px; + min-height: 100%; +} + +.cal-panel-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; +} + +.cal-panel-sidebar { + display: flex; + flex-direction: column; + gap: 16px; + background-color: var(--surface-muted); + border-radius: 8px; + padding: 14px; + overflow-y: auto; +} + +@media (max-width: 900px) { + .cal-panel-layout { + grid-template-columns: 1fr; + } +} + +/* Mesh + shaper visualization containers */ +.cal-mesh-container, +.cal-shaper-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: var(--surface-muted); + border-radius: 8px; + padding: 14px; +} + +#cal-mesh-canvas, +#cal-shaper-canvas { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +.cal-mesh-stats { + display: flex; + gap: 24px; + margin-top: 12px; + padding: 8px 16px; + background-color: var(--card-bg); + border-radius: 6px; +} + +.cal-stat-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.cal-stat-label { + font-size: 11px; + color: var(--text-color-secondary); + text-transform: uppercase; +} + +.cal-stat-value { + font-size: 15px; + font-weight: 600; + color: var(--text-color); + font-family: monospace; +} + +.cal-controls-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.cal-profile-select, +.cal-export-select { + padding: 8px 12px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 13px; +} + +.cal-profile-select { + flex: 1; + max-width: 220px; +} + +.cal-profile-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Buttons */ +.cal-control-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), border-color var(--transition-fast); +} + +.cal-control-button svg { + width: 16px; + height: 16px; +} + +.cal-control-button:hover:not(:disabled) { + background-color: var(--card-bg-hover); +} + +.cal-control-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cal-control-button.primary { + background-color: var(--theme-primary); + border-color: var(--theme-primary); + color: var(--text-on-primary); +} + +.cal-control-button.primary:hover:not(:disabled) { + background-color: var(--theme-primary-hover); +} + +.cal-control-button.danger { + background-color: var(--error-color); + border-color: var(--error-color); + color: var(--text-on-primary); +} + +.cal-control-button.danger:hover:not(:disabled) { + filter: brightness(0.9); +} + +/* Section titles */ +.cal-section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-color); + margin: 0 0 12px 0; +} + +.cal-subsection-title { + font-size: 13px; + font-weight: 500; + color: var(--text-color-secondary); + margin: 0 0 8px 0; +} + +/* Workflow stages */ +.cal-workflow-section { + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.cal-workflow-stages { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 14px; +} + +.cal-workflow-stage { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background-color: var(--card-bg); + border: 1px solid transparent; + border-radius: 6px; + transition: background-color var(--transition-fast); +} + +.cal-workflow-stage.active { + background-color: var(--primary-alpha-20); + border-color: var(--theme-primary); +} + +.cal-workflow-stage.completed .cal-stage-icon svg { + stroke: var(--success-color); +} + +.cal-stage-icon svg { + width: 16px; + height: 16px; + stroke: var(--text-color-secondary); +} + +.cal-stage-label { + font-size: 13px; + color: var(--text-color); +} + +/* Results */ +.cal-results-section[hidden] { + display: none; +} + +.cal-adjustments-container { + margin-bottom: 14px; +} + +.cal-adjustments-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cal-adjustment-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 9px 12px; + background-color: var(--card-bg); + border-radius: 6px; + font-size: 13px; +} + +.cal-adjustment-corner { + font-weight: 500; + color: var(--text-color); +} + +.cal-adjustment-value { + font-family: monospace; + color: var(--theme-primary); +} + +.cal-adjustment-value.cw { + color: var(--success-color); +} + +.cal-adjustment-value.ccw { + color: var(--warning-color); +} + +.cal-improvement-display { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px; + background-color: color-mix(in srgb, var(--success-color) 10%, transparent); + border: 1px solid var(--success-color); + border-radius: 8px; + margin: 14px 0; +} + +.cal-improvement-label { + font-size: 14px; + color: var(--text-color); +} + +.cal-improvement-value { + font-size: 22px; + font-weight: 700; + color: var(--success-color); +} + +/* Input shaper sidebar */ +.cal-axis-selector { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.cal-axis-button { + padding: 8px 24px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.cal-axis-button.active { + background-color: var(--theme-primary); + border-color: var(--theme-primary); + color: var(--text-on-primary); +} + +.cal-axis-button:hover:not(.active) { + background-color: var(--card-bg-hover); + color: var(--text-color); +} + +.cal-shaper-results-section, +.cal-shaper-comparison-section { + padding-bottom: 14px; + border-bottom: 1px solid var(--border-color); +} + +.cal-shaper-recommendation { + padding: 12px; + background-color: var(--card-bg); + border-radius: 8px; +} + +.cal-shaper-details { + margin-top: 10px; + font-size: 12px; + color: var(--text-color-secondary); + display: grid; + gap: 4px; +} + +.cal-empty-state { + color: var(--text-color-secondary); + font-size: 13px; + text-align: center; + padding: 20px; +} + +.cal-shaper-comparison { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cal-shaper-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 9px 12px; + background-color: var(--card-bg); + border: 1px solid transparent; + border-radius: 6px; +} + +.cal-shaper-item.recommended { + border-color: var(--success-color); +} + +.cal-shaper-name { + font-size: 13px; + font-weight: 500; + color: var(--text-color); +} + +.cal-shaper-freq { + font-size: 12px; + font-family: monospace; + color: var(--text-color-secondary); +} + +.cal-shaper-config-output { + margin-top: 8px; + padding: 12px; + background-color: var(--darker-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 12px; + color: var(--text-color); + white-space: pre-wrap; + word-break: break-word; + min-height: 100px; +} + +.cal-shaper-config-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +/* SSH tab */ +.cal-ssh-panel { + max-width: 620px; + padding: 4px; +} + +.cal-info-text { + font-size: 13px; + color: var(--text-color-secondary); + line-height: 1.5; + margin-bottom: 12px; +} + +.cal-ssh-fields { + margin-top: 14px; + padding: 14px; + background-color: var(--surface-muted); + border-radius: 8px; +} + +.cal-input-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.cal-input-group:last-child { + margin-bottom: 0; +} + +.cal-input-group label { + font-size: 13px; + font-weight: 500; + color: var(--text-color); +} + +.cal-input { + padding: 9px 12px; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-color); + font-size: 13px; + transition: border-color var(--transition-fast); +} + +.cal-input:focus { + outline: none; + border-color: var(--border-color-focus); +} + +.cal-ssh-buttons { + display: flex; + gap: 10px; + margin-top: 14px; + flex-wrap: wrap; +} + +.cal-ssh-test-result { + margin-top: 12px; + padding: 10px 12px; + border-radius: 6px; + font-size: 13px; + min-height: 20px; + color: var(--text-color-secondary); +} + +.cal-ssh-test-result.success { + background-color: color-mix(in srgb, var(--success-color) 10%, transparent); + color: var(--success-color); +} + +.cal-ssh-test-result.error { + background-color: color-mix(in srgb, var(--error-color) 10%, transparent); + color: var(--error-color); +} + +/* History tab */ +.cal-history-panel { + padding: 4px; +} + +.cal-history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; +} + +.cal-history-header .cal-section-title { + margin-bottom: 0; +} + +.cal-history-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.cal-history-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 14px; + background-color: var(--surface-muted); + border-radius: 8px; +} + +.cal-history-item-main { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cal-history-item-type { + font-size: 14px; + font-weight: 600; + color: var(--text-color); +} + +.cal-history-item-summary { + font-size: 13px; + color: var(--text-color-secondary); +} + +.cal-history-item-date { + font-size: 12px; + color: var(--text-color-muted); +} + +/* Footer */ +.cal-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.cal-status-message { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--text-color-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cal-footer-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.cal-footer-actions .secondary-btn { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.cal-footer-actions .lucide-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.cal-export-format { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-color-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Visual recommendations overlay (nested inside the modal content) */ +.cal-overlay { + position: absolute; + inset: 0; + background-color: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + z-index: 10; +} + +.cal-overlay.hidden { + display: none; +} + +.cal-overlay-panel { + width: min(92%, 720px); + max-height: 92%; + overflow: auto; + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: var(--shadow-lg); +} + +.cal-overlay-title { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background-color: var(--surface-muted); + font-weight: 600; + color: var(--text-color); +} + +.cal-overlay-title svg { + width: 18px; + height: 18px; + color: var(--theme-primary); +} + +.cal-overlay-title .close-btn { + margin-left: auto; +} + +.cal-overlay-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +#cal-rec-canvas { + width: 100%; + height: auto; + border-radius: 8px; +} + +.cal-rec-legend { + display: flex; + gap: 16px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-color-secondary); +} + +.cal-legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.cal-legend-swatch { + width: 12px; + height: 12px; + border-radius: 3px; +} + +.cal-legend-swatch.cw { + background-color: var(--success-color); +} + +.cal-legend-swatch.ccw { + background-color: var(--warning-color); +} + +.cal-legend-swatch.tape { + background-color: var(--theme-secondary); +} + + + +/* ========================================================================== + Job Upload Modal + Port of the desktop Upload Job dialog (job-uploader.css) onto the WebUI's + modal shell. Layout, field order and the two overlays match the desktop. + ========================================================================== */ + +.modal-content.job-upload { + max-width: 860px; + /* Anchors the loading and progress overlays to the dialog, as on desktop. */ + position: relative; +} + +.modal-content.job-upload .modal-footer { + justify-content: flex-end; +} + +.job-upload-file-section { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 20px; + padding: 16px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-file-path { + flex: 1; + min-width: 0; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background-color: var(--dark-bg); + color: var(--text-color); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-upload-options { + display: flex; + flex-direction: row; + gap: 32px; + margin-bottom: 20px; + padding: 12px 16px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-mappings { + margin-bottom: 20px; + padding: 12px 16px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-mappings-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-secondary); +} + +.job-upload-mappings-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.job-upload-mapping-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background-color: var(--theme-surface); + border: 1px solid var(--border-color); + border-radius: 999px; + font-size: 14px; + color: var(--theme-text); +} + +.job-upload-mapping-swatch { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + +.job-upload-mapping-arrow, +.job-upload-mapping-material { + color: var(--text-color-secondary); +} + +.job-upload-content { + display: grid; + /* Named areas: auto-placement would otherwise push the metadata card onto a + second row, leaving an empty cell beside the preview. */ + grid-template-columns: 1fr 180px; + grid-template-areas: "metadata preview"; + align-items: start; + gap: 20px; +} + +.job-upload-preview { + grid-area: preview; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 16px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-thumbnail { + width: 140px; + height: 140px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: var(--dark-bg); +} + +.job-upload-thumbnail img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.job-upload-no-preview { + font-size: 13px; + font-style: italic; + text-align: center; + color: var(--text-color-muted); +} + +.job-upload-metadata { + grid-area: metadata; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 20px; + padding: 20px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-meta-group { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.job-upload-meta-title { + margin: 0; + font-size: 13px; + font-weight: 500; + color: var(--text-color-secondary); +} + +.job-upload-meta-value { + font-size: 14px; + color: var(--text-color); + word-break: break-word; +} + +.job-upload-meta-group.print-time-highlight { + /* A full-width banner at the top of the card: as a normal cell its taller box + forced a deep gap into the neighbouring column. */ + grid-column: 1 / -1; + order: -1; + flex-direction: row; + align-items: baseline; + justify-content: center; + gap: 10px; + padding: 10px 16px; + border: 1px solid var(--primary-alpha-40); + border-radius: 8px; + background-color: var(--primary-alpha-15); +} + +.job-upload-warnings-card { + margin-bottom: 20px; + padding: 12px 16px; + background-color: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.job-upload-warnings-card-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-secondary); +} + +.job-upload-meta-value.print-time-value { + font-size: 18px; + font-weight: 600; +} + +.job-upload-warnings { + font-size: 13px; + line-height: 1.5; +} + +.job-upload-warning-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 4px 0; + border-bottom: 1px solid var(--border-color-light); +} + +.job-upload-warning-item:last-child { + border-bottom: none; +} + +.job-upload-warning-icon { + flex-shrink: 0; + font-size: 14px; +} + +.job-upload-warning-icon.level-info { + color: var(--accent-color); +} + +.job-upload-warning-icon.level-warning { + color: var(--warning-color); +} + +.job-upload-warning-icon.level-error { + color: var(--error-color); +} + +.job-upload-overlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background-color: var(--overlay-bg); + font-size: 14px; + font-style: italic; + color: var(--text-color); +} + +.job-upload-overlay.hidden { + display: none; +} + +.job-upload-progress { + min-width: 320px; + padding: 30px; + text-align: center; + font-style: normal; + border: 1px solid var(--border-color); + border-radius: 10px; + background-color: var(--card-bg); + box-shadow: var(--shadow-lg); +} + +.job-upload-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + font-size: 16px; + font-weight: 600; + color: var(--text-color); +} + +#job-upload-progress-percentage { + font-size: 14px; + font-weight: 500; + color: var(--theme-primary); +} + +.job-upload-progress-track { + width: 100%; + height: 10px; + margin-bottom: 16px; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 6px; + background-color: var(--dark-bg); +} + +.job-upload-progress-bar { + width: 0%; + height: 100%; + border-radius: 5px; + background-color: var(--accent-color); + transition: width var(--transition-normal); +} + +.job-upload-progress-status { + font-size: 14px; + font-style: italic; + color: var(--text-color-secondary); +} + +@media (max-width: 768px) { + .job-upload-content { + grid-template-columns: 1fr; + grid-template-areas: + "preview" + "metadata"; + } + + .job-upload-preview { + justify-self: stretch; + } + + .job-upload-metadata { + grid-template-columns: 1fr; + } + + .job-upload-file-section { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + /* The options stay on one line - two short labels fit even on a phone. */ + .job-upload-options { + flex-wrap: wrap; + gap: 20px; + } +} + +/* Spoolman spool picker in the matching dialog (per-job tracking). */ +.material-spool-hint { + margin: 0 0 8px; + font-size: 0.85em; + color: var(--text-color-secondary); +} + +.material-mapping-spool { + flex: 1 1 180px; + min-width: 0; + max-width: 260px; + margin: 0 8px; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--card-bg, transparent); + color: inherit; + font: inherit; + font-size: 0.85em; +} + +.job-upload-mapping-spool { + color: var(--text-color-secondary); + font-style: italic; +} diff --git a/src/webui/types/web-api.types.ts b/src/webui/types/web-api.types.ts index 6585aa9..0bda834 100644 --- a/src/webui/types/web-api.types.ts +++ b/src/webui/types/web-api.types.ts @@ -18,6 +18,9 @@ * - Errors: WebUIError, WEB_UI_ERROR_CODES constant object, WebUIErrorCode type */ +import type { RebootStatusPayload } from '../../types/printer-power'; +import type { DeductionSummary } from '../../types/spoolman-tracking'; + // ============================================================================ // AUTHENTICATION TYPES // ============================================================================ @@ -66,7 +69,8 @@ export type WebSocketMessageType = | 'ERROR' | 'COMMAND_RESULT' | 'PONG' - | 'SPOOLMAN_UPDATE'; + | 'SPOOLMAN_UPDATE' + | 'REBOOT_STATUS'; /** * Per-tool temperature reading for multi-tool printers (Creator 5 series). @@ -101,6 +105,7 @@ export interface PrinterStatusData { readonly cumulativeFilament?: number; // Total lifetime filament usage in meters readonly cumulativePrintTime?: number; // Total lifetime print time in minutes readonly formattedEta?: string; // Firmware ETA string (e.g. "04:48" = 4h48m remaining) + readonly completionTime?: string | null; // Library completion timestamp (ISO string), null when not advancing readonly elapsedTimeSeconds?: number; // Precise elapsed seconds for HH:MM:SS display // Creator 5 series (multi-tool) fields. Undefined/empty on single-nozzle printers. readonly toolTemps?: readonly ToolTemperatureData[]; @@ -165,6 +170,8 @@ export interface WebSocketMessage { readonly remainingLength: number; readonly lastUpdated: string; } | null; + // Reboot lifecycle fields (when type === 'REBOOT_STATUS') + readonly reboot?: RebootStatusPayload; } // ============================================================================ @@ -278,6 +285,12 @@ export interface PrinterFeatures { readonly hasMultiTool?: boolean; /** Creator 5 Pro — gates the read-only TVOC air-quality display. */ readonly isCreator5Pro?: boolean; + /** Raw G-code passthrough availability (HTTP-only printers like the Creator 5 report available: false). */ + readonly gcodeCommands?: { + readonly available: boolean; + readonly usesLegacyAPI: boolean; + readonly supportedCommands: readonly string[]; + }; } /** @@ -347,6 +360,37 @@ export interface SpoolmanConfigResponse extends StandardAPIResponse { readonly serverUrl: string; readonly updateMode: 'length' | 'weight'; readonly contextId: string | null; + /** Estimate-based tracking info; present only for station contexts. */ + readonly station?: SpoolmanStationTracking | null; +} + +/** + * Per-job tracking info for material-station contexts. + */ +export interface SpoolmanStationTracking { + readonly supported: boolean; + /** Plain explanation surfaced in the panel. */ + readonly note: string; + /** The job tracked now, or null. */ + readonly activeJob: SpoolmanTrackedJobView | null; + readonly lastDeduction: DeductionSummary | null; +} + +/** + * Panel view of the job tracked now. + */ +export interface SpoolmanTrackedJobView { + readonly fileName: string; + readonly started: boolean; + readonly lastProgress: number | null; + readonly hasUsageProfile: boolean; + readonly tools: ReadonlyArray<{ + readonly toolId: number; + readonly slotId: number; + readonly spoolId: number; + readonly usedG: number | null; + readonly usedM: number | null; + }>; } /** @@ -443,6 +487,76 @@ export interface SlotConfigResponse extends StandardAPIResponse { readonly colorHex: string; } +// ============================================================================ +// JOB UPLOAD API TYPES +// ============================================================================ + +/** A single slicer warning extracted from a 3MF, mirroring slicer-meta's SliceWarning. */ +export interface UploadSliceWarning { + readonly level: number; + readonly message: string; +} + +/** + * Per-tool filament entry parsed out of the staged file. Feeds the material + * matching modal on material-station printers (AD5X, Creator 5 / 5 Pro). + */ +export interface UploadFilamentInfo { + /** Gcode tool index (0-based) this filament prints with. */ + readonly toolId: number; + readonly type: string | null; + readonly color: string | null; + readonly usedM: string | null; + readonly usedG: string | null; +} + +/** + * Normalized slicer metadata for a staged upload. + * + * The desktop job uploader reads the raw `ParseResult` and applies a chain of + * fallbacks between `file`, `threeMf` and `slicer` while rendering. The WebUI + * cannot parse locally, so the server applies the exact same fallbacks here and + * ships the resolved values; the browser only formats them. + */ +export interface UploadJobMetadata { + readonly printerModel: string | null; + readonly filamentType: string | null; + /** Resolved filament length in meters (file.filaments → threeMf.filaments → filamentUsedMM). */ + readonly filamentLengthM: number | null; + /** Resolved filament weight in grams; only rendered when a length is known. */ + readonly filamentWeightG: number | null; + /** Null when the file carries no 3MF section (the desktop renders "-"). */ + readonly supportUsed: boolean | null; + readonly slicerName: string | null; + readonly slicerVersion: string | null; + readonly sliceDate: string | null; + readonly sliceTime: string | null; + readonly printEta: string | null; + readonly layerHeight: number | null; + readonly infillDensity: number | null; + readonly layerCount: number | null; + /** First layer time in seconds (3MF only). */ + readonly firstLayerTime: number | null; + readonly warnings: readonly UploadSliceWarning[]; + /** Base64 PNG (no data URL prefix) from the 3MF plate image or G-code thumbnail. */ + readonly thumbnail: string | null; + readonly filaments: readonly UploadFilamentInfo[]; +} + +export interface JobUploadStageResponse extends StandardAPIResponse { + /** Handle used to reference the staged file on the follow-up start/cancel calls. */ + readonly uploadId?: string; + readonly fileName?: string; + readonly metadata?: UploadJobMetadata; + /** True when the active printer has a material station and the file is a 3MF. */ + readonly requiresMaterialMatching?: boolean; +} + +export interface JobUploadStartResponse extends StandardAPIResponse { + readonly fileName?: string; + readonly started?: boolean; +} + // ============================================================================ // ERROR TYPES // ============================================================================ diff --git a/tests/e2e/browser/helpers/api.ts b/tests/e2e/browser/helpers/api.ts new file mode 100644 index 0000000..cf6d21a --- /dev/null +++ b/tests/e2e/browser/helpers/api.ts @@ -0,0 +1,147 @@ +/** + * @fileoverview Shared API/browser helpers for browser E2E specs. + * + * Extracted from the per-spec duplicates (gating and heaters both carried private + * copies of the token fetch and context resolution): every helper here talks to + * the REAL server or the REAL emulator and asserts nothing itself. + * + * Login budget discipline: the server rate-limits /api/auth/login to 5 attempts + * per 15 minutes per IP and counts successful logins. A suite that uses these + * helpers spends exactly two (harness readiness login + one fetchApiToken) if + * every UI test opens pages through openWithRememberedToken instead of the login + * form — the token restore path is the app's own "remember me" behaviour. + */ + +import { expect, type Page } from '@playwright/test'; +import type { StandalonePrinter, StandaloneWebUI } from './standalone-server'; +import { WEBUI_TEST_PASSWORD } from './standalone-server'; + +interface ContextsPayload { + contexts: Array<{ id: string; name: string; isActive: boolean }>; +} + +export interface EmulatorDetail { + /** Machine status string (e.g. "Idle"); the not-printing safety gate reads it. */ + status?: string; + printFileName?: string; + platTargetTemp?: number; + rightTargetTemp?: number; + nozzleTargetTemps?: number[]; + /** Chamber heater target; -108 sentinel on models without a chamber. */ + chamberTargetTemp?: number; + lightStatus?: string; +} + +/** Logs in through the real auth route to obtain a bearer token for API-driven assertions. */ +export const fetchApiToken = async (webui: StandaloneWebUI): Promise => { + const response = await fetch(`${webui.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: webui.password ?? WEBUI_TEST_PASSWORD }), + }); + + const payload = (await response.json()) as { token?: string; message?: string }; + if (!payload.token) { + throw new Error(`Could not obtain an API token: ${payload.message ?? response.status}`); + } + + return payload.token; +}; + +/** Resolves a printer's context id from the server by machine name; never assumes which context is active. */ +export const resolveContextId = async ( + webui: StandaloneWebUI, + token: string, + machineName: string +): Promise => { + const response = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const { contexts } = (await response.json()) as ContextsPayload; + const target = contexts.find((context) => context.name === machineName); + if (!target) { + throw new Error(`expected a context for ${machineName}`); + } + return target.id; +}; + +/** Reads the emulator printer's own /detail state - the ground truth assertions poll. */ +export const readEmulatorDetail = async (printer: StandalonePrinter): Promise => { + const response = await fetch(`http://127.0.0.1:${printer.httpPort}/detail`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serialNumber: printer.serial, checkCode: printer.checkCode }), + }); + const payload = (await response.json()) as { detail?: EmulatorDetail }; + return payload.detail ?? {}; +}; + +/** Authenticated JSON POST against the server, returning status plus parsed body. */ +export const postJson = async ( + webui: StandaloneWebUI, + token: string, + path: string, + body?: Record +): Promise<{ status: number; payload: T }> => { + const response = await fetch(`${webui.baseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { status: response.status, payload: (await response.json()) as T }; +}; + +/** Authenticated raw-bytes POST (e.g. job file staging), returning status plus parsed body. */ +export const postRaw = async ( + webui: StandaloneWebUI, + token: string, + path: string, + body: Buffer +): Promise<{ status: number; payload: T }> => { + const response = await fetch(`${webui.baseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/octet-stream', + }, + body: new Uint8Array(body), + }); + return { status: response.status, payload: (await response.json()) as T }; +}; + +/** + * Opens the WebUI already authenticated via the remembered token. + * + * Seeds localStorage through addInitScript, which runs before any app script on + * every navigation - the same code path a returning "remember me" user takes, so + * it costs zero logins against the rate limiter. Asserts the dashboard came up + * and the websocket passed the real auth gate ("Connected"). + */ +export const openWithRememberedToken = async ( + page: Page, + webui: StandaloneWebUI, + token: string +): Promise => { + await page.addInitScript((value) => localStorage.setItem('webui-token', value), token); + await page.goto(webui.baseUrl, { waitUntil: 'domcontentloaded' }); + await expect(page.locator('#main-ui')).toBeVisible(); + await expect(page.locator('#login-screen')).toBeHidden(); + await expect(page.locator('#connection-text')).toHaveText('Connected'); +}; + +/** Switches the active printer context through the built WebUI's printer selector. */ +export const switchPrinterInUi = async ( + page: Page, + webui: StandaloneWebUI, + token: string, + machineName: string +): Promise => { + const contextId = await resolveContextId(webui, token, machineName); + const selector = page.locator('#printer-select'); + await expect(selector).toBeVisible(); + await selector.selectOption(contextId); + await expect(page.locator('#connection-text')).toHaveText('Connected'); +}; diff --git a/tests/e2e/browser/helpers/emulator-harness.ts b/tests/e2e/browser/helpers/emulator-harness.ts new file mode 100644 index 0000000..5abe28a --- /dev/null +++ b/tests/e2e/browser/helpers/emulator-harness.ts @@ -0,0 +1,406 @@ +/** + * @fileoverview Helper utilities for launching and controlling flashforge-emulator-v2 + * instances during browser E2E runs, including readiness checks and teardown. + * + * Ported from FlashForgeUI-Electron's Electron E2E harness so both frontends drive + * the same emulator surface. The process-tree teardown discipline matters just as + * much here: the emulator binds its ports in a tsx grandchild that npm does not + * forward signals to, so a naive kill leaks a process holding 8899/8898 and the + * next spec fails with EADDRINUSE. + */ + +import { type ChildProcessByStdio, spawn, spawnSync } from 'node:child_process'; +import { access } from 'node:fs/promises'; +import path from 'node:path'; +import readline from 'node:readline'; +import type { Readable } from 'node:stream'; + +const EMULATOR_READY_TOKEN = 'EMULATOR_READY'; +const DEFAULT_START_TIMEOUT_MS = 30_000; +const DEFAULT_HEALTH_TIMEOUT_MS = 20_000; +const HEALTH_POLL_INTERVAL_MS = 250; +const PROCESS_EXIT_TIMEOUT_MS = 5_000; + +export type EmulatorModel = + | 'adventurer-3' + | 'adventurer-4' + | 'adventurer-5m' + | 'adventurer-5m-pro' + | 'adventurer-5x' + | 'creator-5' + | 'creator-5-pro'; + +type SimulationMode = 'auto' | 'manual'; + +export interface EmulatorInstanceConfig { + instanceId: string; + model: EmulatorModel; + serial: string; + checkCode: string; + machineName: string; + tcpPort: number; + httpPort: number; + discoveryEnabled: boolean; + simulationMode?: SimulationMode; + simulationSpeed?: number; +} + +export interface EmulatorReadyPayload { + instanceId: string; + ip: string; + tcpPort: number; + httpPort: number; + serial: string; + model: string; +} + +interface WaitForReadyResult { + readyPayloads: EmulatorReadyPayload[]; + stdoutLines: string[]; + stderrLines: string[]; +} + +interface HealthPayload { + ok: boolean; +} + +interface StartHarnessResult { + readyPayloads: EmulatorReadyPayload[]; + stop: () => Promise; +} + +const sleep = async (ms: number): Promise => + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const formatLogTail = (lines: readonly string[], maxLines = 20): string => { + if (lines.length === 0) { + return '(no output)'; + } + + return lines.slice(-maxLines).join('\n'); +}; + +const getNpmCommand = (): string => { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +}; + +const resolveEmulatorRoot = (): string => { + const fromEnv = process.env.FF_EMULATOR_ROOT?.trim(); + if (fromEnv && fromEnv.length > 0) { + return path.resolve(fromEnv); + } + + return path.resolve(process.cwd(), '..', 'flashforge-emulator-v2'); +}; + +const assertEmulatorRoot = async (emulatorRoot: string): Promise => { + const packageJsonPath = path.join(emulatorRoot, 'package.json'); + try { + await access(packageJsonPath); + } catch { + throw new Error( + `Unable to locate flashforge-emulator-v2 package.json at ${packageJsonPath}. Set FF_EMULATOR_ROOT to the emulator repo path.` + ); + } +}; + +const buildInstanceArgs = (instance: EmulatorInstanceConfig): string[] => { + return [ + '--instance-id', + instance.instanceId, + '--model', + instance.model, + '--serial', + instance.serial, + '--check-code', + instance.checkCode, + '--machine-name', + instance.machineName, + '--tcp-port', + String(instance.tcpPort), + '--http-port', + String(instance.httpPort), + '--discovery-enabled', + String(instance.discoveryEnabled), + '--simulation-mode', + instance.simulationMode ?? 'auto', + '--simulation-speed', + String(instance.simulationSpeed ?? 100), + ]; +}; + +const spawnEmulatorProcess = ( + emulatorRoot: string, + args: readonly string[] +): ChildProcessByStdio => { + const child = spawn(getNpmCommand(), args, { + cwd: emulatorRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: true, + // The command is `npm run headless:instance`, so the process that actually binds + // the printer ports is a grandchild (npm -> tsx). npm does not forward SIGTERM to + // it, so signalling the child alone leaves the grandchild alive still holding + // 8899/8898 and the next describe fails with EADDRINUSE. Detaching puts the whole + // tree in its own process group, which stopProcessTree can then signal as a unit. + // Windows has no process groups and uses `taskkill /T` instead. + detached: process.platform !== 'win32', + }) as ChildProcessByStdio; + + return child; +}; + +const waitForReady = async (params: { + child: ChildProcessByStdio; + expectedReadyCount: number; + timeoutMs: number; + label: string; +}): Promise => { + const { child, expectedReadyCount, timeoutMs, label } = params; + + return await new Promise((resolve, reject) => { + const readyPayloads: EmulatorReadyPayload[] = []; + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + const stdoutReader = readline.createInterface({ input: child.stdout }); + const stderrReader = readline.createInterface({ input: child.stderr }); + + let expectingReadyJson = false; + let settled = false; + + const cleanup = (): void => { + clearTimeout(timeoutId); + stdoutReader.close(); + stderrReader.close(); + child.off('error', handleError); + child.off('exit', handleExit); + }; + + const finish = (callback: () => void): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + callback(); + }; + + const fail = (message: string): void => { + finish(() => { + reject( + new Error( + `${label} failed: ${message}\n--- stdout ---\n${formatLogTail(stdoutLines)}\n--- stderr ---\n${formatLogTail(stderrLines)}` + ) + ); + }); + }; + + const handleError = (error: Error): void => { + fail(`process error: ${error.message}`); + }; + + const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => { + if (readyPayloads.length >= expectedReadyCount) { + finish(() => { + resolve({ readyPayloads, stdoutLines, stderrLines }); + }); + return; + } + + fail(`process exited before readiness (code=${String(code)}, signal=${String(signal)})`); + }; + + const timeoutId = setTimeout(() => { + fail(`timed out waiting for readiness after ${timeoutMs}ms`); + }, timeoutMs); + + stdoutReader.on('line', (line) => { + const trimmed = line.trim(); + if (trimmed.length > 0) { + stdoutLines.push(trimmed); + } + + if (expectingReadyJson) { + expectingReadyJson = false; + try { + const payload = JSON.parse(trimmed) as EmulatorReadyPayload; + readyPayloads.push(payload); + } catch { + fail(`invalid JSON after ${EMULATOR_READY_TOKEN}: ${trimmed}`); + return; + } + + if (readyPayloads.length >= expectedReadyCount) { + finish(() => { + resolve({ readyPayloads, stdoutLines, stderrLines }); + }); + } + return; + } + + if (trimmed === EMULATOR_READY_TOKEN) { + expectingReadyJson = true; + } + }); + + stderrReader.on('line', (line) => { + const trimmed = line.trim(); + if (trimmed.length > 0) { + stderrLines.push(trimmed); + } + }); + + child.on('error', handleError); + child.on('exit', handleExit); + }); +}; + +const waitForHealthReady = async (httpPort: number, timeoutMs: number): Promise => { + const deadline = Date.now() + timeoutMs; + let lastError: string | null = null; + + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${httpPort}/__health`); + if (response.ok) { + const payload = (await response.json()) as HealthPayload; + if (payload.ok === true) { + return; + } + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + + await sleep(HEALTH_POLL_INTERVAL_MS); + } + + throw new Error( + `Health endpoint did not become ready for httpPort=${httpPort}. Last error: ${lastError ?? 'none'}` + ); +}; + +const waitForChildExit = async ( + child: ChildProcessByStdio, + timeoutMs: number +): Promise => { + if (child.exitCode !== null) { + return; + } + + await new Promise((resolve) => { + let settled = false; + + const finish = (): void => { + if (settled) { + return; + } + settled = true; + child.off('exit', onExit); + clearTimeout(timeoutId); + resolve(); + }; + + const onExit = (): void => { + finish(); + }; + + const timeoutId = setTimeout(() => { + finish(); + }, timeoutMs); + + child.on('exit', onExit); + }); +}; + +/** + * Kills a spawned process and everything it started. + * + * Exported because the standalone-server harness spawns the WebUI server the same + * way and must not re-derive the POSIX process-group handling; getting it wrong + * leaks a process that still holds a port and breaks the next suite with + * EADDRINUSE. + */ +export const stopProcessTree = async ( + child: ChildProcessByStdio +): Promise => { + if (child.exitCode !== null) { + return; + } + + if (process.platform === 'win32' && child.pid) { + spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { + windowsHide: true, + }); + await waitForChildExit(child, PROCESS_EXIT_TIMEOUT_MS); + return; + } + + // Signal the whole process group (negative pid) so the tsx grandchild holding the + // printer ports dies with npm. Falls back to the child alone if the group is already + // gone, which surfaces as ESRCH. + const signalTree = (signal: NodeJS.Signals): void => { + try { + if (child.pid) { + process.kill(-child.pid, signal); + return; + } + } catch { + // Group already reaped, or the platform refused it - fall through. + } + child.kill(signal); + }; + + signalTree('SIGTERM'); + await waitForChildExit(child, PROCESS_EXIT_TIMEOUT_MS); + + if (child.exitCode === null) { + signalTree('SIGKILL'); + await waitForChildExit(child, PROCESS_EXIT_TIMEOUT_MS); + } +}; + +const createHarnessResult = ( + child: ChildProcessByStdio, + readyPayloads: EmulatorReadyPayload[], + cleanup: (() => Promise) | null = null +): StartHarnessResult => { + return { + readyPayloads, + stop: async () => { + await stopProcessTree(child); + if (cleanup) { + await cleanup(); + } + }, + }; +}; + +export const startEmulatorInstance = async (params: { + instance: EmulatorInstanceConfig; + startupTimeoutMs?: number; + healthTimeoutMs?: number; +}): Promise => { + const emulatorRoot = resolveEmulatorRoot(); + await assertEmulatorRoot(emulatorRoot); + + const startupTimeoutMs = params.startupTimeoutMs ?? DEFAULT_START_TIMEOUT_MS; + const healthTimeoutMs = params.healthTimeoutMs ?? DEFAULT_HEALTH_TIMEOUT_MS; + + const args = ['run', 'headless:instance', '--', ...buildInstanceArgs(params.instance)]; + const child = spawnEmulatorProcess(emulatorRoot, args); + + const readiness = await waitForReady({ + child, + expectedReadyCount: 1, + timeoutMs: startupTimeoutMs, + label: 'Single-instance emulator', + }); + + await waitForHealthReady(readiness.readyPayloads[0].httpPort, healthTimeoutMs); + + return createHarnessResult(child, readiness.readyPayloads); +}; diff --git a/tests/e2e/browser/helpers/spoolman-sidecar.ts b/tests/e2e/browser/helpers/spoolman-sidecar.ts new file mode 100644 index 0000000..b413499 --- /dev/null +++ b/tests/e2e/browser/helpers/spoolman-sidecar.ts @@ -0,0 +1,233 @@ +/** + * @fileoverview Test harness for the flashforge-emulator-v2 Spoolman mock + * sidecar (`npm run headless:spoolman`). + * + * The sidecar exposes the Spoolman HTTP surface the WebUI talks to + * (GET /api/v1/spool, GET /api/v1/spool/:id, PUT /api/v1/spool/:id/use) + * plus test-control endpoints: + * GET /__requests → ledger of accepted usage PUTs + * [{ spoolId, useWeight, useLength, timestamp }, ...] + * POST /__reset → clear the ledger (spool state is untouched) + * POST /__shutdown → stop the sidecar + * + * CI clones the emulator at a pinned version that may predate the sidecar; + * specs must skip cleanly via {@link isSpoolmanSidecarAvailable} when + * `scripts/headless/run-spoolman.ts` is absent from the checkout. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as readline from 'readline'; +import { spawn, type ChildProcessByStdio } from 'child_process'; +import type { Readable } from 'stream'; +import { stopProcessTree } from './emulator-harness'; + +/** stdout token printed immediately before the ready JSON line. */ +const SPOOLMAN_READY_TOKEN = 'SPOOLMAN_READY'; + +/** Payload of the JSON line following the ready token. */ +interface SpoolmanReadyPayload { + port: number; + spools: number; +} + +/** One accepted usage PUT, as recorded by the sidecar's /__requests ledger. */ +export interface SpoolmanUsageRequest { + spoolId: number; + useWeight: number | null; + useLength: number | null; + timestamp: string; +} + +export interface SpoolmanSidecar { + port: number; + baseUrl: string; + /** Accepted usage PUTs so far. */ + requests: () => Promise; + /** Clear the usage PUT ledger. */ + reset: () => Promise; + /** Stop the sidecar process tree. */ + stop: () => Promise; +} + +const getNpmCommand = (): string => { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +}; + +const resolveEmulatorRoot = (): string => { + const fromEnv = process.env.FF_EMULATOR_ROOT?.trim(); + if (fromEnv && fromEnv.length > 0) { + return path.resolve(fromEnv); + } + return path.resolve(process.cwd(), '..', 'flashforge-emulator-v2'); +}; + +/** + * Whether the emulator checkout ships the Spoolman sidecar script. Specs + * should `test.skip(!isSpoolmanSidecarAvailable(), ...)` on this. + */ +export const isSpoolmanSidecarAvailable = (): boolean => { + try { + return fs.existsSync(path.join(resolveEmulatorRoot(), 'scripts/headless/run-spoolman.ts')); + } catch { + return false; + } +}; + +/** Message used by specs when they skip for a missing sidecar. */ +export const SPOOLMAN_SIDECAR_SKIP_MESSAGE = + 'requires emulator with spoolman sidecar (scripts/headless/run-spoolman.ts not found)'; + +/** Grab a free TCP port by binding port 0 and releasing it. */ +const getFreePort = async (): Promise => { + const net = await import('net'); + return await new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + probe.close(() => { + resolve(port); + }); + }); + }); +}; + +const postControl = async (port: number, endpoint: string): Promise => { + const response = await fetch(`http://127.0.0.1:${port}${endpoint}`, { method: 'POST' }); + if (!response.ok) { + throw new Error(`sidecar ${endpoint} failed: ${String(response.status)}`); + } + await response.body?.cancel(); +}; + +/** + * Start the Spoolman mock sidecar on a free port and wait for its ready line. + * + * @param options.seed - Optional `--seed` payload (inline JSON array of spools) + */ +export const startSpoolmanSidecar = async (options?: { + seed?: string; + startupTimeoutMs?: number; +}): Promise => { + const emulatorRoot = resolveEmulatorRoot(); + const scriptPath = path.join(emulatorRoot, 'scripts/headless/run-spoolman.ts'); + if (!fs.existsSync(scriptPath)) { + throw new Error(SPOOLMAN_SIDECAR_SKIP_MESSAGE); + } + + const port = await getFreePort(); + const args = ['run', 'headless:spoolman', '--', '--port', String(port)]; + if (options?.seed) { + args.push('--seed', options.seed); + } + + const child = spawn(getNpmCommand(), args, { + cwd: emulatorRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + windowsHide: true, + detached: process.platform !== 'win32', + }) as ChildProcessByStdio; + + const ready = await waitForSpoolmanReady(child, options?.startupTimeoutMs ?? 60_000); + + return { + port: ready.port, + baseUrl: `http://127.0.0.1:${ready.port}`, + requests: async () => { + const response = await fetch(`http://127.0.0.1:${ready.port}/__requests`); + if (!response.ok) { + throw new Error(`sidecar /__requests failed: ${String(response.status)}`); + } + const body = (await response.json()) as { requests?: SpoolmanUsageRequest[] }; + return body.requests ?? []; + }, + reset: async () => { + await postControl(port, '/__reset'); + }, + stop: async () => { + try { + await postControl(port, '/__shutdown'); + } catch { + // Fall through to tree kill; the process may already be gone. + } + await stopProcessTree(child); + }, + }; + + async function waitForSpoolmanReady( + proc: ChildProcessByStdio, + timeoutMs: number + ): Promise { + return await new Promise((resolve, reject) => { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + const stdoutReader = readline.createInterface({ input: proc.stdout }); + const stderrReader = readline.createInterface({ input: proc.stderr }); + let expectingJson = false; + let settled = false; + + const cleanup = (): void => { + clearTimeout(timer); + stdoutReader.close(); + stderrReader.close(); + proc.off('error', handleError); + proc.off('exit', handleExit); + }; + const fail = (message: string): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject( + new Error( + `spoolman sidecar failed: ${message}\n--- stdout ---\n${stdoutLines.slice(-20).join('\n')}\n--- stderr ---\n${stderrLines.slice(-20).join('\n')}` + ) + ); + }; + const handleError = (error: Error): void => { + fail(`process error: ${error.message}`); + }; + const handleExit = (code: number | null, signal: NodeJS.Signals | null): void => { + fail(`process exited before readiness (code=${String(code)}, signal=${String(signal)})`); + }; + const timer = setTimeout(() => { + fail(`timed out waiting for ${SPOOLMAN_READY_TOKEN} after ${String(timeoutMs)}ms`); + }, timeoutMs); + + stdoutReader.on('line', (line) => { + const trimmed = line.trim(); + if (trimmed.length > 0) { + stdoutLines.push(trimmed); + } + if (expectingJson) { + expectingJson = false; + settled = true; + cleanup(); + try { + const payload = JSON.parse(trimmed) as SpoolmanReadyPayload; + resolve(payload); + } catch { + reject(new Error(`invalid JSON after ${SPOOLMAN_READY_TOKEN}: ${trimmed}`)); + } + return; + } + if (trimmed === SPOOLMAN_READY_TOKEN) { + expectingJson = true; + } + }); + stderrReader.on('line', (line) => { + const trimmed = line.trim(); + if (trimmed.length > 0) { + stderrLines.push(trimmed); + } + }); + proc.on('error', handleError); + proc.on('exit', handleExit); + }); + } +}; diff --git a/tests/e2e/browser/helpers/standalone-server.ts b/tests/e2e/browser/helpers/standalone-server.ts new file mode 100644 index 0000000..ce3e7c2 --- /dev/null +++ b/tests/e2e/browser/helpers/standalone-server.ts @@ -0,0 +1,433 @@ +/** + * @fileoverview Boots the real standalone FlashForgeWebUI server so browser tests + * drive the production stack. + * + * There is deliberately no fixture or stub here. This starts emulator printers, seeds + * an isolated DATA_DIR, launches the built server (`node dist/index.js + * --all-saved-printers`), and waits for its HTTP API. Every request a test makes is + * served by the real Express app: real auth middleware and AuthManager, real security + * and static-asset middleware, real WebSocketManager broadcasting real polling data + * from the emulated printers. + * + * Nothing in this file may reimplement app behaviour - if a test needs a response, + * the server has to produce it. (The lesson that created this rule: an earlier + * fixture-suite elsewhere ran against a hand-written API reimplementation, could not + * catch a single server-side regression, and drifted silently from the real routes.) + * + * Key exports: + * - startStandaloneWebUI(): boot emulators + the server, return its base URL + */ + +import { type ChildProcessByStdio, spawn } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; +import type { Readable } from 'node:stream'; +import { type EmulatorModel, startEmulatorInstance, stopProcessTree } from './emulator-harness'; + +const SERVER_READY_TIMEOUT_MS = 60_000; +const PRINTER_READY_TIMEOUT_MS = 90_000; +const READY_POLL_INTERVAL_MS = 400; +const LOG_TAIL_LINES = 40; + +/** Password the harness configures the WebUI with. Tests need it to log in. */ +export const WEBUI_TEST_PASSWORD = 'e2e-webui-password'; + +export interface StandalonePrinter { + label: string; + model: EmulatorModel; + serial: string; + checkCode: string; + /** Must not contain spaces: the emulator CLI parses argv positionally. */ + machineName: string; + tcpPort: number; + httpPort: number; +} + +/** + * Two printers by default so context switching has real contexts to switch between. + * + * Only one instance per machine can hold the firmware default ports, so the second is + * shifted. Both are reached through the saved profile, which carries explicit ports. + */ +export const DEFAULT_STANDALONE_PRINTERS: readonly StandalonePrinter[] = [ + { + label: 'Adventurer 5M Pro (emulated)', + model: 'adventurer-5m-pro', + serial: 'E2E-WEBUI-5MPRO', + checkCode: '123', + machineName: 'WebUI-5MPro', + tcpPort: 8899, + httpPort: 8898, + }, + { + label: 'AD5X (emulated)', + model: 'adventurer-5x', + serial: 'E2E-WEBUI-AD5X', + checkCode: '123', + machineName: 'WebUI-AD5X', + tcpPort: 8999, + httpPort: 8998, + }, +]; + +export interface StandaloneWebUI { + /** Base URL of the WebUI server that was started. */ + readonly baseUrl: string; + readonly password: string; + /** Printers the server connected, in the order they were seeded. */ + readonly printers: readonly StandalonePrinter[]; + /** Recent server stdout/stderr, for failure messages. */ + logTail(): string; + stop(): Promise; +} + +export interface StartStandaloneWebUIOptions { + printers?: readonly StandalonePrinter[]; + /** + * Printers seeded into the saved profile; defaults to `printers`. Pass a subset + * (or []) to boot emulators the server does NOT auto-connect, e.g. so specs can + * exercise the manual connect flow against them. + */ + seedPrinters?: readonly StandalonePrinter[]; + password?: string; + /** Number of printers the server must have connected before tests start. */ + requireConnectedPrinters?: number; + /** + * Extra config.json keys merged into the seeded config (e.g. Spoolman + * settings). Values here win over the deterministic defaults. + */ + configOverrides?: Record; + /** + * Emulator print simulation mode for every spawned instance. Defaults to + * 'manual' (progress moves only via /__simulate jumps). 'auto' lets the + * emulator heat up and transition heating → printing on its own, which + * specs can still freeze with /__simulate {action:'pause'} + jump. + */ + emulatorSimulationMode?: 'auto' | 'manual'; +} + +const sleep = async (ms: number): Promise => + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Asks the OS for a free port so parallel runs and stray servers cannot collide. */ +const findFreePort = async (): Promise => + await new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + if (address === null || typeof address === 'string') { + probe.close(() => reject(new Error('Could not determine a free port for the WebUI'))); + return; + } + const { port } = address; + probe.close(() => resolve(port)); + }); + }); + +/** Maps an emulator model token to the printerModel string the app's detection reads. */ +const modelToPrinterModel = (model: EmulatorModel): string => { + switch (model) { + case 'adventurer-5m-pro': + return 'Adventurer 5M Pro'; + case 'adventurer-5x': + return 'AD5X'; + case 'creator-5': + return 'Creator 5'; + case 'creator-5-pro': + return 'Creator 5 Pro'; + default: + return model; + } +}; + +/** + * Writes the saved-printer profile and deterministic config the server reads on boot. + * + * printer_details.json mirrors the persisted PrinterDetails the app writes itself; + * `--all-saved-printers` then connects every entry. The runtime model detection is + * authoritative either way (the emulated /detail pid decides the backend), so the + * seeded printerModel string only needs to be plausible for connection display. + */ +const seedDataDir = async ( + dataDir: string, + printers: readonly StandalonePrinter[], + ip: string, + port: number, + password: string, + configOverrides?: Record +): Promise => { + await mkdir(dataDir, { recursive: true }); + + const nowIso = new Date().toISOString(); + const entries: Record> = {}; + for (const printer of printers) { + entries[printer.serial] = { + Name: printer.machineName, + IPAddress: ip, + SerialNumber: printer.serial, + CheckCode: printer.checkCode, + ClientType: 'new', + printerModel: modelToPrinterModel(printer.model), + commandPort: printer.tcpPort, + httpPort: printer.httpPort, + webUIEnabled: true, + lastConnected: nowIso, + }; + } + + await writeFile( + path.join(dataDir, 'printer_details.json'), + `${JSON.stringify({ lastUsedPrinterSerial: printers[0]?.serial ?? null, printers: entries }, null, 2)}\n`, + 'utf-8' + ); + + // Deterministic auth config so the assertions never depend on whatever the current + // DEFAULT_CONFIG happens to be. The CLI flags below override these too, but the + // file keeps the run self-describing. + await writeFile( + path.join(dataDir, 'config.json'), + `${JSON.stringify( + { + WebUIEnabled: true, + WebUIPort: port, + WebUIPassword: password, + WebUIPasswordRequired: true, + ...configOverrides, + }, + null, + 2 + )}\n`, + 'utf-8' + ); +}; + +interface ServerProcess { + child: ChildProcessByStdio; + logLines: string[]; +} + +/** + * Launches the built server exactly as a headless user would. + * + * Detached on POSIX so signalling the parent takes the whole tree; on Windows + * stopProcessTree uses `taskkill /T`. + */ +const spawnServer = (params: { + dataDir: string; + port: number; + password: string; +}): ServerProcess => { + const logLines: string[] = []; + + const child = spawn( + process.execPath, + [ + 'dist/index.js', + '--all-saved-printers', + `--webui-port=${params.port}`, + `--webui-password=${params.password}`, + ], + { + cwd: process.cwd(), + env: { ...process.env, DATA_DIR: params.dataDir }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + detached: process.platform !== 'win32', + } + ) as ChildProcessByStdio; + + const record = (line: string): void => { + logLines.push(line); + if (logLines.length > 400) { + logLines.shift(); + } + }; + + readline.createInterface({ input: child.stdout }).on('line', record); + readline + .createInterface({ input: child.stderr }) + .on('line', (line) => record(`[stderr] ${line}`)); + + return { child, logLines }; +}; + +const formatLogTail = (logLines: readonly string[]): string => + logLines.slice(-LOG_TAIL_LINES).join('\n') || '(no output captured)'; + +/** Resolves once the server's own HTTP API answers, or throws with the server's log. */ +const waitForServer = async ( + baseUrl: string, + server: ServerProcess, + timeoutMs: number +): Promise => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (server.child.exitCode !== null) { + throw new Error( + `FlashForgeWebUI exited with code ${server.child.exitCode} before the server started.\n` + + `Server output:\n${formatLogTail(server.logLines)}` + ); + } + + try { + const response = await fetch(`${baseUrl}/api/auth/status`); + if (response.ok) { + return; + } + } catch { + // Server not listening yet. + } + + await sleep(READY_POLL_INTERVAL_MS); + } + + throw new Error( + `WebUI server did not start within ${timeoutMs}ms.\nServer output:\n${formatLogTail(server.logLines)}` + ); +}; + +/** + * Waits until the server reports the expected number of connected printers. + * + * Uses the real /api/contexts endpoint, which means the wait also proves the printers + * actually connected rather than merely that the HTTP server bound its port. + */ +const waitForPrinters = async ( + baseUrl: string, + token: string, + expected: number, + server: ServerProcess, + timeoutMs: number +): Promise => { + const deadline = Date.now() + timeoutMs; + let lastSeen = 0; + + while (Date.now() < deadline) { + try { + const response = await fetch(`${baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (response.ok) { + const payload = (await response.json()) as { contexts?: unknown[] }; + lastSeen = payload.contexts?.length ?? 0; + if (lastSeen >= expected) { + return; + } + } + } catch { + // Transient while the server finishes connecting. + } + + await sleep(READY_POLL_INTERVAL_MS); + } + + throw new Error( + `Expected ${expected} connected printer(s) but the server reported ${lastSeen} after ${timeoutMs}ms.\n` + + `Server output:\n${formatLogTail(server.logLines)}` + ); +}; + +/** Logs in through the real auth route to obtain a token for readiness polling. */ +const login = async (baseUrl: string, password: string): Promise => { + const response = await fetch(`${baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + const payload = (await response.json()) as { + success?: boolean; + token?: string; + message?: string; + }; + if (!payload.success || !payload.token) { + throw new Error(`Harness could not log into the WebUI: ${payload.message ?? response.status}`); + } + + return payload.token; +}; + +export const startStandaloneWebUI = async ( + options: StartStandaloneWebUIOptions = {} +): Promise => { + const printers = options.printers ?? DEFAULT_STANDALONE_PRINTERS; + const seedPrinters = options.seedPrinters ?? printers; + const password = options.password ?? WEBUI_TEST_PASSWORD; + const requiredPrinters = options.requireConnectedPrinters ?? seedPrinters.length; + + const emulators: Array<{ stop: () => Promise }> = []; + let dataRoot: string | null = null; + let server: ServerProcess | null = null; + + const cleanup = async (): Promise => { + if (server) { + await stopProcessTree(server.child); + } + for (const emulator of emulators.reverse()) { + await emulator.stop(); + } + if (dataRoot) { + await rm(dataRoot, { recursive: true, force: true }); + } + }; + + try { + let printerIp = '127.0.0.1'; + for (const printer of printers) { + const instance = await startEmulatorInstance({ + instance: { + instanceId: `webui-${printer.serial.toLowerCase()}`, + model: printer.model, + serial: printer.serial, + checkCode: printer.checkCode, + machineName: printer.machineName, + tcpPort: printer.tcpPort, + httpPort: printer.httpPort, + discoveryEnabled: true, + simulationMode: options?.emulatorSimulationMode ?? 'manual', + simulationSpeed: 100, + }, + }); + emulators.push(instance); + + const ready = instance.readyPayloads[0]; + if (!ready) { + throw new Error(`Emulator "${printer.label}" never reported ready`); + } + printerIp = ready.ip; + } + + dataRoot = await mkdtemp(path.join(os.tmpdir(), 'ffwebui-e2e-')); + const dataDir = path.join(dataRoot, 'data'); + + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + + await seedDataDir(dataDir, seedPrinters, printerIp, port, password, options?.configOverrides); + + server = spawnServer({ dataDir, port, password }); + await waitForServer(baseUrl, server, SERVER_READY_TIMEOUT_MS); + + const token = await login(baseUrl, password); + await waitForPrinters(baseUrl, token, requiredPrinters, server, PRINTER_READY_TIMEOUT_MS); + + const startedServer = server; + return { + baseUrl, + password, + printers: seedPrinters, + logTail: () => formatLogTail(startedServer.logLines), + stop: cleanup, + }; + } catch (error) { + await cleanup(); + throw error; + } +}; diff --git a/tests/e2e/browser/helpers/targets.ts b/tests/e2e/browser/helpers/targets.ts new file mode 100644 index 0000000..8478b01 --- /dev/null +++ b/tests/e2e/browser/helpers/targets.ts @@ -0,0 +1,120 @@ +/** + * @fileoverview Per-model emulator matrix for browser E2E suites. + * + * Ported from FlashForgeUI-Electron's `support/track.ts` pattern: one source of + * truth listing every emulated model the suite must exercise, each carrying the + * capability flags assertions derive from. Specs loop this list so a new model is + * covered everywhere by construction instead of by whichever models a spec author + * remembered to hand-pick. + * + * The matrix matches FlashForgeUI-Electron's emulator track (four models). All + * four sit on distinct ports so a single server instance can hold them at once; + * suites run sequentially (playwright workers: 1), so reusing the same ports + * across suites is fine. + */ + +import type { EmulatorModel } from './emulator-harness'; +import type { StandalonePrinter } from './standalone-server'; + +export type ModelToken = Exclude; + +/** + * USB product ID every real unit of this model carries; the discovery packet and + * the manual-connect model selector both use it, and HTTP-only models cannot be + * connected without it (it is what skips the legacy TCP probe). + */ +export const MODEL_PRODUCT_IDS: Readonly> = { + 'adventurer-5m': 35, + 'adventurer-5m-pro': 36, + 'adventurer-5x': 38, + 'creator-5': 40, + 'creator-5-pro': 41, +}; + +export interface ModelTarget { + readonly printer: StandalonePrinter; + /** USB product ID the manual connect API needs (see MODEL_PRODUCT_IDS). */ + readonly productId: number; + /** printerModel display string the app's detection reports for this model. */ + readonly printerModel: string; + /** + * Creator 5 series: HTTP-only firmware with no TCP/G-code passthrough. Local and + * recent job entry points are gated off and raw G-code controls (Home Axes) stay + * disabled. + */ + readonly isCreator5Series: boolean; + /** Models carrying an inventory/material feed station (IFS). */ + readonly hasMaterialStation: boolean; +} + +export const MODEL_TARGETS: readonly ModelTarget[] = [ + { + printer: { + label: 'Adventurer 5M Pro (emulated)', + model: 'adventurer-5m-pro', + serial: 'E2E-WEBUI-M-5MPRO', + checkCode: '123', + machineName: 'Matrix-5MPro', + tcpPort: 8899, + httpPort: 8898, + }, + isCreator5Series: false, + hasMaterialStation: false, + productId: MODEL_PRODUCT_IDS['adventurer-5m-pro'], + printerModel: 'Adventurer 5M Pro', + }, + { + printer: { + label: 'AD5X (emulated)', + model: 'adventurer-5x', + serial: 'E2E-WEBUI-M-AD5X', + checkCode: '123', + machineName: 'Matrix-AD5X', + tcpPort: 8999, + httpPort: 8998, + }, + isCreator5Series: false, + hasMaterialStation: true, + productId: MODEL_PRODUCT_IDS['adventurer-5x'], + printerModel: 'AD5X', + }, + { + printer: { + label: 'Creator 5 (emulated)', + model: 'creator-5', + serial: 'E2E-WEBUI-M-C5', + checkCode: '123', + machineName: 'Matrix-Creator5', + tcpPort: 9099, + httpPort: 9098, + }, + isCreator5Series: true, + hasMaterialStation: true, + productId: MODEL_PRODUCT_IDS['creator-5'], + printerModel: 'Creator 5', + }, + { + printer: { + label: 'Creator 5 Pro (emulated)', + model: 'creator-5-pro', + serial: 'E2E-WEBUI-M-C5PRO', + checkCode: '123', + machineName: 'Matrix-Creator5Pro', + tcpPort: 9199, + httpPort: 9198, + }, + isCreator5Series: true, + hasMaterialStation: true, + productId: MODEL_PRODUCT_IDS['creator-5-pro'], + printerModel: 'Creator 5 Pro', + }, +]; + +/** The matrix as a plain printer list, for startStandaloneWebUI({ printers }). */ +export const MATRIX_PRINTERS: readonly StandalonePrinter[] = MODEL_TARGETS.map( + (target) => target.printer +); + +/** Machine-name lookup for picking single targets out of the matrix. */ +export const PRINTER_BY_MACHINE_NAME: Readonly> = + Object.fromEntries(MODEL_TARGETS.map((target) => [target.printer.machineName, target.printer])); diff --git a/tests/e2e/browser/webui-auth.spec.ts b/tests/e2e/browser/webui-auth.spec.ts new file mode 100644 index 0000000..a0fde21 --- /dev/null +++ b/tests/e2e/browser/webui-auth.spec.ts @@ -0,0 +1,154 @@ +/** + * @fileoverview Browser coverage for the WebUI authentication flow against the real + * standalone server. + * + * Every assertion here runs against the app's own AuthManager and auth middleware, so + * a rejected password, an issued token, and a revoked one are all real. A hand-rolled + * fixture that hands out a hard-coded token string would only prove that the fixture + * agrees with itself. + * + * Ported from FlashForgeUI-Electron's browser auth suite; the standalone server + * shares the webui auth code, so the same assertions apply. + */ + +import { expect, test } from '@playwright/test'; +import { + type StandaloneWebUI, + startStandaloneWebUI, + WEBUI_TEST_PASSWORD, +} from './helpers/standalone-server'; + +/** + * Login budget: the server's rate limiter allows 5 login attempts per 15 minutes + * per IP (successes count too). This file spends exactly: harness login (1), bad + * password (2), page login with remember-me (3), session-restore login (4), and + * the revoke test's API login (5). Any new test that logs in must first shave one. + */ + +test.describe('WebUI authentication', () => { + let webui: StandaloneWebUI; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI(); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + test('rejects a bad password and keeps the UI locked', async ({ page }) => { + await page.goto(webui.baseUrl, { waitUntil: 'domcontentloaded' }); + + await expect(page.locator('#login-screen')).toBeVisible(); + await expect(page.locator('#main-ui')).toBeHidden(); + + await page.fill('#password-input', 'definitely-not-the-password'); + await page.click('#login-button'); + + // The real AuthManager rejects this, so the app must stay on the login screen. + await expect(page.locator('#main-ui')).toBeHidden(); + await expect(page.locator('#login-screen')).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('webui-token'))).toBeNull(); + }); + + test('logs in, persists the remembered token, and authenticates the websocket', async ({ + page, + }) => { + const consoleMessages: string[] = []; + page.on('console', (message) => { + if (message.type() === 'warning' || message.type() === 'error') { + consoleMessages.push(message.text()); + } + }); + + await page.goto(webui.baseUrl, { waitUntil: 'domcontentloaded' }); + + await expect(page.locator('#login-screen')).toBeVisible(); + + await page.fill('#password-input', WEBUI_TEST_PASSWORD); + await page.check('#remember-me-checkbox'); + await page.click('#login-button'); + + await expect(page.locator('#main-ui')).toBeVisible(); + await expect(page.locator('#login-screen')).toBeHidden(); + + // "Connected" only appears once the websocket handshake passed the real auth gate, + // so this doubles as the assertion that the token authenticated the socket. + await expect(page.locator('#connection-text')).toHaveText('Connected'); + + const storedToken = await page.evaluate(() => localStorage.getItem('webui-token')); + expect(storedToken, 'a real token should be persisted for "remember me"').toBeTruthy(); + + // The token has to be accepted by the real auth middleware, not just stored. + const authorized = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${storedToken}` }, + }); + expect(authorized.status).toBe(200); + + expect(consoleMessages).not.toEqual( + expect.arrayContaining([ + expect.stringContaining('icon name was not found'), + expect.stringContaining('No camera URL provided by server'), + expect.stringContaining('No WebSocket URL provided for camera stream'), + ]) + ); + }); + + test('restores the session from the remembered token without asking again', async ({ page }) => { + await page.goto(webui.baseUrl, { waitUntil: 'domcontentloaded' }); + await page.fill('#password-input', WEBUI_TEST_PASSWORD); + await page.check('#remember-me-checkbox'); + await page.click('#login-button'); + await expect(page.locator('#main-ui')).toBeVisible(); + + await page.reload({ waitUntil: 'domcontentloaded' }); + + // A remembered token must carry the reload with no second login prompt. + await expect(page.locator('#main-ui')).toBeVisible(); + await expect(page.locator('#login-screen')).toBeHidden(); + await expect(page.locator('#connection-text')).toHaveText('Connected'); + }); + + test('refuses API access without a valid token', async () => { + const anonymous = await fetch(`${webui.baseUrl}/api/contexts`); + expect(anonymous.status, 'protected routes must reject anonymous callers').toBe(401); + + const bogus = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: 'Bearer not-a-real-token' }, + }); + expect(bogus.status, 'protected routes must reject a forged token').toBe(401); + }); + + test('revokes the token on logout', async () => { + const loginResponse = await fetch(`${webui.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: WEBUI_TEST_PASSWORD }), + }); + const { token } = (await loginResponse.json()) as { token: string }; + + expect( + ( + await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }) + ).status + ).toBe(200); + + await fetch(`${webui.baseUrl}/api/auth/logout`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + + // A token that survives logout is a real security bug; a stub fixture could never + // catch it because a stub never implements revocation. + expect( + ( + await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }) + ).status, + 'a logged-out token must stop working' + ).toBe(401); + }); +}); diff --git a/tests/e2e/browser/webui-connect.spec.ts b/tests/e2e/browser/webui-connect.spec.ts new file mode 100644 index 0000000..6a77d82 --- /dev/null +++ b/tests/e2e/browser/webui-connect.spec.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Printer connection flow coverage, mirroring FlashForgeUI-Electron's + * connect spec against the standalone server's API. + * + * One suite boot starts the full model matrix of emulators but seeds only the + * Adventurer 5M Pro as a saved printer, so the remaining models must be reached + * through the real connect paths the webui offers: + * + * - auto-connect of saved printers on boot (--all-saved-printers) + * - manual connect per model via POST /printers/connect, including the HTTP-only + * Creator 5 series where a serial number is mandatory + * - rejection of a wrong check code without leaving a half-created context + * - network discovery (UDP broadcast) seeing every emulated instance and marking + * the saved one as known + */ + +import { expect, test } from '@playwright/test'; +import { fetchApiToken, postJson } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +interface ContextsPayload { + contexts: Array<{ + id: string; + name: string; + model?: string; + serialNumber?: string; + isActive: boolean; + }>; +} + +interface ConnectPayload { + success?: boolean; + contextId?: string; + error?: string; + printer?: { SerialNumber?: string }; +} + +interface DiscoveryPayload { + success?: boolean; + error?: string; + printers?: Array<{ serialNumber?: string }>; + savedMatches?: Array<{ isKnown?: boolean; ipAddressChanged?: boolean }>; +} + +/** The matrix's first entry: a plain dual-API printer, seeded as the saved profile. */ +const SAVED_TARGET = MODEL_TARGETS[0]; +if (!SAVED_TARGET) { + throw new Error('MODEL_TARGETS must not be empty'); +} + +const fetchContexts = async ( + webui: StandaloneWebUI, + token: string +): Promise => { + const response = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const payload = (await response.json()) as ContextsPayload; + return payload.contexts ?? []; +}; + +test.describe('WebUI printer connect flow', () => { + let webui: StandaloneWebUI; + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ + printers: MATRIX_PRINTERS, + seedPrinters: [SAVED_TARGET.printer], + }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + test('auto-connects the saved printer on boot', async () => { + const contexts = await fetchContexts(webui, token); + expect(contexts, 'only the seeded printer may be auto-connected').toHaveLength(1); + expect(contexts[0]?.name).toBe(SAVED_TARGET.printer.machineName); + expect(contexts[0]?.isActive).toBe(true); + }); + + for (const target of MODEL_TARGETS.slice(1)) { + const printer = target.printer; + + test(`connects to ${printer.machineName} through the manual connect API`, async () => { + const { status, payload } = await postJson( + webui, + token, + '/api/printers/connect', + { + ipAddress: '127.0.0.1', + type: 'new', + checkCode: printer.checkCode, + serialNumber: printer.serial, + name: printer.machineName, + productId: target.productId, + commandPort: printer.tcpPort, + httpPort: printer.httpPort, + } + ); + + expect(status, `connect failed: ${payload.error}`).toBe(200); + expect(payload.success).toBe(true); + expect(payload.contextId, 'a context must exist for the new printer').toBeTruthy(); + expect(payload.printer?.SerialNumber).toBe(printer.serial); + + const contexts = await fetchContexts(webui, token); + const match = contexts.find((context) => context.serialNumber === printer.serial); + expect( + match, + `expected a context for serial ${printer.serial} (${printer.machineName})` + ).toBeTruthy(); + expect(match?.model, `model detection must identify ${printer.machineName}`).toBe( + target.printerModel + ); + }); + } + + test('rejects a wrong check code without creating a context', async () => { + const printer = MODEL_TARGETS[1]?.printer; + if (!printer) { + throw new Error('MODEL_TARGETS must contain a second model'); + } + + const contextsBefore = await fetchContexts(webui, token); + + const { status, payload } = await postJson( + webui, + token, + '/api/printers/connect', + { + ipAddress: '127.0.0.1', + type: 'new', + checkCode: '000', + serialNumber: `${printer.serial}-BADCODE`, + name: 'Bad-CheckCode', + commandPort: printer.tcpPort, + httpPort: printer.httpPort, + } + ); + + expect(status, 'a wrong check code must not succeed').toBeGreaterThanOrEqual(400); + expect(payload.success).not.toBe(true); + + const contextsAfter = await fetchContexts(webui, token); + expect(contextsAfter).toHaveLength(contextsBefore.length); + }); + + test('finds every emulated printer through network discovery', async () => { + const { status, payload } = await postJson( + webui, + token, + '/api/discovery/scan', + { timeout: 8000, interval: 1000, retries: 3 } + ); + + expect(status, `discovery scan failed: ${payload.error}`).toBe(200); + expect(payload.success).toBe(true); + + const found = new Set((payload.printers ?? []).map((found) => found.serialNumber)); + for (const target of MODEL_TARGETS) { + expect( + found.has(target.printer.serial), + `discovery must report ${target.printer.machineName}` + ).toBe(true); + } + + const savedMatch = payload.savedMatches?.find((match) => match.isKnown === true); + expect(savedMatch, 'the seeded saved printer must be flagged as known').toBeTruthy(); + }); +}); diff --git a/tests/e2e/browser/webui-creator5-gating.spec.ts b/tests/e2e/browser/webui-creator5-gating.spec.ts new file mode 100644 index 0000000..9519399 --- /dev/null +++ b/tests/e2e/browser/webui-creator5-gating.spec.ts @@ -0,0 +1,92 @@ +/** + * @fileoverview Browser coverage for the Creator 5 series capability gating. + * + * Creator 5 / Creator 5 Pro firmware neither sends nor accepts material mappings + * over the local API, so starting a previously-uploaded local or recent job + * dead-ends at material selection. The webui disables those entry points for the + * series (commit 27c67ca) while every other model keeps them. This spec pins that + * contract per model, driven end to end through the real server and emulated + * printers, so a regression fails loudly instead of shipping a dead-end button. + * + * It also pins the HTTP-only side effect: Home Axes is raw G-code (~G28), and the + * series exposes no TCP/G-code passthrough, so the button must stay disabled for + * those models and enabled on a 5M-family printer. + * + * Every model in the matrix is exercised (see helpers/targets.ts); the expected + * state derives from each target's isCreator5Series flag. + */ + +import { expect, test } from '@playwright/test'; +import { fetchApiToken, openWithRememberedToken, switchPrinterInUi } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +const LOCAL_JOB_UNAVAILABLE_MESSAGE = 'Local job management is not available on this printer.'; + +test.describe('WebUI Creator 5 capability gating', () => { + let webui: StandaloneWebUI; + /** + * Shared API token, fetched once in beforeAll. + * + * The server's login rate limiter allows 5 attempts per 15 minutes per IP and + * counts successful logins too. This suite spends two (harness readiness + + * this token); every test then opens its page through the remembered-token + * restore path, which is not a login. Same constraint the + * FlashForgeUI-Electron browser suite is budgeted around. + */ + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + for (const target of MODEL_TARGETS) { + test(`gates local job entry points per model on ${target.printer.machineName}`, async ({ + page, + }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, target.printer.machineName); + + const recentButton = page.locator('#btn-start-recent'); + const localButton = page.locator('#btn-start-local'); + const homeAxesButton = page.locator('#btn-home-axes'); + + if (target.isCreator5Series) { + await expect( + recentButton, + 'recent job start must be disabled on the Creator 5 series' + ).toBeDisabled(); + await expect( + localButton, + 'local job start must be disabled on the Creator 5 series' + ).toBeDisabled(); + await expect(recentButton).toHaveAttribute('title', LOCAL_JOB_UNAVAILABLE_MESSAGE); + await expect(localButton).toHaveAttribute('title', LOCAL_JOB_UNAVAILABLE_MESSAGE); + + // HTTP-only: no TCP/G-code passthrough, so Home Axes must stay disabled. + await expect( + homeAxesButton, + 'Home Axes must be disabled on HTTP-only models' + ).toBeDisabled(); + } else { + await expect( + recentButton, + 'recent job start must stay available on other models' + ).toBeEnabled(); + await expect( + localButton, + 'local job start must stay available on other models' + ).toBeEnabled(); + await expect( + homeAxesButton, + 'Home Axes must be available on printers with a G-code channel' + ).toBeEnabled(); + } + }); + } +}); diff --git a/tests/e2e/browser/webui-led.spec.ts b/tests/e2e/browser/webui-led.spec.ts new file mode 100644 index 0000000..070af61 --- /dev/null +++ b/tests/e2e/browser/webui-led.spec.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview LED control coverage across every model, mirroring FlashForgeUI-Electron's + * emulator-track LED spec. + * + * Drives the real server's led-on/led-off routes for each model in the matrix and + * asserts the change in the emulator printer's own /detail state (lightStatus + * open/close), never in rendered UI: a button that toggles its own styling while + * the command never reaches the printer is precisely the failure this catches. + * + * The cycle ends with the LED back on, which is the state a user leaves a printer in. + */ + +import { expect, test } from '@playwright/test'; +import { fetchApiToken, postJson, readEmulatorDetail, resolveContextId } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +interface CommandPayload { + success?: boolean; + error?: string; +} + +const postLedCommand = async ( + webui: StandaloneWebUI, + token: string, + contextId: string, + enabled: boolean +): Promise<{ status: number; payload: CommandPayload }> => + await postJson( + webui, + token, + `/api/printer/control/led-${enabled ? 'on' : 'off'}?contextId=${contextId}` + ); + +test.describe('WebUI LED control', () => { + let webui: StandaloneWebUI; + /** Fetched once per suite; the login rate limiter budgets 5 attempts / 15 min. */ + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + for (const target of MODEL_TARGETS) { + const printer = target.printer; + + test(`cycles the LED on, off, and back on on ${printer.machineName}`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Start from a known state so the first assertion cannot pass by accident. + const initialOff = await postLedCommand(webui, token, contextId, false); + expect(initialOff.status, `initial LED off failed: ${initialOff.payload.error}`).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('close'); + + const onResult = await postLedCommand(webui, token, contextId, true); + expect(onResult.status, `LED on failed: ${onResult.payload.error}`).toBe(200); + expect(onResult.payload.success).toBe(true); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('open'); + + const offResult = await postLedCommand(webui, token, contextId, false); + expect(offResult.status, `LED off failed: ${offResult.payload.error}`).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('close'); + + const onAgain = await postLedCommand(webui, token, contextId, true); + expect(onAgain.status, `LED on again failed: ${onAgain.payload.error}`).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).lightStatus).toBe('open'); + }); + } +}); diff --git a/tests/e2e/browser/webui-matching-multitool.spec.ts b/tests/e2e/browser/webui-matching-multitool.spec.ts new file mode 100644 index 0000000..75fec0e --- /dev/null +++ b/tests/e2e/browser/webui-matching-multitool.spec.ts @@ -0,0 +1,176 @@ +/** + * @fileoverview INTERACTIVE multi-tool material matching regression net. + * + * The earlier matching specs only ever drove the dialog with single-color + * fixtures (one tool row) or posted mappings through the API, so the + * click-through path for the SECOND and later tool rows had no coverage. + * + * This spec clicks like a user: select a tool row, click a loaded slot, watch + * the mapping preview grow, and requires Confirm to enable only once every + * tool is mapped. It runs on every material-station model in the matrix. + * + * The fixture uses slicer filaments 1 and 3 (PLA and PETG), which print with + * T0 and T2. The dialog must show them as Tool 1 and Tool 3 and send tool ids + * 0 and 2. A second suite checks the Spoolman spool picker in the same dialog. + */ + +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { fetchApiToken, openWithRememberedToken, switchPrinterInUi } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { + isSpoolmanSidecarAvailable, + type SpoolmanSidecar, + SPOOLMAN_SIDECAR_SKIP_MESSAGE, + startSpoolmanSidecar, +} from './helpers/spoolman-sidecar'; +import { MATRIX_PRINTERS, MODEL_TARGETS, PRINTER_BY_MACHINE_NAME } from './helpers/targets'; + +const FIXTURES_DIR = path.resolve('tests/fixtures/print-files'); +const TWO_TOOL_FIXTURE = 'two-tool-toolchange.3mf'; + +test.describe('WebUI interactive multi-tool material matching', () => { + let webui: StandaloneWebUI; + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + for (const target of MODEL_TARGETS.filter((candidate) => candidate.hasMaterialStation)) { + const printer = target.printer; + + test(`maps every tool interactively on ${printer.machineName}`, async ({ page }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, printer.machineName); + + await page.click('#btn-upload-job'); + await expect(page.locator('#job-upload-modal')).toBeVisible(); + await page.locator('#job-upload-file-input').setInputFiles(path.join(FIXTURES_DIR, TWO_TOOL_FIXTURE)); + + const matching = page.locator('#material-matching-modal'); + await expect(matching, 'a two-filament file on a station printer must raise the matching dialog').toBeVisible(); + const tools = matching.locator('.material-tool-item'); + await expect(tools).toHaveCount(2); + // Tool rows carry the gcode tool index, not the list position. + await expect(tools.nth(0)).toHaveAttribute('data-tool-id', '0'); + await expect(tools.nth(1)).toHaveAttribute('data-tool-id', '2'); + await expect(tools.nth(1)).toContainText('Tool 3'); + + const firstTool = tools.first(); + await firstTool.click(); + await expect(firstTool).toHaveClass(/selected/); + await matching.locator('.material-slot-item[data-material-type="PLA"]:not(.disabled)').click(); + await expect(matching.locator('.material-mapping-item')).toHaveCount(1); + + const secondTool = tools.nth(1); + await secondTool.click(); + await expect(secondTool, 'clicking the second tool row must select it').toHaveClass(/selected/); + await expect(firstTool).not.toHaveClass(/selected/); + + await matching.locator('.material-slot-item[data-material-type="PETG"]:not(.disabled)').click(); + await expect(matching.locator('.material-mapping-item')).toHaveCount(2); + await expect( + matching.locator('#material-matching-confirm'), + 'Confirm must enable once every tool has a mapping' + ).toBeEnabled(); + + await matching.locator('#material-matching-confirm').click(); + await expect(page.locator('.job-upload-mapping-chip')).toHaveCount(2); + await page.locator('#job-upload-cancel').click(); + }); + + test(`rejects a slot with a different material on ${printer.machineName}`, async ({ page }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, printer.machineName); + + await page.click('#btn-upload-job'); + await page.locator('#job-upload-file-input').setInputFiles(path.join(FIXTURES_DIR, TWO_TOOL_FIXTURE)); + const matching = page.locator('#material-matching-modal'); + await expect(matching.locator('.material-tool-item')).toHaveCount(2); + + await matching.locator('.material-tool-item').nth(1).click(); + await matching.locator('.material-slot-item[data-material-type="PLA"]:not(.disabled)').click(); + await expect(matching.locator('#material-matching-error')).toContainText('Material mismatch'); + await expect(matching.locator('.material-mapping-item')).toHaveCount(0); + + await matching.locator('#material-matching-cancel').click(); + await page.locator('#job-upload-cancel').click(); + }); + } +}); + +test.describe('WebUI matching dialog spool picker (Spoolman)', () => { + test.skip(!isSpoolmanSidecarAvailable(), SPOOLMAN_SIDECAR_SKIP_MESSAGE); + + const printer = PRINTER_BY_MACHINE_NAME['Matrix-Creator5']; + let sidecar: SpoolmanSidecar; + let webui: StandaloneWebUI; + let token: string; + + test.beforeAll(async () => { + sidecar = await startSpoolmanSidecar(); + webui = await startStandaloneWebUI({ + printers: [printer], + configOverrides: { + SpoolmanEnabled: true, + SpoolmanServerUrl: sidecar.baseUrl, + SpoolmanUpdateMode: 'weight', + }, + }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + await sidecar?.stop(); + }); + + test('asks for a spool per tool and sends the choices with the upload', async ({ page }) => { + // One printer only, so there is no printer picker to switch with. + await openWithRememberedToken(page, webui, token); + + await page.click('#btn-upload-job'); + await page.locator('#job-upload-file-input').setInputFiles(path.join(FIXTURES_DIR, TWO_TOOL_FIXTURE)); + const matching = page.locator('#material-matching-modal'); + await expect(matching.locator('.material-tool-item')).toHaveCount(2); + await expect(matching.locator('#material-spool-hint')).toBeVisible(); + + await matching.locator('.material-tool-item').nth(0).click(); + await matching.locator('.material-slot-item[data-material-type="PLA"]:not(.disabled)').click(); + await matching.locator('.material-tool-item').nth(1).click(); + await matching.locator('.material-slot-item[data-material-type="PETG"]:not(.disabled)').click(); + + const spoolSelects = matching.locator('select.material-mapping-spool'); + await expect(spoolSelects).toHaveCount(2); + const confirm = matching.locator('#material-matching-confirm'); + await expect(confirm, 'Confirm waits for a spool choice per tool').toBeDisabled(); + + await spoolSelects.nth(0).selectOption('1'); + await expect(confirm).toBeDisabled(); + await spoolSelects.nth(1).selectOption('none'); + await expect(confirm).toBeEnabled(); + await confirm.click(); + + const chips = page.locator('.job-upload-mapping-chip'); + await expect(chips.nth(0)).toContainText('Spool #1'); + await expect(chips.nth(1)).toContainText('Not tracked'); + + const startRequest = page.waitForRequest((request) => request.url().includes('/api/jobs/upload/start')); + await page.locator('#job-upload-ok').click(); + const body = (await startRequest).postDataJSON() as { + materialMappings: Array<{ toolId: number; slotId: number }>; + spoolAssignments: Array<{ toolId: number; spoolId: number | null }>; + }; + expect(body.materialMappings.map((mapping) => mapping.toolId)).toEqual([0, 2]); + expect(body.spoolAssignments).toEqual([ + { toolId: 0, spoolId: 1 }, + { toolId: 2, spoolId: null }, + ]); + }); +}); diff --git a/tests/e2e/browser/webui-smoke.spec.ts b/tests/e2e/browser/webui-smoke.spec.ts new file mode 100644 index 0000000..855f021 --- /dev/null +++ b/tests/e2e/browser/webui-smoke.spec.ts @@ -0,0 +1,195 @@ +/** + * @fileoverview Browser coverage for WebUI boot, cache-busted assets, and printer + * context switching against the real standalone server. + * + * The asset checks are the reason this suite exists: a stale mix of cached and + * rebuilt WebUI files produces icon and camera failures that no other surface sees. + * They run against the server's real static middleware and its real build stamp. + * + * Context switching drives two genuinely connected emulator printers, so switching is + * verified against the server's own reported active context rather than a canned + * reply. + * + * Ported from FlashForgeUI-Electron's browser smoke suite. + */ + +import { expect, type Page, test } from '@playwright/test'; +import { + type StandaloneWebUI, + startStandaloneWebUI, + WEBUI_TEST_PASSWORD, +} from './helpers/standalone-server'; + +/** + * Login budget: the rate limiter allows 5 logins / 15 min / IP. This file spends + * exactly: harness login (1), plus one UI login per test (2-4). Any new test that + * logs in must first shave one. + */ + +/** Logs in through the UI and waits for the dashboard to come up. */ +const signIn = async (page: Page, webui: StandaloneWebUI): Promise => { + await page.goto(webui.baseUrl, { waitUntil: 'domcontentloaded' }); + await page.fill('#password-input', WEBUI_TEST_PASSWORD); + await page.click('#login-button'); + await expect(page.locator('#main-ui')).toBeVisible(); +}; + +interface ContextsPayload { + contexts: Array<{ id: string; name: string; isActive: boolean }>; + activeContextId: string | null; +} + +/** + * Gets a token straight from the auth API. + * + * Deliberately independent of whatever the browser stored: these assertions exist to + * check the server's view of the world, so they must not fail merely because the + * client keeps its token somewhere other than localStorage. + */ +const apiToken = async (webui: StandaloneWebUI): Promise => { + const response = await fetch(`${webui.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: WEBUI_TEST_PASSWORD }), + }); + const payload = (await response.json()) as { token?: string }; + if (!payload.token) { + throw new Error('Could not obtain an API token for out-of-band assertions'); + } + return payload.token; +}; + +const fetchContexts = async (webui: StandaloneWebUI, token: string): Promise => { + const response = await fetch(`${webui.baseUrl}/api/contexts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.status, 'the contexts endpoint should accept a freshly issued token').toBe(200); + return (await response.json()) as ContextsPayload; +}; + +test.describe('WebUI smoke', () => { + let webui: StandaloneWebUI; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI(); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + test('loads the built WebUI with versioned assets and without stale camera/icon errors', async ({ + page, + }) => { + const consoleMessages: string[] = []; + page.on('console', (message) => { + if (message.type() === 'warning' || message.type() === 'error') { + consoleMessages.push(message.text()); + } + }); + + // Observed from the client, which is the side that decides what to request. + const requestedPaths: string[] = []; + page.on('request', (request) => { + requestedPaths.push(new URL(request.url()).pathname); + }); + + await signIn(page, webui); + await expect(page.locator('#connection-text')).toHaveText('Connected'); + + const assetInfo = await page.evaluate(() => { + const localAssetUrls = [ + ...Array.from(document.querySelectorAll('link[href]')).map( + (element) => element.getAttribute('href') || '' + ), + ...Array.from(document.querySelectorAll('script[src]')).map( + (element) => element.getAttribute('src') || '' + ), + ].filter((url) => url.length > 0 && !url.startsWith('http')); + const inlineVideoImport = Array.from(document.querySelectorAll('script[type="module"]')) + .map((element) => element.textContent || '') + .find((content) => content.includes('video-rtc.js')); + + return { + localAssetUrls, + allVersioned: localAssetUrls.every((url) => url.includes('?v=')), + inlineVideoImportVersioned: inlineVideoImport?.includes('video-rtc.js?v=') ?? false, + }; + }); + + expect(assetInfo.localAssetUrls.length).toBeGreaterThan(0); + expect(assetInfo.allVersioned).toBe(true); + expect(assetInfo.inlineVideoImportVersioned).toBe(true); + + // No camera is configured on an emulated printer, so the client must not try to + // bootstrap a stream. + expect(requestedPaths).not.toContain('/api/camera/proxy-config'); + + expect(consoleMessages).not.toEqual( + expect.arrayContaining([ + expect.stringContaining('icon name was not found'), + expect.stringContaining('No camera URL provided by server'), + expect.stringContaining('No WebSocket URL provided for camera stream'), + ]) + ); + }); + + test('serves every referenced asset without a stale or missing file', async ({ page }) => { + const failures: string[] = []; + page.on('response', (response) => { + if (response.status() >= 400) { + failures.push(`${response.status()} ${new URL(response.url()).pathname}`); + } + }); + + await signIn(page, webui); + await expect(page.locator('#connection-text')).toHaveText('Connected'); + + // A 404 on a versioned asset is exactly the stale-build symptom this suite guards, + // and against the real static middleware it is observable here. + expect( + failures, + `the WebUI requested resources the server could not serve:\n${failures.join('\n')}` + ).toEqual([]); + }); + + test('switches printer contexts through the built WebUI', async ({ page }) => { + const token = await apiToken(webui); + + // The server makes the last printer it connected active, so the target has to be + // chosen from the live state. Switching to whichever one is already active would + // pass without proving anything. + const before = await fetchContexts(webui, token); + expect(before.contexts.length, 'this test needs two connected printers').toBeGreaterThanOrEqual( + 2 + ); + + const target = before.contexts.find((context) => !context.isActive); + expect(target, 'expected an inactive context to switch to').toBeTruthy(); + + await signIn(page, webui); + + const selector = page.locator('#printer-select'); + await expect(selector).toBeVisible(); + + // Every real context must be offered. + const optionLabels = (await selector.locator('option').allTextContents()).join(' '); + for (const printer of webui.printers) { + expect(optionLabels, `the selector should list ${printer.machineName}`).toContain( + printer.machineName + ); + } + + await selector.selectOption(target!.id); + + // Assert against the server's own view of the active context, not the dropdown's + // rendering: a selector that updates locally while the switch never reaches the + // server is exactly the regression worth catching. + await expect + .poll(async () => (await fetchContexts(webui, token)).activeContextId, { timeout: 15_000 }) + .toBe(target!.id); + + const after = await fetchContexts(webui, token); + expect(after.contexts.filter((context) => context.isActive)).toHaveLength(1); + }); +}); diff --git a/tests/e2e/browser/webui-spoolman-tracking.spec.ts b/tests/e2e/browser/webui-spoolman-tracking.spec.ts new file mode 100644 index 0000000..6d48783 --- /dev/null +++ b/tests/e2e/browser/webui-spoolman-tracking.spec.ts @@ -0,0 +1,605 @@ +/** + * @fileoverview E2E coverage for per-job Spoolman tracking on material-station + * printers (GitHub issue #21). + * + * Runs against the local flashforge-emulator-v2 checkout plus its Spoolman + * mock sidecar (`npm run headless:spoolman`). CI clones the emulator at a + * pinned version that may predate the sidecar, so the whole suite skips with + * an explanatory message when `scripts/headless/run-spoolman.ts` is absent. + * + * The user picks a spool for each tool in the matching dialog; the choice + * belongs to that one print. Scenarios: + * - Completion charges each chosen spool its full slicer estimate. + * - Cancel at 40% and at 75% charges each tool from the per-tool usage curve + * of the fixture's gcode (tool 0 prints the first half, tool 2 the second). + * - Pause/resume charges nothing; completion afterwards charges in full. + * - "Do not track" for a tool, no spool choices at all, and a file sent + * without Start Now all leave those tools uncharged. + * - A reprint of a tracked file started on the printer itself charges + * nothing: the spool choice ended with the first print. + * - The panel data shows the tracked job while it prints. + * - Same completion on the Creator 5 Pro and AD5X; AD5X stored-file starts + * through the matching dialog are charged from the printer's per-tool data. + * - 5M Pro regression: the single-spool flow is unchanged. + * + * Expected amounts come from two-tool-toolchange.expected.json, which the + * fixture generator computes independently of the app (tolerance ±0.05 g). + */ + +import * as fs from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { fetchApiToken, postJson, postRaw, readEmulatorDetail, resolveContextId } from './helpers/api'; +import { + type StandalonePrinter, + type StandaloneWebUI, + startStandaloneWebUI, +} from './helpers/standalone-server'; +import { PRINTER_BY_MACHINE_NAME } from './helpers/targets'; +import { + type SpoolmanSidecar, + SPOOLMAN_SIDECAR_SKIP_MESSAGE, + isSpoolmanSidecarAvailable, + startSpoolmanSidecar, +} from './helpers/spoolman-sidecar'; + +const FIXTURES_DIR = path.resolve('tests/fixtures/print-files'); +const TWO_TOOL_FIXTURE = 'two-tool-toolchange.3mf'; +const SINGLE_TOOL_GCODE = 'adventurer5m-single-color.gcode'; + +interface ExpectedTool { + usedG: number; + gramsAt40: number; + gramsAt75: number; +} +const EXPECTED = JSON.parse( + readFileSync(path.join(FIXTURES_DIR, 'two-tool-toolchange.expected.json'), 'utf8') +) as { tools: Record<'0' | '2', ExpectedTool> }; +const TOOL_0 = EXPECTED.tools['0']; +const TOOL_2 = EXPECTED.tools['2']; + +/** Gram tolerance for fixture-computed expectations. */ +const G_TOLERANCE = 0.05; + +/** WebUI polling cadence plus headroom; waits account for at least one cycle. */ +const POLL_CYCLE_MS = 4500; + +/** Spools chosen for the two tools across the station scenarios. */ +const SPOOL_A = 1; +const SPOOL_B = 2; + +/** The 5M regression pins 50% progress, so the cached weight is half of 96 g. */ +const EMULATOR_HALF_WEIGHT_G = 48; + +/** Tool→slot mapping the matching dialog sends: filaments 1 and 3 print with T0 and T2. */ +const MAPPINGS = [ + { toolId: 0, slotId: 1, materialName: 'PLA', toolMaterialColor: '#4DA3FF', slotMaterialColor: '#4DA3FF' }, + { toolId: 2, slotId: 2, materialName: 'PETG', toolMaterialColor: '#FF8A3D', slotMaterialColor: '#FF8A3D' }, +] as const; + +const BOTH_SPOOLS = [ + { toolId: 0, spoolId: SPOOL_A }, + { toolId: 2, spoolId: SPOOL_B }, +]; + +interface StagePayload { + success?: boolean; + uploadId?: string; + error?: string; +} + +interface StartPayload { + success?: boolean; + fileName?: string; + error?: string; +} + +const sidecarAvailable = isSpoolmanSidecarAvailable(); + +const emulatorStatus = async (printer: StandalonePrinter): Promise => { + return (await readEmulatorDetail(printer)).status?.toLowerCase() ?? ''; +}; + +const emulatorPost = async ( + printer: StandalonePrinter, + route: string, + body: Record +): Promise => { + const response = await fetch(`http://127.0.0.1:${printer.httpPort}${route}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.ok).toBe(true); + await response.body?.cancel(); +}; + +const emulatorSimulate = (printer: StandalonePrinter, body: Record) => + emulatorPost(printer, '/__simulate', body); + +const clearPlatform = async (printer: StandalonePrinter): Promise => { + await emulatorPost(printer, '/control', { + serialNumber: printer.serial, + checkCode: printer.checkCode, + payload: { cmd: 'stateCtrl_cmd', args: { action: 'setClearPlatform' } }, + }); + await expect.poll(async () => emulatorStatus(printer), { timeout: 20_000 }).toBe('ready'); +}; + +/** + * Wait until the job is actually printing, freeze auto simulation, and let + * one WebUI poll cycle pass so the tracker sees the job start. A real print + * lasts far longer than one poll; the emulator's jumps do not. + */ +const driveToPrinting = async (printer: StandalonePrinter): Promise => { + await emulatorSimulate(printer, { action: 'resume' }); + await expect.poll(async () => emulatorStatus(printer), { timeout: 60_000 }).toBe('printing'); + await emulatorSimulate(printer, { action: 'pause' }); + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS)); +}; + +/** Start a file already on the printer directly, bypassing the WebUI. */ +const startOnPrinter = async (printer: StandalonePrinter, fileName: string): Promise => { + const start = await fetch(`http://127.0.0.1:${printer.httpPort}/printGcode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serialNumber: printer.serial, + checkCode: printer.checkCode, + fileName, + levelingBeforePrint: false, + }), + }); + expect(start.ok).toBe(true); + expect(((await start.json()) as { code?: number }).code).toBe(0); +}; + +const chargesBySpool = async (sidecar: SpoolmanSidecar): Promise> => { + const map = new Map(); + for (const entry of await sidecar.requests()) { + map.set(entry.spoolId, (map.get(entry.spoolId) ?? 0) + (entry.useWeight ?? 0)); + } + return map; +}; + +const expectCharge = (charges: Map, spoolId: number, grams: number): void => { + const charged = charges.get(spoolId) ?? 0; + expect(Math.abs(charged - grams), `spool ${spoolId}: charged ${charged} g, expected ${grams} g`).toBeLessThanOrEqual( + G_TOLERANCE + ); +}; + +/** Upload a fixture through the WebUI with the given matching-dialog choices. */ +const uploadThroughApp = async ( + webui: StandaloneWebUI, + token: string, + contextId: string, + options: { + fixture?: string; + startNow?: boolean; + spoolAssignments?: ReadonlyArray<{ toolId: number; spoolId: number | null }>; + mappings?: ReadonlyArray<(typeof MAPPINGS)[number]>; + } = {} +): Promise => { + const fixture = options.fixture ?? TWO_TOOL_FIXTURE; + const { payload: stage } = await postRaw( + webui, + token, + `/api/jobs/upload/stage?contextId=${contextId}&filename=${encodeURIComponent(fixture)}`, + await fs.readFile(path.join(FIXTURES_DIR, fixture)) + ); + expect(stage.error).toBeUndefined(); + + const { payload: start } = await postJson(webui, token, `/api/jobs/upload/start?contextId=${contextId}`, { + uploadId: stage.uploadId, + startNow: options.startNow ?? true, + autoLevel: false, + materialMappings: options.mappings ?? MAPPINGS, + spoolAssignments: options.spoolAssignments, + }); + expect(start.error).toBeUndefined(); + return start.fileName ?? fixture; +}; + +const startSuite = async (printers: StandalonePrinter[]) => { + const sidecar = await startSpoolmanSidecar(); + const webui = await startStandaloneWebUI({ + printers, + configOverrides: { + SpoolmanEnabled: true, + SpoolmanServerUrl: sidecar.baseUrl, + SpoolmanUpdateMode: 'weight', + }, + // Auto simulation so the emulator can heat up and reach 'printing'; + // each scenario freezes progress with /__simulate pause + jump. + emulatorSimulationMode: 'auto', + }); + const token = await fetchApiToken(webui); + return { sidecar, webui, token }; +}; + +// ============================================================================ +// Creator 5 — core scenarios +// ============================================================================ + +test.describe('Spoolman per-job tracking (Creator 5)', () => { + test.skip(!sidecarAvailable, SPOOLMAN_SIDECAR_SKIP_MESSAGE); + test.describe.configure({ mode: 'serial' }); + + const printer: StandalonePrinter = PRINTER_BY_MACHINE_NAME['Matrix-Creator5']; + let sidecar: SpoolmanSidecar; + let webui: StandaloneWebUI; + let token = ''; + let contextId = ''; + + test.beforeAll(async () => { + ({ sidecar, webui, token } = await startSuite([printer])); + contextId = await resolveContextId(webui, token, printer.machineName); + }); + + test.afterAll(async () => { + await webui?.stop(); + await sidecar?.stop(); + }); + + const upload = (options: Parameters[3] = {}) => + uploadThroughApp(webui, token, contextId, options); + + const readStation = async () => { + const response = await fetch(`${webui.baseUrl}/api/spoolman/config`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return ((await response.json()) as { + station?: { activeJob: { fileName: string; tools: Array<{ spoolId: number }> } | null }; + }).station; + }; + + test('completion charges each chosen spool its full estimate', async () => { + await sidecar.reset(); + await upload({ spoolAssignments: BOTH_SPOOLS }); + await driveToPrinting(printer); + + const station = await readStation(); + expect(station?.activeJob?.fileName).toBe(TWO_TOOL_FIXTURE); + expect(station?.activeJob?.tools.map((tool) => tool.spoolId)).toEqual([SPOOL_A, SPOOL_B]); + + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(2); + + const charges = await chargesBySpool(sidecar); + expectCharge(charges, SPOOL_A, TOOL_0.usedG); + expectCharge(charges, SPOOL_B, TOOL_2.usedG); + expect((await sidecar.requests()).every((entry) => entry.useLength === null)).toBe(true); + await expect.poll(async () => (await readStation())?.activeJob ?? null).toBeNull(); + + await clearPlatform(printer); + }); + + for (const percent of [40, 75] as const) { + test(`cancel at ${percent}% charges each tool from its own usage curve`, async () => { + await sidecar.reset(); + await upload({ spoolAssignments: BOTH_SPOOLS }); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent }); + // Let the WebUI's polling cycle observe the progress before cancelling. + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS)); + + const { payload } = await postJson<{ error?: string }>( + webui, + token, + `/api/printer/control/cancel?contextId=${contextId}`, + {} + ); + expect(payload.error).toBeUndefined(); + + const expectedTool0 = percent === 40 ? TOOL_0.gramsAt40 : TOOL_0.gramsAt75; + const expectedTool2 = percent === 40 ? TOOL_2.gramsAt40 : TOOL_2.gramsAt75; + const expectedRequests = [expectedTool0, expectedTool2].filter((grams) => grams > 0).length; + await expect + .poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }) + .toBe(expectedRequests); + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS)); + expect(await sidecar.requests()).toHaveLength(expectedRequests); + + const charges = await chargesBySpool(sidecar); + expectCharge(charges, SPOOL_A, expectedTool0); + expectCharge(charges, SPOOL_B, expectedTool2); + + await clearPlatform(printer); + }); + } + + test('pause and resume charge nothing; completion afterwards charges in full', async () => { + await sidecar.reset(); + await upload({ spoolAssignments: BOTH_SPOOLS }); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 25 }); + + const { payload: pausePayload } = await postJson<{ error?: string }>( + webui, + token, + `/api/printer/control/pause?contextId=${contextId}`, + {} + ); + expect(pausePayload.error).toBeUndefined(); + await expect.poll(async () => emulatorStatus(printer), { timeout: 20_000 }).toBe('paused'); + + const { payload: resumePayload } = await postJson<{ error?: string }>( + webui, + token, + `/api/printer/control/resume?contextId=${contextId}`, + {} + ); + expect(resumePayload.error).toBeUndefined(); + await expect.poll(async () => emulatorStatus(printer), { timeout: 20_000 }).toBe('printing'); + + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS)); + expect(await sidecar.requests()).toHaveLength(0); + + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(2); + const charges = await chargesBySpool(sidecar); + expectCharge(charges, SPOOL_A, TOOL_0.usedG); + expectCharge(charges, SPOOL_B, TOOL_2.usedG); + + await clearPlatform(printer); + }); + + test('"Do not track" leaves that tool uncharged', async () => { + await sidecar.reset(); + await upload({ + spoolAssignments: [ + { toolId: 0, spoolId: SPOOL_A }, + { toolId: 2, spoolId: null }, + ], + }); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(1); + expectCharge(await chargesBySpool(sidecar), SPOOL_A, TOOL_0.usedG); + + await clearPlatform(printer); + }); + + test('an upload without spool choices is not tracked', async () => { + await sidecar.reset(); + await upload({ spoolAssignments: undefined }); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS * 2)); + expect(await sidecar.requests()).toHaveLength(0); + + await clearPlatform(printer); + }); + + test('a file sent without Start Now and started on the printer is not tracked', async () => { + await sidecar.reset(); + const fileName = await upload({ spoolAssignments: BOTH_SPOOLS, startNow: false }); + await startOnPrinter(printer, fileName); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS * 2)); + expect(await sidecar.requests()).toHaveLength(0); + + await clearPlatform(printer); + }); + + test('a reprint of a tracked file started on the printer charges nothing', async () => { + await sidecar.reset(); + const fileName = await upload({ spoolAssignments: BOTH_SPOOLS }); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(2); + await clearPlatform(printer); + + await sidecar.reset(); + await startOnPrinter(printer, fileName); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS * 2)); + expect(await sidecar.requests()).toHaveLength(0); + + await clearPlatform(printer); + }); +}); + +// ============================================================================ +// Other station profiles +// ============================================================================ + +test.describe('Spoolman per-job tracking (Creator 5 Pro + AD5X)', () => { + test.skip(!sidecarAvailable, SPOOLMAN_SIDECAR_SKIP_MESSAGE); + test.describe.configure({ mode: 'serial' }); + + const printers: StandalonePrinter[] = [ + PRINTER_BY_MACHINE_NAME['Matrix-Creator5Pro'], + PRINTER_BY_MACHINE_NAME['Matrix-AD5X'], + ]; + let sidecar: SpoolmanSidecar; + let webui: StandaloneWebUI; + let token = ''; + + test.beforeAll(async () => { + ({ sidecar, webui, token } = await startSuite(printers)); + }); + + test.afterAll(async () => { + await webui?.stop(); + await sidecar?.stop(); + }); + + for (const printer of printers) { + test(`two-tool completion charges both spools (${printer.label})`, async () => { + await sidecar.reset(); + const contextId = await resolveContextId(webui, token, printer.machineName); + await uploadThroughApp(webui, token, contextId, { spoolAssignments: BOTH_SPOOLS }); + + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(2); + const charges = await chargesBySpool(sidecar); + expectCharge(charges, SPOOL_A, TOOL_0.usedG); + expectCharge(charges, SPOOL_B, TOOL_2.usedG); + + await clearPlatform(printer); + }); + } +}); + +// ============================================================================ +// AD5X stored files — started through the matching dialog +// ============================================================================ + +test.describe('Spoolman per-job tracking (AD5X stored files)', () => { + test.skip(!sidecarAvailable, SPOOLMAN_SIDECAR_SKIP_MESSAGE); + test.describe.configure({ mode: 'serial' }); + + const printer: StandalonePrinter = PRINTER_BY_MACHINE_NAME['Matrix-AD5X']; + let sidecar: SpoolmanSidecar; + let webui: StandaloneWebUI; + let token = ''; + let contextId = ''; + let storedFile = ''; + + test.beforeAll(async () => { + ({ sidecar, webui, token } = await startSuite([printer])); + contextId = await resolveContextId(webui, token, printer.machineName); + // Put the file on the printer without starting it. The emulator reports + // the slicer's per-tool weights for it, as AD5X firmware does. + storedFile = await uploadThroughApp(webui, token, contextId, { startNow: false }); + }); + + test.afterAll(async () => { + await webui?.stop(); + await sidecar?.stop(); + }); + + const startStored = async (spoolAssignments?: ReadonlyArray<{ toolId: number; spoolId: number | null }>) => { + const { payload } = await postJson<{ success?: boolean; error?: string }>( + webui, + token, + `/api/jobs/start?contextId=${contextId}`, + { filename: storedFile, startNow: true, leveling: false, materialMappings: MAPPINGS, spoolAssignments } + ); + expect(payload.error).toBeUndefined(); + expect(payload.success).toBe(true); + }; + + test('completion charges the printer-reported weight of each tool', async () => { + await sidecar.reset(); + await startStored(BOTH_SPOOLS); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(2); + const charges = await chargesBySpool(sidecar); + expectCharge(charges, SPOOL_A, TOOL_0.usedG); + expectCharge(charges, SPOOL_B, TOOL_2.usedG); + + await clearPlatform(printer); + }); + + test('cancel charges linearly by progress (no gcode on hand)', async () => { + await sidecar.reset(); + await startStored(BOTH_SPOOLS); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 40 }); + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS)); + + const { payload } = await postJson<{ error?: string }>( + webui, + token, + `/api/printer/control/cancel?contextId=${contextId}`, + {} + ); + expect(payload.error).toBeUndefined(); + + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(2); + const charges = await chargesBySpool(sidecar); + expectCharge(charges, SPOOL_A, TOOL_0.usedG * 0.4); + expectCharge(charges, SPOOL_B, TOOL_2.usedG * 0.4); + + await clearPlatform(printer); + }); + + test('a stored-file start without spool choices is not tracked', async () => { + await sidecar.reset(); + await startStored(undefined); + await driveToPrinting(printer); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS * 2)); + expect(await sidecar.requests()).toHaveLength(0); + + await clearPlatform(printer); + }); +}); + +// ============================================================================ +// 5M Pro — the single-spool flow is unchanged +// ============================================================================ + +test.describe('Spoolman single-tool regression (Adventurer 5M Pro)', () => { + test.skip(!sidecarAvailable, SPOOLMAN_SIDECAR_SKIP_MESSAGE); + test.describe.configure({ mode: 'serial' }); + + const printer: StandalonePrinter = PRINTER_BY_MACHINE_NAME['Matrix-5MPro']; + let sidecar: SpoolmanSidecar; + let webui: StandaloneWebUI; + let token = ''; + + test.beforeAll(async () => { + ({ sidecar, webui, token } = await startSuite([printer])); + }); + + test.afterAll(async () => { + await webui?.stop(); + await sidecar?.stop(); + }); + + test('single-tool completion deducts exactly once on the active spool', async () => { + await sidecar.reset(); + const contextId = await resolveContextId(webui, token, printer.machineName); + + const { payload: select } = await postJson<{ success?: boolean; error?: string }>( + webui, + token, + '/api/spoolman/select', + { contextId, spoolId: 3 } + ); + expect(select.error).toBeUndefined(); + + const { payload: stage } = await postRaw( + webui, + token, + `/api/jobs/upload/stage?contextId=${contextId}&filename=${encodeURIComponent(SINGLE_TOOL_GCODE)}`, + await fs.readFile(path.join(FIXTURES_DIR, SINGLE_TOOL_GCODE)) + ); + expect(stage.error).toBeUndefined(); + const { payload: start } = await postJson(webui, token, `/api/jobs/upload/start?contextId=${contextId}`, { + uploadId: stage.uploadId, + startNow: true, + autoLevel: false, + }); + expect(start.error).toBeUndefined(); + + await expect.poll(async () => emulatorStatus(printer), { timeout: 60_000 }).toBe('printing'); + await emulatorSimulate(printer, { action: 'pause' }); + // Pin a known progress so the dual-API backend's filament-usage cache + // captures a deterministic value (96 g × progress while printing). + await emulatorSimulate(printer, { action: 'jump', percent: 50 }); + await new Promise((resolve) => setTimeout(resolve, POLL_CYCLE_MS)); + await emulatorSimulate(printer, { action: 'jump', percent: 100 }); + + await expect.poll(async () => (await sidecar.requests()).length, { timeout: 30_000 }).toBe(1); + const requests = await sidecar.requests(); + expect(requests[0]?.spoolId).toBe(3); + expect(Math.abs((requests[0]?.useWeight ?? 0) - EMULATOR_HALF_WEIGHT_G)).toBeLessThanOrEqual(G_TOLERANCE); + }); +}); diff --git a/tests/e2e/browser/webui-temperature-heaters.spec.ts b/tests/e2e/browser/webui-temperature-heaters.spec.ts new file mode 100644 index 0000000..efe149a --- /dev/null +++ b/tests/e2e/browser/webui-temperature-heaters.spec.ts @@ -0,0 +1,216 @@ +/** + * @fileoverview End-to-end coverage for the bed/extruder heater routes across every model. + * + * The four `/api/printer/temperature/{bed,extruder}[/off]` routes used to send + * raw G-code (`~M140` / `~M104`) over TCP, which hard-fails on the HTTP-only + * Creator 5 series (no TCP channel). They now route through backend-manager + * temperature commands that branch on client presence: dual-API printers keep + * the legacy TCP G-code channel, HTTP-only printers use the HTTP + * temperature-control API. + * + * This spec pins both halves of that contract against real emulated printers + * driven through the real server, looping the full model matrix so every + * printer's heater path is exercised: a set/cancel cycle per model asserting the + * targets actually change in each emulator's own /detail state, plus the + * Creator 5 series nozzles-array contract. + */ + +import { expect, test } from '@playwright/test'; +import { fetchApiToken, postJson, readEmulatorDetail, resolveContextId } from './helpers/api'; +import { type StandaloneWebUI, startStandaloneWebUI } from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +interface CommandPayload { + success?: boolean; + error?: string; +} + +const postHeaterCommand = async ( + webui: StandaloneWebUI, + token: string, + contextId: string, + endpoint: string, + body?: Record +): Promise<{ status: number; payload: CommandPayload }> => + await postJson( + webui, + token, + `/api/printer/temperature/${endpoint}?contextId=${contextId}`, + body + ); + +test.describe('WebUI bed/extruder heater routes', () => { + let webui: StandaloneWebUI; + /** Fetched once per suite; the login rate limiter budgets 5 attempts / 15 min. */ + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + for (const target of MODEL_TARGETS) { + const printer = target.printer; + + test(`sets and cancels bed and extruder targets on ${printer.machineName}`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Bed on: the emulator's own /detail must report the new target. + const bedResult = await postHeaterCommand(webui, token, contextId, 'bed', { + temperature: 60, + }); + expect(bedResult.status, `bed set failed: ${bedResult.payload.error}`).toBe(200); + expect(bedResult.payload.success).toBe(true); + await expect.poll(async () => (await readEmulatorDetail(printer)).platTargetTemp).toBe(60); + + // Bed off: target returns to 0. + const bedOffResult = await postHeaterCommand(webui, token, contextId, 'bed/off'); + expect(bedOffResult.status, `bed off failed: ${bedOffResult.payload.error}`).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).platTargetTemp).toBe(0); + + // Extruder on: /detail's rightTemp alias mirrors tool 0 on every model. + const extruderResult = await postHeaterCommand(webui, token, contextId, 'extruder', { + temperature: 215, + }); + expect(extruderResult.status, `extruder set failed: ${extruderResult.payload.error}`).toBe( + 200 + ); + expect(extruderResult.payload.success).toBe(true); + await expect.poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp).toBe(215); + + // Extruder off: target returns to 0. + const extruderOffResult = await postHeaterCommand(webui, token, contextId, 'extruder/off'); + expect( + extruderOffResult.status, + `extruder off failed: ${extruderOffResult.payload.error}` + ).toBe(200); + await expect.poll(async () => (await readEmulatorDetail(printer)).rightTargetTemp).toBe(0); + }); + } + + for (const target of MODEL_TARGETS.filter((candidate) => candidate.isCreator5Series)) { + const printer = target.printer; + + test(`drives the ${printer.machineName} primary tool through the nozzles array`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + const result = await postHeaterCommand(webui, token, contextId, 'extruder', { + temperature: 205, + }); + expect(result.status).toBe(200); + + const detail = await readEmulatorDetail(printer); + // The Creator 5 series firmware only honors the 4-entry nozzles array for + // tool control; the HTTP path must land there, not in a legacy scalar field. + expect(detail.nozzleTargetTemps?.[0]).toBe(205); + expect(detail.nozzleTargetTemps?.slice(1)).toEqual([0, 0, 0]); + }); + } + + // --------------------------------------------------------------------------- + // Creator 5 series per-tool heater routes + // --------------------------------------------------------------------------- + // The /tool/:index routes exist specifically for the C5 wire format (fixed + // 4-entry nozzles array, no G-code passthrough). Pin that only the addressed + // entry moves and the rest of the array is untouched — a legacy-scalar + // payload would silently no-op here, which is exactly the bug class the + // HTTP fallback fix closed. + + for (const target of MODEL_TARGETS.filter((candidate) => candidate.isCreator5Series)) { + const printer = target.printer; + + test(`sets and cancels a secondary tool target on ${printer.machineName}`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Baseline captured first: tests share one emulator instance, so this + // must be order-independent (earlier tests may leave T0 at a target). + const before = await readEmulatorDetail(printer); + const baseline = before.nozzleTargetTemps ?? [0, 0, 0, 0]; + + const toolResult = await postHeaterCommand(webui, token, contextId, 'tool/1', { + temperature: 210, + }); + expect(toolResult.status, `tool 1 set failed: ${toolResult.payload.error}`).toBe(200); + expect(toolResult.payload.success).toBe(true); + await expect + .poll(async () => (await readEmulatorDetail(printer)).nozzleTargetTemps?.[1]) + .toBe(210); + + // Only the addressed slot changed; every other entry keeps its prior target. + const during = await readEmulatorDetail(printer); + expect(during.nozzleTargetTemps?.[0]).toBe(baseline[0]); + expect(during.nozzleTargetTemps?.slice(2)).toEqual(baseline.slice(2)); + + const offResult = await postHeaterCommand(webui, token, contextId, 'tool/1/off'); + expect(offResult.status, `tool 1 off failed: ${offResult.payload.error}`).toBe(200); + await expect + .poll(async () => (await readEmulatorDetail(printer)).nozzleTargetTemps?.[1]) + .toBe(0); + }); + } + + // --------------------------------------------------------------------------- + // Creator 5 Pro chamber heater (only model with a chamber) + // --------------------------------------------------------------------------- + + for (const target of MODEL_TARGETS.filter((c) => c.printer.machineName === 'Matrix-Creator5Pro')) { + const printer = target.printer; + + test(`sets, clamps, and cancels the chamber target on ${printer.machineName}`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + const setResult = await postHeaterCommand(webui, token, contextId, 'chamber', { + temperature: 60, + }); + expect(setResult.status, `chamber set failed: ${setResult.payload.error}`).toBe(200); + await expect + .poll(async () => (await readEmulatorDetail(printer)).chamberTargetTemp) + .toBe(60); + + // 100 °C must clamp to the firmware ceiling (80) — asserted at the + // emulator's own state, so both the WebUI clamp and the wire behavior + // are pinned. + const clampResult = await postHeaterCommand(webui, token, contextId, 'chamber', { + temperature: 100, + }); + expect(clampResult.status, `chamber clamp failed: ${clampResult.payload.error}`).toBe(200); + await expect + .poll(async () => (await readEmulatorDetail(printer)).chamberTargetTemp) + .toBe(80); + + const offResult = await postHeaterCommand(webui, token, contextId, 'chamber/off'); + expect(offResult.status, `chamber off failed: ${offResult.payload.error}`).toBe(200); + await expect + .poll(async () => (await readEmulatorDetail(printer)).chamberTargetTemp) + .toBe(0); + }); + } + + // --------------------------------------------------------------------------- + // Base Creator 5: chamber command must be a documented silent no-op + // --------------------------------------------------------------------------- + + for (const target of MODEL_TARGETS.filter((c) => c.printer.machineName === 'Matrix-Creator5')) { + const printer = target.printer; + + test(`chamber command is silently ACKed and does nothing on ${printer.machineName}`, async () => { + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Firmware silently ACKs {code:0} for the chamber on the base C5 (no + // chamber heater). The API must therefore succeed — but /detail must + // keep reporting the no-chamber sentinel (-108 per the endpoint docs). + const result = await postHeaterCommand(webui, token, contextId, 'chamber', { + temperature: 60, + }); + expect(result.status).toBe(200); + expect(result.payload.success).toBe(true); + + const after = await readEmulatorDetail(printer); + expect(after.chamberTargetTemp).toBe(-108); + }); + } +}); diff --git a/tests/e2e/browser/webui-upload.spec.ts b/tests/e2e/browser/webui-upload.spec.ts new file mode 100644 index 0000000..6f601a6 --- /dev/null +++ b/tests/e2e/browser/webui-upload.spec.ts @@ -0,0 +1,287 @@ +/** + * @fileoverview Job upload coverage across every model, mirroring FlashForgeUI-Electron's + * upload spec: an API-level three-call upload per model plus the browser-driven + * dialog behaviour (material matching raised only where the printer has a station). + * + * This is the regression net for the alpha.10 hotfix class of bug: a confirm + * handler that throws or silently skips the upload still leaves the dialog looking + * successful - so every upload test confirms the file actually landed on the + * printer (recent-jobs list = the printer's /gcodeList underneath) and that no + * print started despite Start Now being unchecked. + * + * Material matching rules under test, identical to FlashForgeUI-Electron's: + * - any 3MF + material-station printer (AD5X, Creator 5, Creator 5 Pro) -> dialog + * appears, mapping required, single tool included + * - multi-filament file + 5M-series printer -> dialog must NOT appear + * + * Note the material-station capability comes from the test matrix, never from the + * printer: stock Creator 5 firmware no longer reports the station in /detail, but + * it still has one and still requires mappings on a fresh upload. + */ + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { + fetchApiToken, + openWithRememberedToken, + postJson, + postRaw, + readEmulatorDetail, + resolveContextId, + switchPrinterInUi, +} from './helpers/api'; +import { + type StandalonePrinter, + type StandaloneWebUI, + startStandaloneWebUI, +} from './helpers/standalone-server'; +import { MATRIX_PRINTERS, MODEL_TARGETS } from './helpers/targets'; + +interface StagePayload { + success?: boolean; + uploadId?: string; + error?: string; + requiresMaterialMatching?: boolean; +} + +interface StartPayload { + success?: boolean; + started?: boolean; + fileName?: string; + error?: string; +} + +interface FilesPayload { + success?: boolean; + files?: Array<{ fileName?: string; name?: string }>; + error?: string; +} + +const FIXTURES_DIR = path.resolve('tests/fixtures/print-files'); +const GCODE_FIXTURE = 'adventurer5m-single-color.gcode'; +const THREE_MF_FIXTURE = 'ad5x-single-tool.3mf'; + +/** Machine statuses that mean material is actively being laid down or prepared. */ +const ACTIVE_PRINT_STATUSES: readonly string[] = [ + 'printing', + 'heating', + 'calibrating', + 'pausing', + 'paused', +]; + +/** + * Post-upload safety gate, mirroring FlashForgeUI-Electron's assertNotPrinting: + * give the printer a moment to react (an auto-start would have begun by now), + * then read the emulator's own /detail and fail loudly if a print ever started. + * The emulator instance is torn down by the suite, so no recovery path is needed. + */ +const assertNotPrinting = async (printer: StandalonePrinter): Promise => { + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + const detail = await readEmulatorDetail(printer); + const status = (detail.status ?? '').toLowerCase(); + const active = ACTIVE_PRINT_STATUSES.find((candidate) => status.includes(candidate)); + expect( + active, + `printer entered "${detail.status}" after an upload with Start Now unchecked` + ).toBeUndefined(); +}; + +test.describe('WebUI job upload', () => { + let webui: StandaloneWebUI; + let token: string; + + test.beforeAll(async () => { + webui = await startStandaloneWebUI({ printers: MATRIX_PRINTERS }); + token = await fetchApiToken(webui); + }); + + test.afterAll(async () => { + await webui?.stop(); + }); + + const stageFile = async (contextId: string, fileName: string) => { + const bytes = await readFile(path.join(FIXTURES_DIR, fileName)); + return await postRaw( + webui, + token, + `/api/jobs/upload/stage?contextId=${contextId}&filename=${encodeURIComponent(fileName)}`, + bytes + ); + }; + + const fetchRecentJobs = async (contextId: string): Promise => { + const response = await fetch(`${webui.baseUrl}/api/jobs/recent?contextId=${contextId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + return (await response.json()) as FilesPayload; + }; + + const expectFileOnPrinter = async ( + contextId: string, + printer: StandalonePrinter, + fixture: string + ) => { + await expect + .poll( + async () => { + const recent = await fetchRecentJobs(contextId); + return recent.files?.some((file) => (file.fileName ?? file.name) === fixture) ?? false; + }, + { timeout: 15_000 } + ) + .toBe(true); + await assertNotPrinting(printer); + }; + + for (const target of MODEL_TARGETS) { + const printer = target.printer; + const fixture = target.hasMaterialStation ? THREE_MF_FIXTURE : GCODE_FIXTURE; + + test(`uploads a job file through the dialog on ${printer.machineName} without starting a print`, async ({ + page, + }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, printer.machineName); + + await page.click('#btn-upload-job'); + const modal = page.locator('#job-upload-modal'); + await expect(modal).toBeVisible(); + + await page.locator('#job-upload-file-input').setInputFiles(path.join(FIXTURES_DIR, fixture)); + + if (target.hasMaterialStation) { + const matching = page.locator('#material-matching-modal'); + await expect( + matching, + 'material-station printers must raise the matching dialog' + ).toBeVisible(); + + // One requirement row per filament the slicer recorded. + await expect(matching.locator('.material-tool-item')).toHaveCount(1); + + // Assign the single tool to the first available loaded slot and confirm. + await matching.locator('.material-tool-item[data-tool-id="0"]').click(); + await matching.locator('.material-slot-item:not(.empty):not(.disabled)').first().click(); + await expect(matching.locator('#material-matching-confirm')).toBeEnabled(); + await matching.locator('#material-matching-confirm').click(); + } + + await page.locator('#job-upload-start-now').uncheck(); + const okButton = page.locator('#job-upload-ok'); + await expect(okButton, 'OK should be enabled once the job is ready').toBeEnabled(); + await okButton.click(); + + await expect(modal, 'the uploader must close once the upload finishes').toBeHidden({ + timeout: 30_000, + }); + + const contextId = await resolveContextId(webui, token, printer.machineName); + await expectFileOnPrinter(contextId, printer, fixture); + }); + + test(`raises the material matching dialog on ${printer.machineName} only for a station printer`, async ({ + page, + }) => { + await openWithRememberedToken(page, webui, token); + await switchPrinterInUi(page, webui, token, printer.machineName); + + await page.click('#btn-upload-job'); + const modal = page.locator('#job-upload-modal'); + await expect(modal).toBeVisible(); + + // Same 3MF on every model: only station printers may raise matching. + await page + .locator('#job-upload-file-input') + .setInputFiles(path.join(FIXTURES_DIR, THREE_MF_FIXTURE)); + + const matching = page.locator('#material-matching-modal'); + if (target.hasMaterialStation) { + await expect( + matching, + 'AD5X / Creator 5 should raise the material matching dialog' + ).toBeVisible(); + await expect(matching.locator('.material-tool-item')).toHaveCount(1); + await matching.locator('#material-matching-cancel').click(); + } else { + // 5M-series printers have no material station, so mapping tools to slots + // is meaningless and the dialog must never appear. Bounded wait mirrors + // FlashForgeUI-Electron's findOptional(10s). + await page.waitForTimeout(10_000); + await expect( + matching, + '5M-series printers should not raise the material matching dialog' + ).toBeHidden(); + } + + await modal.locator('#job-upload-cancel').click(); + await expect(modal).toBeHidden(); + }); + } + + test('uploads through the API without starting a print (all models)', async () => { + for (const target of MODEL_TARGETS) { + const printer = target.printer; + const fixture = target.hasMaterialStation ? THREE_MF_FIXTURE : GCODE_FIXTURE; + const contextId = await resolveContextId(webui, token, printer.machineName); + + // Material-station firmware only accepts 3MF; the gate must refuse gcode. + if (target.hasMaterialStation) { + const gcodeStage = await stageFile(contextId, GCODE_FIXTURE); + expect(gcodeStage.status, 'gcode must be refused on material-station models').toBe(400); + } + + const stage = await stageFile(contextId, fixture); + expect(stage.status, `staging failed: ${stage.payload.error}`).toBe(200); + expect(stage.payload.success).toBe(true); + expect(stage.payload.uploadId, 'staging must return an upload handle').toBeTruthy(); + expect(stage.payload.requiresMaterialMatching).toBe(target.hasMaterialStation); + + // Material-station uploads carry a mapping for the single tool, exactly as + // the matching dialog would (the AD5X ff-api path hard-rejects empty lists). + const materialMappings = target.hasMaterialStation + ? [ + { + toolId: 0, + slotId: 1, + materialName: 'PLA', + toolMaterialColor: '#FFFFFF', + slotMaterialColor: '#FFFFFF', + }, + ] + : undefined; + + const start = await postJson( + webui, + token, + `/api/jobs/upload/start?contextId=${contextId}`, + { uploadId: stage.payload.uploadId, startNow: false, materialMappings } + ); + expect(start.status, `upload failed: ${start.payload.error}`).toBe(200); + expect(start.payload.success).toBe(true); + expect(start.payload.started, 'startNow: false must not start the print').toBe(false); + + await expectFileOnPrinter(contextId, printer, fixture); + } + }); + + test('refuses a staged file name that is not a bare job file name', async () => { + const target = MODEL_TARGETS[0]; + if (!target) { + throw new Error('MODEL_TARGETS must not be empty'); + } + const contextId = await resolveContextId(webui, token, target.printer.machineName); + + // Deliberately no fixture read: the filename must be rejected before anything + // touches the filesystem, so any bytes prove the point. + const stage = await postRaw( + webui, + token, + `/api/jobs/upload/stage?contextId=${contextId}&filename=${encodeURIComponent('../escape.gcode')}`, + Buffer.from('placeholder bytes') + ); + expect(stage.status).toBe(400); + }); +}); diff --git a/tests/fixtures/print-files/ad5x-single-tool.3mf b/tests/fixtures/print-files/ad5x-single-tool.3mf new file mode 100644 index 0000000..3c3d62a Binary files /dev/null and b/tests/fixtures/print-files/ad5x-single-tool.3mf differ diff --git a/tests/fixtures/print-files/adventurer5m-single-color.gcode b/tests/fixtures/print-files/adventurer5m-single-color.gcode new file mode 100644 index 0000000..9179925 --- /dev/null +++ b/tests/fixtures/print-files/adventurer5m-single-color.gcode @@ -0,0 +1,21227 @@ +; HEADER_BLOCK_START +; generated by Orca-Flashforge 1.3.0 on 2025-05-04 at 17:53:29 +; total layer number: 10 +; filament_density: 1.24 +; filament_diameter: 1.75 +; max_z_height: 2.00 +; HEADER_BLOCK_END + +; filament used [mm] = 1219.86 +; filament used [cm3] = 2.93 +; filament used [g] = 3.64 +; filament cost = 0.07 +; total filament used [g] = 3.64 +; total filament cost = 0.07 +; total layers count = 10 +; estimated printing time (normal mode) = 7m 13s + +; CONFIG_BLOCK_START +; accel_to_decel_enable = 0 +; accel_to_decel_factor = 50% +; activate_air_filtration = 0 +; activate_chamber_temp_control = 0 +; adaptive_bed_mesh_margin = 0 +; adaptive_pressure_advance = 0 +; adaptive_pressure_advance_bridges = 0 +; adaptive_pressure_advance_model = "0,0,0\n0,0,0" +; adaptive_pressure_advance_overhangs = 0 +; additional_cooling_fan_speed = 100 +; alternate_extra_wall = 0 +; auxiliary_fan = 1 +; bbl_calib_mark_logo = 1 +; bbl_use_printhost = 0 +; bed_custom_model = +; bed_custom_texture = +; bed_exclude_area = 0x0 +; bed_mesh_max = 99999,99999 +; bed_mesh_min = -99999,-99999 +; bed_mesh_probe_distance = 50,50 +; before_layer_change_gcode = ;BEFORE_LAYER_CHANGE\n;[layer_z] +; best_object_pos = 0.5,0.5 +; bottom_shell_layers = 3 +; bottom_shell_thickness = 0 +; bottom_solid_infill_flow_ratio = 1 +; bottom_surface_pattern = monotonic +; bridge_acceleration = 50% +; bridge_angle = 0 +; bridge_density = 100% +; bridge_flow = 1 +; bridge_no_support = 0 +; bridge_speed = 25 +; brim_ears_detection_length = 1 +; brim_ears_max_angle = 125 +; brim_object_gap = 0.1 +; brim_type = auto_brim +; brim_width = 5 +; chamber_temperature = 0 +; change_extrusion_role_gcode = +; change_filament_gcode = M25 +; close_fan_the_first_x_layers = 1 +; complete_print_exhaust_fan_speed = 80 +; cool_plate_temp = 60 +; cool_plate_temp_initial_layer = 55 +; cooling_tube_length = 5 +; cooling_tube_retraction = 91.5 +; counterbore_hole_bridging = none +; curr_bed_type = High Temp Plate +; default_acceleration = 10000 +; default_filament_colour = "" +; default_filament_profile = "Flashforge Generic PLA" +; default_jerk = 0 +; default_print_profile = 0.20mm Standard @Flashforge AD5M Pro 0.4 Nozzle +; deretraction_speed = 35 +; detect_narrow_internal_solid_infill = 1 +; detect_overhang_wall = 1 +; detect_thin_wall = 0 +; different_settings_to_system = brim_type;enable_arc_fitting;wall_generator;;change_filament_gcode;manual_filament_change;single_extruder_multi_material +; disable_m73 = 0 +; dont_filter_internal_bridges = disabled +; dont_slow_down_outer_wall = 0 +; draft_shield = disabled +; during_print_exhaust_fan_speed = 60 +; elefant_foot_compensation = 0.15 +; elefant_foot_compensation_layers = 1 +; emit_machine_limits_to_gcode = 1 +; enable_arc_fitting = 0 +; enable_filament_ramming = 1 +; enable_long_retraction_when_cut = 0 +; enable_overhang_bridge_fan = 1 +; enable_overhang_speed = 1 +; enable_pressure_advance = 1 +; enable_prime_tower = 0 +; enable_support = 0 +; enforce_support_layers = 0 +; eng_plate_temp = 60 +; eng_plate_temp_initial_layer = 55 +; ensure_vertical_shell_thickness = ensure_all +; exclude_object = 0 +; extra_loading_move = -2 +; extra_perimeters_on_overhangs = 0 +; extruder_clearance_height_to_lid = 150 +; extruder_clearance_height_to_rod = 27 +; extruder_clearance_radius = 76 +; extruder_colour = #C0C0C0 +; extruder_offset = 0x0 +; fan_cooling_layer_time = 100 +; fan_kickstart = 0 +; fan_max_speed = 100 +; fan_min_speed = 100 +; fan_speedup_overhangs = 1 +; fan_speedup_time = 0 +; filament_colour = #C0C0C0 +; filament_cooling_final_speed = 3.4 +; filament_cooling_initial_speed = 2.2 +; filament_cooling_moves = 4 +; filament_cost = 20 +; filament_density = 1.24 +; filament_diameter = 1.75 +; filament_end_gcode = "; filament end gcode\n" +; filament_flow_ratio = 0.98 +; filament_ids = GFL99 +; filament_is_support = 0 +; filament_loading_speed = 28 +; filament_loading_speed_start = 3 +; filament_max_volumetric_speed = 25 +; filament_minimal_purge_on_wipe_tower = 15 +; filament_multitool_ramming = 0 +; filament_multitool_ramming_flow = 10 +; filament_multitool_ramming_volume = 10 +; filament_notes = "" +; filament_ramming_parameters = "120 100 6.6 6.8 7.2 7.6 7.9 8.2 8.7 9.4 9.9 10.0| 0.05 6.6 0.45 6.8 0.95 7.8 1.45 8.3 1.95 9.7 2.45 10 2.95 7.6 3.45 7.6 3.95 7.6 4.45 7.6 4.95 7.6" +; filament_settings_id = "Flashforge HS PLA" +; filament_shrink = 100% +; filament_shrinkage_compensation_z = 100% +; filament_soluble = 0 +; filament_stamping_distance = 0 +; filament_stamping_loading_speed = 0 +; filament_start_gcode = "; filament start gcode\n;right_extruder_material: PLA\n" +; filament_toolchange_delay = 0 +; filament_type = PLA +; filament_unloading_speed = 90 +; filament_unloading_speed_start = 100 +; filament_vendor = Generic +; filename_format = {input_filename_base}.gcode +; filter_out_gap_fill = 0.5 +; first_layer_print_sequence = 0 +; flush_into_infill = 0 +; flush_into_objects = 0 +; flush_into_support = 1 +; flush_multiplier = 1 +; flush_volumes_matrix = 0 +; flush_volumes_vector = 140,140 +; full_fan_speed_layer = 0 +; fuzzy_skin = none +; fuzzy_skin_first_layer = 0 +; fuzzy_skin_point_distance = 0.8 +; fuzzy_skin_thickness = 0.3 +; gap_fill_target = nowhere +; gap_infill_speed = 200 +; gcode_add_line_number = 0 +; gcode_comments = 0 +; gcode_flavor = klipper +; gcode_label_objects = 0 +; has_scarf_joint_seam = 0 +; head_wrap_detect_zone = +; high_current_on_filament_swap = 0 +; hole_to_polyhole = 0 +; hole_to_polyhole_threshold = 0.01 +; hole_to_polyhole_twisted = 1 +; host_type = octoprint +; hot_plate_temp = 50 +; hot_plate_temp_initial_layer = 55 +; idle_temperature = 0 +; independent_support_layer_height = 1 +; infill_anchor = 400% +; infill_anchor_max = 20 +; infill_combination = 0 +; infill_combination_max_layer_height = 100% +; infill_direction = 45 +; infill_jerk = 9 +; infill_wall_overlap = 50% +; inherits_group = "0.20mm Standard @Flashforge AD5M Pro 0.4 Nozzle";;"Flashforge Adventurer 5M Pro 0.4 Nozzle" +; initial_layer_acceleration = 500 +; initial_layer_infill_speed = 80 +; initial_layer_jerk = 9 +; initial_layer_line_width = 0.5 +; initial_layer_min_bead_width = 85% +; initial_layer_print_height = 0.2 +; initial_layer_speed = 50 +; initial_layer_travel_speed = 100% +; inner_wall_acceleration = 5000 +; inner_wall_jerk = 9 +; inner_wall_line_width = 0.45 +; inner_wall_speed = 300 +; interface_shells = 0 +; interlocking_beam = 0 +; interlocking_beam_layer_count = 2 +; interlocking_beam_width = 0.8 +; interlocking_boundary_avoidance = 2 +; interlocking_depth = 2 +; interlocking_orientation = 22.5 +; internal_bridge_flow = 1 +; internal_bridge_speed = 50 +; internal_solid_infill_acceleration = 7000 +; internal_solid_infill_line_width = 0.42 +; internal_solid_infill_pattern = monotonic +; internal_solid_infill_speed = 250 +; ironing_angle = -1 +; ironing_flow = 15% +; ironing_pattern = zig-zag +; ironing_spacing = 0.1 +; ironing_speed = 15 +; ironing_type = no ironing +; is_infill_first = 0 +; layer_change_gcode = ;AFTER_LAYER_CHANGE\n;[layer_z] +; layer_height = 0.2 +; line_width = 0.42 +; long_retractions_when_cut = 0 +; machine_end_gcode = G1 E-3 F3600\nG0 X50 Y50 F30000\nM104 S0 ; turn off temperature +; machine_load_filament_time = 0 +; machine_max_acceleration_e = 5000,5000 +; machine_max_acceleration_extruding = 20000,20000 +; machine_max_acceleration_retracting = 5000,5000 +; machine_max_acceleration_travel = 20000,20000 +; machine_max_acceleration_x = 20000,20000 +; machine_max_acceleration_y = 20000,20000 +; machine_max_acceleration_z = 500,500 +; machine_max_jerk_e = 2.5,2.5 +; machine_max_jerk_x = 9,9 +; machine_max_jerk_y = 9,9 +; machine_max_jerk_z = 3,3 +; machine_max_speed_e = 30,30 +; machine_max_speed_x = 600,600 +; machine_max_speed_y = 600,600 +; machine_max_speed_z = 20,20 +; machine_min_extruding_rate = 0,0 +; machine_min_travel_rate = 0,0 +; machine_pause_gcode = M25 +; machine_start_gcode = M190 S[bed_temperature_initial_layer_single]\nM104 S[nozzle_temperature_initial_layer]\nG90\nM83\nG1 Z5 F6000\nG1 E-0.2 F800\nG1 X110 Y-110 F6000\nG1 E2 F800\nG1 Y-110 X55 Z0.25 F4800\nG1 X-55 E8 F2400\nG1 Y-109.6 F2400\nG1 X55 E5 F2400\nG1 Y-110 X55 Z0.45 F4800\nG1 X-55 E8 F2400\nG1 Y-109.6 F2400\nG1 X55 E5 F2400\nG92 E0 +; machine_tool_change_time = 0 +; machine_unload_filament_time = 0 +; make_overhang_printable = 0 +; make_overhang_printable_angle = 55 +; make_overhang_printable_hole_size = 0 +; manual_filament_change = 1 +; max_bridge_length = 10 +; max_layer_height = 0.28 +; max_travel_detour_distance = 0 +; max_volumetric_extrusion_rate_slope = 0 +; max_volumetric_extrusion_rate_slope_segment_length = 3 +; min_bead_width = 85% +; min_feature_size = 25% +; min_layer_height = 0.08 +; min_length_factor = 0.5 +; min_skirt_length = 0 +; min_width_top_surface = 300% +; minimum_sparse_infill_area = 15 +; mmu_segmented_region_interlocking_depth = 0 +; mmu_segmented_region_max_width = 0 +; notes = +; nozzle_diameter = 0.4 +; nozzle_height = 2.5 +; nozzle_hrc = 0 +; nozzle_temperature = 220 +; nozzle_temperature_initial_layer = 220 +; nozzle_temperature_range_high = 230 +; nozzle_temperature_range_low = 190 +; nozzle_type = stainless_steel +; nozzle_volume = 0 +; only_one_wall_first_layer = 0 +; only_one_wall_top = 0 +; ooze_prevention = 0 +; other_layers_print_sequence = 0 +; other_layers_print_sequence_nums = 0 +; outer_wall_acceleration = 5000 +; outer_wall_jerk = 9 +; outer_wall_line_width = 0.42 +; outer_wall_speed = 200 +; overhang_1_4_speed = 0 +; overhang_2_4_speed = 40 +; overhang_3_4_speed = 20 +; overhang_4_4_speed = 10 +; overhang_fan_speed = 100 +; overhang_fan_threshold = 50% +; overhang_reverse = 0 +; overhang_reverse_internal_only = 0 +; overhang_reverse_threshold = 50% +; overhang_speed_classic = 0 +; parking_pos_retraction = 92 +; pellet_flow_coefficient = 0.4157 +; pellet_modded_printer = 0 +; post_process = +; precise_outer_wall = 0 +; precise_z_height = 0 +; preferred_orientation = 0 +; preheat_steps = 1 +; preheat_time = 30 +; pressure_advance = 0.025 +; prime_tower_brim_width = 3 +; prime_tower_width = 60 +; prime_volume = 45 +; print_compatible_printers = "Flashforge Adventurer 5M Pro 0.4 Nozzle" +; print_flow_ratio = 1 +; print_order = default +; print_sequence = by layer +; print_settings_id = 0.20mm 0.4 Nozzle Default +; printable_area = -110x-110,110x-110,110x110,-110x110 +; printable_height = 220 +; printer_model = Flashforge Adventurer 5M Pro +; printer_notes = +; printer_settings_id = Flashforge Adventurer 5M Pro 0.4 MultiColor +; printer_structure = undefine +; printer_technology = FFF +; printer_variant = 0.4 +; printhost_authorization_type = key +; printhost_ssl_ignore_revoke = 0 +; printing_by_object_gcode = +; purge_in_prime_tower = 1 +; raft_contact_distance = 0.1 +; raft_expansion = 1.5 +; raft_first_layer_density = 90% +; raft_first_layer_expansion = 2 +; raft_layers = 0 +; reduce_crossing_wall = 0 +; reduce_fan_stop_start_freq = 1 +; reduce_infill_retraction = 1 +; required_nozzle_HRC = 0 +; resolution = 0.012 +; retract_before_wipe = 100% +; retract_length_toolchange = 2 +; retract_lift_above = 0 +; retract_lift_below = 0 +; retract_lift_enforce = All Surfaces +; retract_restart_extra = 0 +; retract_restart_extra_toolchange = 0 +; retract_when_changing_layer = 1 +; retraction_distances_when_cut = 18 +; retraction_length = 0.8 +; retraction_minimum_travel = 1 +; retraction_speed = 35 +; role_based_wipe_speed = 1 +; rotate_solid_infill_direction = 1 +; scan_first_layer = 0 +; scarf_angle_threshold = 155 +; scarf_joint_flow_ratio = 1 +; scarf_joint_speed = 100% +; scarf_overhang_threshold = 40% +; seam_gap = 10% +; seam_position = aligned +; seam_slope_conditional = 0 +; seam_slope_entire_loop = 0 +; seam_slope_inner_walls = 0 +; seam_slope_min_length = 20 +; seam_slope_start_height = 0 +; seam_slope_steps = 10 +; seam_slope_type = none +; silent_mode = 0 +; single_extruder_multi_material = 1 +; single_extruder_multi_material_priming = 0 +; skirt_distance = 2 +; skirt_height = 1 +; skirt_loops = 0 +; skirt_speed = 50 +; skirt_start_angle = -135 +; skirt_type = combined +; slice_closing_radius = 0.049 +; slicing_mode = regular +; slow_down_for_layer_cooling = 1 +; slow_down_layer_time = 6 +; slow_down_layers = 1 +; slow_down_min_speed = 20 +; slowdown_for_curled_perimeters = 1 +; small_area_infill_flow_compensation = 0 +; small_area_infill_flow_compensation_model = 0,0;"\n0.2,0.4444";"\n0.4,0.6145";"\n0.6,0.7059";"\n0.8,0.7619";"\n1.5,0.8571";"\n2,0.8889";"\n3,0.9231";"\n5,0.9520";"\n10,1" +; small_perimeter_speed = 50% +; small_perimeter_threshold = 0 +; solid_infill_direction = 45 +; solid_infill_filament = 1 +; sparse_infill_acceleration = 100% +; sparse_infill_density = 15% +; sparse_infill_filament = 1 +; sparse_infill_line_width = 0.45 +; sparse_infill_pattern = grid +; sparse_infill_speed = 270 +; spiral_mode = 0 +; spiral_mode_max_xy_smoothing = 200% +; spiral_mode_smooth = 0 +; staggered_inner_seams = 0 +; standby_temperature_delta = -5 +; start_end_points = 30x-3,54x245 +; support_air_filtration = 1 +; support_angle = 0 +; support_base_pattern = rectilinear +; support_base_pattern_spacing = 2.5 +; support_bottom_interface_spacing = 0.5 +; support_bottom_z_distance = 0.18 +; support_chamber_temp_control = 1 +; support_critical_regions_only = 0 +; support_expansion = 0 +; support_filament = 0 +; support_interface_bottom_layers = 2 +; support_interface_filament = 0 +; support_interface_loop_pattern = 0 +; support_interface_not_for_body = 1 +; support_interface_pattern = auto +; support_interface_spacing = 0.5 +; support_interface_speed = 80 +; support_interface_top_layers = 2 +; support_line_width = 0.42 +; support_material_interface_fan_speed = 100 +; support_multi_bed_types = 0 +; support_object_xy_distance = 0.3 +; support_on_build_plate_only = 0 +; support_remove_small_overhang = 1 +; support_speed = 150 +; support_style = default +; support_threshold_angle = 30 +; support_top_z_distance = 0.18 +; support_type = normal(auto) +; temperature_vitrification = 60 +; template_custom_gcode = +; textured_cool_plate_temp = 40 +; textured_cool_plate_temp_initial_layer = 40 +; textured_plate_temp = 60 +; textured_plate_temp_initial_layer = 55 +; thick_bridges = 0 +; thick_internal_bridges = 1 +; thumbnails = 140x110/PNG +; thumbnails_format = PNG +; time_cost = 0 +; time_lapse_gcode = +; timelapse_type = 0 +; top_bottom_infill_wall_overlap = 25% +; top_shell_layers = 5 +; top_shell_thickness = 1 +; top_solid_infill_flow_ratio = 1 +; top_surface_acceleration = 2000 +; top_surface_jerk = 9 +; top_surface_line_width = 0.42 +; top_surface_pattern = monotonicline +; top_surface_speed = 200 +; travel_acceleration = 10000 +; travel_jerk = 12 +; travel_slope = 3 +; travel_speed = 500 +; travel_speed_z = 0 +; tree_support_adaptive_layer_height = 1 +; tree_support_angle_slow = 25 +; tree_support_auto_brim = 1 +; tree_support_branch_angle = 45 +; tree_support_branch_angle_organic = 40 +; tree_support_branch_diameter = 5 +; tree_support_branch_diameter_angle = 5 +; tree_support_branch_diameter_double_wall = 3 +; tree_support_branch_diameter_organic = 2 +; tree_support_branch_distance = 5 +; tree_support_branch_distance_organic = 1 +; tree_support_brim_width = 3 +; tree_support_tip_diameter = 0.8 +; tree_support_top_rate = 30% +; tree_support_wall_count = 0 +; upward_compatible_machine = +; use_firmware_retraction = 0 +; use_relative_e_distances = 1 +; wall_direction = auto +; wall_distribution_count = 1 +; wall_filament = 1 +; wall_generator = arachne +; wall_loops = 2 +; wall_sequence = inner wall/outer wall +; wall_transition_angle = 10 +; wall_transition_filter_deviation = 25% +; wall_transition_length = 100% +; wipe = 1 +; wipe_before_external_loop = 0 +; wipe_distance = 2 +; wipe_on_loops = 0 +; wipe_speed = 200 +; wipe_tower_bridging = 10 +; wipe_tower_cone_angle = 0 +; wipe_tower_extra_flow = 100% +; wipe_tower_extra_spacing = 100% +; wipe_tower_filament = 0 +; wipe_tower_max_purge_speed = 90 +; wipe_tower_no_sparse_layers = 0 +; wipe_tower_rotation_angle = 0 +; wipe_tower_x = 165.000 +; wipe_tower_x = 165 +; wipe_tower_y = 250.000 +; wipe_tower_y = 250 +; wiping_volumes_extruders = 70,70,70,70,70,70,70,70,70,70 +; xy_contour_compensation = 0 +; xy_hole_compensation = 0 +; z_hop = 0.4 +; z_hop_types = Auto Lift +; z_offset = 0 +; first_layer_bed_temperature = 55 +; bed_shape = -110x-110,110x-110,110x110,-110x110 +; first_layer_temperature = 220 +; first_layer_height = 0.200 +; CONFIG_BLOCK_END + + +; THUMBNAIL_BLOCK_START + +; +; thumbnail begin 140x110 5052 +; iVBORw0KGgoAAAANSUhEUgAAAIwAAABuCAYAAAD1TPu3AAAOkklEQVR4Ae2d14pUSxfH+/KAqAcEBS +; 9EbxRExYjgjSKCXqgcUVBUcARzzlnHnHPOY04Y8Al8hHkEH2EeYX/92+dbh2VNpd0Tu7oWLHqmd/fu +; 7l3//V+xqmq1LFmyZMmSJUuWLFmyZMmSJUuWLFkSliFDhvyN1rJkscmSJUv2XLhw4dfPnz8Lrc+ePf +; t97NixH7Nnz/4nAyhLCRQTJC69fft256RJk+bVsrSewBYwhwmKBw8eFDdv3iwuX75c3Lhxo3j9+nXx +; 7du3/47PnDmzvZaltQSW+PDhQ5cGyp07d4qNGzcWBw4cKOqmqbh+/Xpx5syZog6q8rGjo6N83cGDB3 +; /wfphp3LhxU2tZ0pYNGzbc0kD5+vVrcerUKYBQfPr0qZsZgl3evn1bnDt3rmQfzTYvXrzoGjp06Nha +; lvQENsCB1WB4+fJlsWPHjpJNQv4LQLl69Wpx6dKlos5O5XNPnz4tMmASFJtjy8ADFtjDPPbx40cncJ +; 4/f16cPn26ePz4cfk/kdXu3bs7AOP8+fPbalmaV3BsiWz0gAOQPXv2lCbGBwp8Gm1+tMIwAA798uXL +; H8cATw6/m1C4282BBgSbN28ufZGQCQIUvF6cXZuJ4jhsY74mh99NJtzlpmN79OhRr2PrAgXggnFcr4 +; GxAI3JSERhmMLMNoNYbI4tAw6ruBxbTAp5F3FkXaDwsRJA4RyYOfM8+DgjR44cW8syuGTVqlXt5kAy +; gLCKzbGtyiQAy3VMFNMkDrGNbWpZBl5gFZdji1PqG2Abk8AUpiOr9eHDh95z8F4Jv83zkFnOJmoAxR +; YuY3oIl8mRmMcIl32mRzuzjx49+oMlyNnAWCG20owF28Ba+hgmk0JmLUv/ia0OxEBifnBucXJ9Ayk5 +; FJ++efOmuHjxYnH+/Ply4PnbdV4fYwEyWMs0UZjQzDb9IK46EI4tj7aBAyQ69I0xPY2qyWzCWADn1a +; tXfxzDIc71qD4UWx1IHFtbuKwVVtG1IIlsXHkWXkuNqQqb8HqXyeJ52Mo8nh3iPhBbuNyIY4v/YobQ +; JpBQQOhiK1NhDWER/d1s74fRbt26ZT1PDr97SXx1IBxR89j379+7MYkGA38DEn2nC5BwUGGJEFuZwN +; XmDZNkglifz1engm2yQ9yg4BCaLZPi2PrqQHoQAYLc/WfPni2fA0zv37/vllP5/PlzlDMsWg/ly4iM +; MFvMG4DTjrE4vTGRlVZMb3aIK0gjdSBMg07P2yIiQPHixYv/AETkQ1cdkVUVVgEkJvCEueQ78Ogzlz +; ARofv9+/e7dfX9H5Cd2SGOEFsdyNfgZFB6t4IhdzeJNFtExCAx6FXufs0WvP/evXvl92Pwea7ua3XR +; oWfLA6EAbdeuXcWmTZvK33TixIny8fDhw91yP7ke5ZGeNjjpQTTT/KGIKKScSxJwT548+QM02gxxfl +; +uRiI6G0uS94Htcj0qQmx1IOjcVwe6e/dusGAISHCC5TlMxrVr1yoBUL8X9oKpGHDYQEwSnyX+jOu7 +; xEZ0ZoOWZpuWd4h7UgeKKRgCFolK5M6P9VU4r5gxfB8+A4DCIvwNIwBacivMMHCd11eq0N/PAIe1Ht +; XSoPHVgXytBNzlmAUGSTOJL2vLa6qm9bXTLCYNJ5lHPh928X3PmFIFyrk4P+DTDKUbtDSoMNst5dPw +; Y03HlruTi+tzbHmeKR/c4UQ6796963ZxzbYClEENheGitrudz+Nul3wOQOHz8TdseSABta9UoRWgcz +; 79Xqa5AB7+FnOn39MyLONrcHJdXAYJ6vblMgQkmAnC5StXrhQnT54sNaYdU7MCSpgLm/Aog8p5OOYC +; CRqK6LQ/pRXAAFbOjeni9/DZ9WtVHjdrUC3RbG7WgdDYBiehbfO5I0eOlJTNHagHI9To5FMGj3NxDg +; CAjwLDMIA8usJlfgPm1Od7+SI1zTIhoM2dOzfd2lNvNDhJlz5UTd4CJeGlL2iIhVxKxlZnYzmPDB6f +; CVMAHj7PZS59pQphHthCkntm7UoUvywU+nOuyZMnpwkYXx3Idaf61MccXMjYgqG0W/IdZPAwCQII/j +; bNgItVfKUKzss5MVOAUEJlswgaqnRrbW9vTw8wvgYnLkzViMVUsxYkLBH7fhkwHVmJkynnJqz2nTNU +; qnA1lwuoJQrDeY5lWr7Pli1bilpKggfvanCq4oD6VJxbwIdWOS9AEFBIHofnMRn4KXxXHF4J221MFl +; uqMOtZtnlOMf6WmHDAsmjRoh+1VKTqRPf+VJmbxF2P4sDir4RmOWrFhPmmrKCE++ZvlXqWfEYVhuWz +; +Ex06dKlv2opSG/VgfpKMQ/mIDF4kiiToqXPBAEWX0QnNwfMZCsRSPKPwmQMYHgNnydgmTFjRhrr0r +; jqQFBoI1FLXygOrMuJZWDQEMP4wAQgKR/o5wCgLWqKAYvkptD169d3jR49el6t2YXqaSMT3QdKpWCo +; gSENTjHvt5lUibAAjDRUYfZIGOKrSEmiigni+whY5s2b11FLQXo60X2gFGcZsyD5nEamjmgVUyZhOi +; yjk2zSpB5zLrnZBCxJsApSdaJ7MyoRE86wrRNOmr7NwZZWTcwQgDx+/HgZycUAUju29dzVryQWKWqk +; DtRMysAywKxzt23btrIDjpvg0KFDZXlAcjZm0g1nmAGXqjkgi/XdJDclYEkmIdeTOlAzqC5VmL+HnA +; 3+iW5qMlsf5LX8zWtjmFZMuLDKiBEjmr9/tzcnug9WDdWBRGEUbhJeLyDRvTixN46E3wKWOXPm3Kql +; IFUnujebxk5Z0WpbdUpYJQYwkptKKlxudKJ7M2lPIzpZdYoWi9j3wEzaBNVSkEYmujeT9mapgvCZaa +; ++mYwCLh0uT5gwoa2WggzmOlBvaKhUUXV+koDGZ4qSdWxtE91D3WPNpL4pKzIbgAlp5jou5jXhWMzc +; JslNtYRjGxs1NIOKOQAsJOJs85gAAaYFFvUttcq5SMiF/J4k60A9mejeLCrmwGRJYQrX+2xzsm2d+z +; bVdaCFCxem0bfSrHWgWBVz4Av/AUBo9SlhI4ASMs0tVQdKybEVcxAT/vuSbUQ++DScJ2SaW6YOhJ0O +; dY81k4o5sIX/toIhKk6s7VioCTzZOpCtwYm7b+fOnUnVgVwsSUGRTKwvj4T5CS3HqlWHy6tXr/6dbB +; 1IFNueAljEHNh8DEyObaahzDMyn5fwOjSVJckGpyobXzajijnwhf/4IbJno8zDxl+jaQqguZxeF2iS +; dGxdG1+mpGIOYpZElRBZaj7iCMvEtdi6WMvUgbj7+LG0I9IBRuKJZSV6Mvd4oFS3Bcg2erF+B+xgri +; ODaYpZXFE7tknXgbgYdI+xltr27dvZKazYu3dvsX///nLZDHMN/MGsUgcycysxfocefIBG9xy/P9Q+ +; mWQdCLFlbMXWApjly5d3zpo1q3327Nnt9exjR/2id9F+SI5BT64arCrmwJdbATS+RJzZFef7vGQbnB +; AzEWfaWlcSqZ4zaKuzTReDQF9HzMTz/lZtDmIX5nGZKHwYbo5Qzgn2Sq7BSQQHV/9YIoIqSaRRo0ZN +; Xbx4cQeg4Y7qi40aGlUxB7bcCkCSBQptTOJKxIUy2TpcTsax1cKkd/2D635KF3Nvq9raurnaU/drun +; wL2vSX6rYAV/0GoPBdfawI01Tp3E+yDmTK3Llz/ygirly5suG7Arap0m7YFyp1IFduhYE1J4kxj9kV +; 9cU4xEnWgVwybdq0Nn0Bt23b1nDWEfMmIflAtDeIOfB9Nr+VyWLym2Uqqix/6lo/ztycApVFGJOb6O +; 6TKVOmtJms0MguX7qEwB1H2NlfQNHmgEEPvV52KjEbnAQsMRX3JBucYmT48OFjcVbFlstdVGWZcSkh +; SERCfqK/wCLmQNbljU3EyVQPzJFeiYpzhKKpJOtAVWTt2rW/AM2+ffvKxByZXC5MaJcvXULo71kBOl +; w2o6AqiThZDJnfTGogtMhySzi2IcFZJcJhKSsAQ/pfXyjYBjNF2QAlspKscJXt73pLdfY01G4QWtlb +; P/o0NjfVMgJoZs6c2T5x4sS2eljdIUtwuZb2FGWSeX/NCtBtAbHzsQGMa3Nx8jCwim8GZrINTr0p5F +; S4SDAOZoqdO2wXkwsd2rC7t1SbAxOgoUlgvkRcLJMlVQfqbRkzZsy8Xbt2lSaKRJ7Lrtfv0K7+AIuY +; g9BuHr60PhqbiEu6DtRXMmzYsLHyd71S3WnbsKE/WEXMQaghW/ptzeUzzKW+Qg5xkhPd+1vWrFnziw +; Ez993pS411bDW4JI+iWcS1ZY0NMEk2OA2E4BDXM8C/AQ1NVFV2Ta2q2hxgEvX2NDGg0YNPrwrOe2ie +; VLIT3QdaFixYcAvQCNv0tonS5kDC9CoFQMwSTrjeJIJ15nxRUHZs+1hgG+mBgW0a3TDTZw5MExSbiJ +; PdQmKZTE90b4k60EDJX3/99bf0wKCxS6aHzEEotxKKgqRdNJRAbNk60EDL+PHj/xG2sW17G1LdFhCb +; /JMFkG0ABTD4LT7AtHwdaKAFE7V8+fIf2iGOYRuKfr7cimzH62o3CK2e4GOylq4DDRYxHWJf+I3/QA +; REVdu22YPsmUiPiq+DL9YhbqkGp2YSM/x2TUHBZLAjmO0YvTn4GGJWQnkfX9o/14GaRLRD7Fu+y8Ua +; tvqUObku5NjqcHnFihWdOVwe5GI6xI2G3zawcT5XbiXXgZpYGnWItdaB1iVmSTbBdLFLrgMlIrRLCN +; s0Uo+STTB9E8hyHSgxqVqPkr2ZYRVS/i5WSXaie5Z/RTvEoXoUUZb0F4cc21wHSlh6Wo9KdsHjLG7R +; c7JRmCSmtMA6c7kO1MIiK0DQTH7gwIEyCrpy5Uppbqg8s7o2Di+g2rBhQ7n0SHZsW1xGjhw5ddmyZT +; 8ADE3nLFhE3YcpL4TJbIXHgkYAZt26dZlVsvwrAGf69Ol71q5d27l169YuAUz9/99UluvJwLZalixZ +; smTJkiVLlixZsmTJkiVLlixZsrSq/A/28SBeq8znxgAAAABJRU5ErkJggg== +; thumbnail end +; THUMBNAIL_BLOCK_END + +; external perimeters extrusion width = 0.42mm +; perimeters extrusion width = 0.45mm +; infill extrusion width = 0.45mm +; solid infill extrusion width = 0.42mm +; top infill extrusion width = 0.42mm +; first layer extrusion width = 0.50mm + +; EXECUTABLE_BLOCK_START +M73 P0 R7 +M106 S0 +M106 P2 S0 +;TYPE:Custom +M190 S55 +M104 S220 +G90 +M83 +G1 Z5 F6000 +G1 E-0.2 F800 +G1 X110 Y-110 F6000 +G1 E2 F800 +G1 Y-110 X55 Z0.25 F4800 +G1 X-55 E8 F2400 +G1 Y-109.6 F2400 +G1 X55 E5 F2400 +G1 Y-110 X55 Z0.45 F4800 +G1 X-55 E8 F2400 +G1 Y-109.6 F2400 +G1 X55 E5 F2400 +G92 E0 +G90 +G21 +M83 ; use relative distances for extrusion +; filament start gcode +;right_extruder_material: PLA +SET_PRESSURE_ADVANCE ADVANCE=0.025; Override pressure advance value +M106 S0 +M106 P2 S0 +;LAYER_CHANGE +;Z:0.2 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;0.2 +G1 E-.8 F2100 +;AFTER_LAYER_CHANGE +;0.2 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=500 +G1 X35.66 Y8.453 F30000 +G1 Z.6 +G1 Z.2 +G1 E.8 F2100 +;TYPE:Inner wall +;WIDTH:0.499999 +G1 F3000 +G1 X35.645 Y8.556 E.00388 +G1 X35.736 Y9.053 E.01882 +G1 X35.832 Y9.299 E.00981 +G1 X36.128 Y9.752 E.02016 +G1 X36.44 Y10 E.01485 +G1 X36.128 Y10.249 E.01486 +G1 X35.832 Y10.701 E.02014 +G1 X35.737 Y10.941 E.00961 +G1 X35.643 Y11.533 E.02231 +G1 X35.737 Y12.06 E.01994 +G1 X35.832 Y12.299 E.00955 +G1 X36.129 Y12.753 E.02025 +G1 X36.591 Y13.11 E.02173 +G1 X36.486 Y13.606 E.01889 +G1 X36.317 Y14.095 E.01927 +G1 X36.066 Y14.594 E.02079 +G1 X35.62 Y15.213 E.02844 +G1 X35.266 Y15.572 E.01878 +G1 X34.869 Y15.888 E.01889 +G1 X34.441 Y16.149 E.01869 +G1 X33.943 Y16.372 E.02032 +G1 X33.276 Y16.568 E.02588 +G1 X32.76 Y16.633 E.01937 +G1 X-28.792 Y16.633 E2.29258 +G1 X-29.045 Y16.648 E.00945 +;WIDTH:0.47037 +G1 X-29.299 Y16.663 E.00883 +;WIDTH:0.44074 +G1 X-29.801 Y16.677 E.01629 +;WIDTH:0.412412 +G1 X-32.699 Y16.677 E.08726 +G1 X-33.221 Y16.619 E.01581 +G1 X-33.732 Y16.496 E.01581 +G1 X-34.223 Y16.31 E.01581 +G1 X-34.687 Y16.064 E.01581 +G1 X-35.116 Y15.762 E.01581 +G1 X-35.504 Y15.408 E.01581 +G1 X-35.845 Y15.009 E.01581 +G1 X-36.133 Y14.57 E.01581 +G1 X-36.364 Y14.098 E.0158 +;WIDTH:0.413083 +G1 X-36.534 Y13.599 E.01593 +;WIDTH:0.415489 +G1 X-36.64 Y13.081 E.01604 +G1 X-36.282 Y12.794 E.01392 +G1 X-36.282 Y12.896 E.00308 +G1 X-36.204 Y13.398 E.01543 +;WIDTH:0.413129 +G1 X-36.06 Y13.864 E.01472 +;WIDTH:0.412409 +G1 X-35.86 Y14.301 E.01447 +G1 X-35.608 Y14.708 E.01442 +G1 X-35.307 Y15.08 E.01442 +G1 X-34.961 Y15.412 E.01442 +G1 X-34.577 Y15.697 E.01442 +G1 X-34.16 Y15.932 E.01442 +G1 X-33.716 Y16.113 E.01441 +;WIDTH:0.413224 +G1 X-33.215 Y16.246 E.01566 +G1 X-32.699 Y16.307 E.01566 +;WIDTH:0.412408 +G1 X-29.744 Y16.307 E.08896 +;WIDTH:0.456204 +G1 X-29.297 Y16.244 E.01522 +;WIDTH:0.499999 +G1 X-28.849 Y16.181 E.01684 +G1 X-28.397 Y16.007 E.01804 +G1 X-27.97 Y15.779 E.01804 +G1 X-27.575 Y15.499 E.01804 +G1 X-27.219 Y15.171 E.01804 +G1 X-26.906 Y14.801 E.01804 +G1 X-26.642 Y14.395 E.01804 +G1 X-26.399 Y13.879 E.02124 +G1 X-26.248 Y13.375 E.01962 +G1 X-26.143 Y12.505 E.03262 +G1 X-26.143 Y9.951 E.09513 +G1 X-26.248 Y9.125 E.03102 +G1 X-26.399 Y8.621 E.01962 +G1 X-26.6 Y8.18 E.01804 +G1 X-26.887 Y7.722 E.02015 +G1 X-27.219 Y7.329 E.01913 +G1 X-27.611 Y6.977 E.01965 +G1 X-28.319 Y6.53 E.03117 +G1 X-28.789 Y6.338 E.01893 +G1 X-29.276 Y6.211 E.01871 +G1 X-29.801 Y6.149 E.0197 +G1 X-32.699 Y6.149 E.10795 +G1 X-33.18 Y6.204 E.01804 +G1 X-33.733 Y6.346 E.02124 +G1 X-34.218 Y6.551 E.01965 +G1 X-34.889 Y6.977 E.02959 +G1 X-35.341 Y7.392 E.02284 +G1 X-35.744 Y7.926 E.0249 +G1 X-35.798 Y7.763 E.00638 +G1 X-36.057 Y7.347 E.01828 +G1 X-36.242 Y7.144 E.01022 +G1 X-36.643 Y6.863 E.01824 +G1 X-36.643 Y-12.617 E.72556 +M73 P1 R7 +G1 X-36.56 Y-13.275 E.02469 +M73 P1 R7 +G1 X-36.431 Y-13.777 E.01933 +G1 X-36.24 Y-14.26 E.01934 +G1 X-35.989 Y-14.715 E.01934 +G1 X-35.684 Y-15.135 E.01935 +G1 X-35.328 Y-15.514 E.01936 +G1 X-34.925 Y-15.847 E.01946 +G1 X-34.496 Y-16.119 E.01894 +G1 X-33.989 Y-16.354 E.02082 +G1 X-33.276 Y-16.568 E.02771 +G1 X-32.76 Y-16.633 E.01937 +G1 X32.533 Y-16.63 E2.43191 +G1 X33.276 Y-16.568 E.02778 +G1 X33.78 Y-16.439 E.01937 +G1 X34.263 Y-16.247 E.01937 +G1 X34.719 Y-15.997 E.01937 +G1 X35.14 Y-15.691 E.01937 +G1 X35.519 Y-15.335 E.01937 +G1 X35.85 Y-14.934 E.01937 +G1 X36.129 Y-14.495 E.01937 +G1 X36.35 Y-14.024 E.01937 +G1 X36.511 Y-13.53 E.01937 +G1 X36.609 Y-13.019 E.01937 +G1 X36.643 Y-12.473 E.02037 +G1 X36.643 Y6.863 E.7202 +G1 X36.243 Y7.143 E.01819 +G1 X36.067 Y7.331 E.00959 +G1 X35.831 Y7.703 E.0164 +G1 X35.736 Y7.942 E.00958 +G1 X35.666 Y8.414 E.01777 +G1 X36.108 Y8.454 F30000 +;TYPE:Outer wall +G1 F3000 +G1 X36.1 Y8.497 E.0016 +G1 X36.191 Y8.996 E.0189 +G1 X36.453 Y9.425 E.01871 +G1 X36.866 Y9.744 E.01946 +G1 X37.1 Y9.827 E.00925 +G1 X37.1 Y10.173 E.01288 +G1 X36.935 Y10.221 E.00639 +G1 X36.511 Y10.513 E.0192 +G1 X36.217 Y10.947 E.0195 +G1 X36.104 Y11.458 E.0195 +G1 X36.186 Y11.974 E.01947 +G1 X36.453 Y12.425 E.01951 +G1 X36.867 Y12.745 E.01949 +G1 X37.079 Y12.822 E.0084 +G1 X37.063 Y13.076 E.00951 +G1 X36.955 Y13.644 E.02151 +G1 X36.776 Y14.193 E.02151 +G1 X36.53 Y14.716 E.02151 +G1 X36.221 Y15.203 E.02151 +G1 X35.853 Y15.648 E.02151 +G1 X35.432 Y16.044 E.02151 +G1 X34.964 Y16.383 E.02151 +G1 X34.459 Y16.661 E.02149 +G1 X33.9 Y16.879 E.02232 +G1 X33.362 Y17.018 E.02071 +G1 X32.789 Y17.09 E.02151 +G1 X-32.801 Y17.09 E2.44296 +G1 X-33.449 Y16.995 E.02438 +G1 X-34.005 Y16.841 E.02149 +G1 X-34.537 Y16.618 E.02149 +G1 X-35.037 Y16.331 E.02149 +G1 X-35.497 Y15.982 E.02149 +G1 X-35.91 Y15.579 E.02149 +G1 X-36.269 Y15.128 E.02149 +G1 X-36.565 Y14.636 E.0214 +G1 X-36.805 Y14.105 E.02167 +G1 X-36.974 Y13.541 E.02194 +G1 X-37.079 Y12.817 E.02727 +G1 X-36.858 Y12.73 E.00885 +G1 X-36.459 Y12.371 E.01999 +;WIDTH:0.502546 +G1 X-36.267 Y11.909 E.01877 +G1 X-36.234 Y11.385 E.01966 +;WIDTH:0.514376 +G1 X-36.299 Y10.931 E.01763 +G1 X-36.477 Y10.596 E.01458 +;WIDTH:0.499999 +G1 X-36.858 Y10.27 E.01867 +G1 X-37.1 Y10.177 E.00965 +G1 X-37.1 Y9.827 E.01307 +G1 X-36.934 Y9.778 E.00646 +G1 X-36.513 Y9.482 E.01917 +;WIDTH:0.515861 +G1 X-36.254 Y9.141 E.01649 +G1 X-36.104 Y8.538 E.02394 +;WIDTH:0.50629 +G1 X-36.187 Y8.028 E.01951 +;WIDTH:0.499999 +M73 P2 R7 +G1 X-36.452 Y7.576 E.01951 +M73 P2 R7 +G1 X-36.866 Y7.255 E.01951 +G1 X-37.1 Y7.173 E.00924 +G1 X-37.1 Y-12.487 E.73227 +G1 X-37.063 Y-13.076 E.02198 +G1 X-36.955 Y-13.644 E.02151 +G1 X-36.776 Y-14.193 E.02151 +G1 X-36.53 Y-14.716 E.02151 +G1 X-36.221 Y-15.203 E.02151 +G1 X-35.853 Y-15.648 E.02151 +G1 X-35.432 Y-16.044 E.02151 +G1 X-34.964 Y-16.383 E.02151 +G1 X-34.459 Y-16.661 E.02149 +G1 X-33.9 Y-16.879 E.02232 +G1 X-33.362 Y-17.018 E.02071 +G1 X-32.789 Y-17.09 E.02151 +G1 X32.811 Y-17.087 E2.44332 +G1 X33.362 Y-17.018 E.02069 +G1 X33.921 Y-16.874 E.02151 +G1 X34.458 Y-16.661 E.02151 +G1 X34.964 Y-16.383 E.02151 +G1 X35.432 Y-16.044 E.02151 +G1 X35.853 Y-15.648 E.02151 +G1 X36.221 Y-15.203 E.02151 +G1 X36.53 Y-14.716 E.02151 +G1 X36.776 Y-14.193 E.02151 +G1 X36.955 Y-13.644 E.02151 +G1 X37.063 Y-13.076 E.02151 +G1 X37.1 Y-12.487 E.02198 +G1 X37.1 Y7.173 E.73227 +G1 X36.937 Y7.22 E.00631 +G1 X36.511 Y7.513 E.01924 +G1 X36.192 Y8.001 E.02171 +G1 X36.115 Y8.415 E.01571 +G1 E-.8 F2100 +;WIPE_START +G1 F3000 +G1 X36.1 Y8.497 +G1 X36.191 Y8.996 +G1 X36.453 Y9.425 +G1 X36.866 Y9.744 +G1 X37.1 Y9.827 +G1 X37.1 Y9.964 +;WIPE_END +G1 X29.468 Y9.876 Z.6 F30000 +G1 X-26.695 Y9.229 Z.6 +G1 Z.2 +G1 E.8 F2100 +G1 F3000 +G1 X-26.6 Y9.98 E.0282 +G1 X-26.6 Y12.505 E.09405 +G1 X-26.701 Y13.318 E.03048 +G1 X-26.844 Y13.765 E.01749 +G1 X-27.028 Y14.15 E.0159 +G1 X-27.258 Y14.51 E.0159 +G1 X-27.531 Y14.838 E.0159 +G1 X-27.844 Y15.129 E.0159 +G1 X-28.19 Y15.378 E.0159 +G1 X-28.565 Y15.582 E.0159 +G1 X-28.963 Y15.738 E.0159 +G1 X-29.377 Y15.842 E.0159 +G1 X-29.801 Y15.894 E.0159 +G1 X-32.699 Y15.894 E.10795 +G1 X-33.151 Y15.837 E.01697 +G1 X-33.564 Y15.729 E.0159 +G1 X-33.961 Y15.57 E.0159 +G1 X-34.334 Y15.363 E.0159 +G1 X-34.678 Y15.111 E.0159 +G1 X-34.988 Y14.817 E.0159 +G1 X-35.259 Y14.487 E.0159 +G1 X-35.486 Y14.125 E.01591 +G1 X-35.667 Y13.737 E.01593 +G1 X-35.843 Y13.058 E.02614 +G1 X-35.867 Y12.503 E.02072 +;WIDTH:0.502344 +G1 X-35.811 Y11.968 E.02012 +;WIDTH:0.508461 +G1 X-35.782 Y11.253 E.02714 +;WIDTH:0.508748 +G1 X-35.848 Y10.708 E.02086 +G1 X-35.873 Y10.014 E.02635 +;WIDTH:0.513956 +G1 X-35.836 Y9.414 E.02308 +G1 X-35.703 Y8.92 E.01963 +;WIDTH:0.503245 +G1 X-35.486 Y8.378 E.02189 +;WIDTH:0.499999 +G1 X-35.242 Y7.99 E.01706 +G1 X-34.969 Y7.662 E.01591 +G1 X-34.644 Y7.363 E.01643 +G1 X-33.973 Y6.937 E.02959 +G1 X-33.537 Y6.762 E.01752 +G1 X-33.123 Y6.658 E.0159 +G1 X-32.699 Y6.606 E.0159 +G1 X-29.801 Y6.606 E.10795 +G1 X-29.349 Y6.663 E.01697 +G1 X-28.936 Y6.771 E.0159 +G1 X-28.527 Y6.937 E.01643 +G1 X-27.856 Y7.363 E.02959 +G1 X-27.512 Y7.683 E.01752 +M73 P3 R7 +G1 X-27.241 Y8.013 E.0159 +M73 P3 R7 +G1 X-27.014 Y8.375 E.0159 +G1 X-26.834 Y8.762 E.0159 +G1 X-26.701 Y9.182 E.01643 +G1 X-26.7 Y9.189 E.00026 +G1 E-.8 F2100 +;WIPE_START +G1 F3000 +G1 X-26.6 Y9.98 +G1 X-26.6 Y11.183 +;WIPE_END +G1 X-34.172 Y10.225 Z.6 F30000 +G1 X-36.343 Y9.951 Z.6 +G1 Z.2 +G1 E.8 F2100 +;TYPE:Inner wall +;WIDTH:0.595996 +G1 F3000 +G1 X-36.412 Y9.951 E.00312 +G1 X-36.447 Y10.011 E.00312 +M73 P3 R6 +G1 X-36.412 Y10.07 E.00312 +M73 P3 R6 +G1 X-36.343 Y10.07 E.00312 +G1 X-36.309 Y10.011 E.00312 +G1 E-.8 F2100 +;WIPE_START +G1 F3000 +G1 X-36.343 Y10.07 +G1 X-36.412 Y10.07 +G1 X-36.447 Y10.011 +G1 X-36.412 Y9.951 +G1 X-36.343 Y9.951 +;WIPE_END +G1 X-29.106 Y7.527 Z.6 F30000 +G1 X36.071 Y-14.296 Z.6 +G1 Z.2 +G1 E.8 F2100 +;TYPE:Bottom surface +;WIDTH:0.502364 +G1 F4800 +G1 X34.921 Y-15.426 E.06039 +G1 X34.535 Y-15.707 E.01786 +G1 X34.117 Y-15.936 E.01786 +G1 X33.582 Y-16.135 E.02138 +G1 X36.133 Y-13.584 E.13509 +G1 X36.178 Y-13.444 E.0055 +G1 X36.268 Y-12.976 E.01786 +G1 X36.28 Y-12.788 E.00706 +G1 X32.783 Y-16.285 E.18515 +G1 X32.13 Y-16.288 E.02443 +G1 X36.3 Y-12.118 E.22079 +G1 X36.3 Y-11.468 E.02433 +G1 X31.48 Y-16.288 E.25519 +G1 X30.831 Y-16.288 E.02433 +G1 X36.3 Y-10.818 E.28959 +G1 X36.3 Y-10.168 E.02433 +G1 X30.181 Y-16.288 E.324 +M73 P4 R6 +G1 X29.531 Y-16.288 E.02433 +M73 P4 R6 +G1 X36.3 Y-9.519 E.3584 +G1 X36.3 Y-8.869 E.02433 +G1 X28.881 Y-16.288 E.3928 +G1 X28.231 Y-16.288 E.02433 +G1 X36.3 Y-8.219 E.42721 +G1 X36.3 Y-7.569 E.02433 +G1 X27.582 Y-16.288 E.46161 +G1 X26.932 Y-16.288 E.02433 +G1 X36.3 Y-6.92 E.49602 +G1 X36.3 Y-6.27 E.02433 +G1 X26.282 Y-16.288 E.53042 +G1 X25.632 Y-16.288 E.02433 +G1 X36.3 Y-5.62 E.56482 +G1 X36.3 Y-4.97 E.02433 +G1 X24.983 Y-16.288 E.59923 +G1 X24.333 Y-16.288 E.02433 +G1 X36.3 Y-4.321 E.63363 +G1 X36.3 Y-3.671 E.02433 +G1 X23.683 Y-16.288 E.66803 +M73 P5 R6 +G1 X23.033 Y-16.288 E.02433 +M73 P5 R6 +G1 X36.3 Y-3.021 E.70244 +G1 X36.3 Y-2.371 E.02433 +G1 X22.383 Y-16.288 E.73684 +G1 X21.734 Y-16.288 E.02433 +G1 X36.3 Y-1.722 E.77124 +G1 X36.3 Y-1.072 E.02433 +G1 X21.084 Y-16.288 E.80565 +G1 X20.434 Y-16.288 E.02433 +G1 X36.3 Y-.422 E.84005 +G1 X36.3 Y.228 E.02433 +G1 X19.784 Y-16.288 E.87445 +G1 X19.135 Y-16.288 E.02433 +G1 X36.3 Y.878 E.90886 +G1 X36.3 Y1.527 E.02433 +G1 X18.485 Y-16.288 E.94326 +G1 X17.835 Y-16.288 E.02433 +G1 X36.3 Y2.177 E.97767 +M73 P6 R6 +G1 X36.3 Y2.827 E.02433 +M73 P6 R6 +G1 X17.185 Y-16.288 E1.01207 +G1 X16.535 Y-16.288 E.02433 +G1 X36.3 Y3.477 E1.04647 +G1 X36.3 Y4.126 E.02433 +G1 X15.886 Y-16.288 E1.08088 +G1 X15.236 Y-16.288 E.02433 +G1 X36.3 Y4.776 E1.11528 +G1 X36.3 Y5.426 E.02433 +G1 X14.586 Y-16.288 E1.14968 +G1 X13.936 Y-16.288 E.02433 +G1 X36.3 Y6.076 E1.18409 +G1 X36.3 Y6.678 E.02255 +G1 X36.273 Y6.698 E.00127 +G1 X13.287 Y-16.288 E1.21703 +G1 X12.637 Y-16.288 E.02433 +G1 X35.919 Y6.993 E1.23269 +G1 X35.783 Y7.138 E.00743 +G1 X35.64 Y7.364 E.01002 +M73 P7 R6 +G1 X11.987 Y-16.288 E1.25232 +M73 P7 R6 +G1 X11.337 Y-16.288 E.02433 +G1 X35.421 Y7.795 E1.27514 +G1 X35.322 Y8.346 E.02096 +G1 X10.687 Y-16.288 E1.30432 +G1 X10.038 Y-16.288 E.02433 +G1 X35.388 Y9.062 E1.34224 +G1 X35.474 Y9.359 E.01158 +G1 X35.779 Y9.856 E.02181 +G1 X35.894 Y10 E.0069 +G1 X35.797 Y10.12 E.00579 +G1 X9.388 Y-16.288 E1.39825 +G1 X8.738 Y-16.289 E.02433 +G1 X35.548 Y10.521 E1.41948 +G1 X35.474 Y10.641 E.00526 +M73 P8 R6 +G1 X35.377 Y11 E.01392 +M73 P8 R6 +G1 X8.088 Y-16.289 E1.44482 +G1 X7.439 Y-16.289 E.02433 +G1 X35.305 Y11.578 E1.47543 +G1 X35.402 Y12.132 E.02106 +G1 X35.52 Y12.443 E.01244 +G1 X6.789 Y-16.289 E1.52122 +G1 X6.139 Y-16.289 E.02433 +G1 X36.103 Y13.676 E1.58651 +G1 X36.031 Y13.898 E.00875 +G1 X35.917 Y14.14 E.00999 +G1 X5.489 Y-16.289 E1.61107 +G1 X4.839 Y-16.289 E.02433 +G1 X35.684 Y14.556 E1.63313 +G1 X35.572 Y14.732 E.00781 +M73 P9 R6 +G1 X35.409 Y14.93 E.00962 +M73 P9 R6 +G1 X4.19 Y-16.289 E1.65294 +G1 X3.54 Y-16.289 E.02433 +G1 X35.093 Y15.264 E1.67063 +G1 X34.921 Y15.426 E.00885 +G1 X34.738 Y15.559 E.00846 +G1 X2.89 Y-16.289 E1.68623 +G1 X2.24 Y-16.289 E.02433 +G1 X34.342 Y15.813 E1.69966 +G1 X34.119 Y15.935 E.00952 +G1 X33.9 Y16.021 E.0088 +G1 X1.591 Y-16.289 E1.71068 +G1 X.941 Y-16.289 E.02433 +M73 P10 R6 +G1 X33.409 Y16.18 E1.7191 +M73 P10 R6 +G1 X33.212 Y16.23 E.00764 +G1 X32.855 Y16.275 E.01345 +G1 X.291 Y-16.289 E1.72417 +G1 X-.359 Y-16.289 E.02433 +G1 X32.22 Y16.29 E1.72495 +G1 X31.571 Y16.29 E.02433 +G1 X-1.009 Y-16.289 E1.72495 +G1 X-1.658 Y-16.289 E.02433 +G1 X30.921 Y16.29 E1.72495 +G1 X30.271 Y16.29 E.02433 +G1 X-2.308 Y-16.289 E1.72495 +M73 P11 R6 +G1 X-2.958 Y-16.289 E.02433 +M73 P11 R6 +G1 X29.621 Y16.29 E1.72495 +G1 X28.971 Y16.29 E.02433 +G1 X-3.608 Y-16.289 E1.72496 +G1 X-4.258 Y-16.289 E.02433 +G1 X28.322 Y16.29 E1.72496 +G1 X27.672 Y16.29 E.02433 +G1 X-4.907 Y-16.289 E1.72496 +G1 X-5.557 Y-16.289 E.02433 +G1 X27.022 Y16.29 E1.72496 +G1 X26.372 Y16.29 E.02433 +M73 P12 R6 +G1 X-6.207 Y-16.289 E1.72496 +M73 P12 R6 +G1 X-6.857 Y-16.289 E.02433 +G1 X25.723 Y16.29 E1.72496 +G1 X25.073 Y16.29 E.02433 +G1 X-7.506 Y-16.289 E1.72496 +G1 X-8.156 Y-16.289 E.02433 +G1 X24.423 Y16.29 E1.72496 +G1 X23.773 Y16.29 E.02433 +G1 X-8.806 Y-16.289 E1.72496 +G1 X-9.456 Y-16.289 E.02433 +G1 X23.124 Y16.29 E1.72496 +M73 P13 R6 +G1 X22.474 Y16.29 E.02433 +M73 P13 R6 +G1 X-10.106 Y-16.289 E1.72496 +G1 X-10.755 Y-16.289 E.02433 +G1 X21.824 Y16.29 E1.72496 +G1 X21.174 Y16.29 E.02433 +G1 X-11.405 Y-16.289 E1.72496 +G1 X-12.055 Y-16.289 E.02433 +G1 X20.525 Y16.29 E1.72497 +G1 X19.875 Y16.29 E.02433 +G1 X-12.705 Y-16.289 E1.72497 +G1 X-13.354 Y-16.289 E.02433 +G1 X19.225 Y16.29 E1.72497 +M73 P14 R6 +G1 X18.575 Y16.29 E.02433 +M73 P14 R6 +G1 X-14.004 Y-16.289 E1.72497 +G1 X-14.654 Y-16.289 E.02433 +G1 X17.925 Y16.29 E1.72497 +G1 X17.276 Y16.29 E.02433 +G1 X-15.304 Y-16.29 E1.72497 +G1 X-15.954 Y-16.29 E.02433 +G1 X16.626 Y16.29 E1.72497 +G1 X15.976 Y16.29 E.02433 +G1 X-16.603 Y-16.29 E1.72497 +G1 X-17.253 Y-16.29 E.02433 +G1 X15.326 Y16.29 E1.72497 +M73 P15 R6 +G1 X14.677 Y16.29 E.02433 +M73 P15 R6 +G1 X-17.903 Y-16.29 E1.72497 +G1 X-18.553 Y-16.29 E.02433 +G1 X14.027 Y16.29 E1.72497 +G1 X13.377 Y16.29 E.02433 +G1 X-19.202 Y-16.29 E1.72497 +G1 X-19.852 Y-16.29 E.02433 +G1 X12.727 Y16.29 E1.72497 +G1 X12.078 Y16.29 E.02433 +G1 X-20.502 Y-16.29 E1.72497 +G1 X-21.152 Y-16.29 E.02433 +G1 X11.428 Y16.29 E1.72498 +M73 P16 R6 +G1 X10.778 Y16.29 E.02433 +M73 P16 R6 +G1 X-21.802 Y-16.29 E1.72498 +G1 X-22.451 Y-16.29 E.02433 +G1 X10.128 Y16.29 E1.72498 +G1 X9.478 Y16.29 E.02433 +G1 X-23.101 Y-16.29 E1.72498 +G1 X-23.751 Y-16.29 E.02433 +G1 X8.829 Y16.29 E1.72498 +G1 X8.179 Y16.29 E.02433 +G1 X-24.401 Y-16.29 E1.72498 +M73 P17 R6 +G1 X-25.05 Y-16.29 E.02433 +M73 P17 R6 +G1 X7.529 Y16.29 E1.72498 +M73 P17 R5 +G1 X6.879 Y16.29 E.02433 +M73 P17 R5 +G1 X-25.7 Y-16.29 E1.72498 +G1 X-26.35 Y-16.29 E.02433 +G1 X6.23 Y16.29 E1.72498 +G1 X5.58 Y16.29 E.02433 +G1 X-27 Y-16.29 E1.72498 +G1 X-27.65 Y-16.29 E.02433 +G1 X4.93 Y16.29 E1.72498 +G1 X4.28 Y16.29 E.02433 +G1 X-28.299 Y-16.29 E1.72498 +M73 P18 R5 +G1 X-28.949 Y-16.29 E.02433 +M73 P18 R5 +G1 X3.631 Y16.29 E1.72498 +G1 X2.981 Y16.29 E.02433 +G1 X-29.599 Y-16.29 E1.72498 +G1 X-30.249 Y-16.29 E.02433 +G1 X2.331 Y16.29 E1.72499 +G1 X1.681 Y16.29 E.02433 +G1 X-30.898 Y-16.29 E1.72499 +G1 X-31.548 Y-16.29 E.02433 +G1 X1.032 Y16.29 E1.72499 +G1 X.382 Y16.29 E.02433 +G1 X-32.198 Y-16.29 E1.72499 +G1 X-32.836 Y-16.278 E.02387 +G1 X-.268 Y16.29 E1.72434 +M73 P19 R5 +G1 X-.918 Y16.29 E.02433 +M73 P19 R5 +G1 X-33.392 Y-16.184 E1.71938 +G1 X-33.655 Y-16.117 E.01016 +G1 X-33.884 Y-16.027 E.00923 +G1 X-1.568 Y16.29 E1.71105 +G1 X-2.217 Y16.29 E.02433 +G1 X-34.327 Y-15.821 E1.70012 +G1 X-34.535 Y-15.707 E.00887 +G1 X-34.725 Y-15.568 E.0088 +G1 X-2.867 Y16.29 E1.68677 +G1 X-3.517 Y16.289 E.02433 +G1 X-35.082 Y-15.275 E1.67124 +G1 X-35.268 Y-15.1 E.0096 +G1 X-35.399 Y-14.942 E.00764 +G1 X-4.167 Y16.289 E1.65362 +M73 P20 R5 +G1 X-4.816 Y16.289 E.02433 +M73 P20 R5 +G1 X-35.676 Y-14.57 E1.63389 +G1 X-35.828 Y-14.33 E.01065 +G1 X-35.91 Y-14.155 E.00724 +G1 X-5.466 Y16.289 E1.61191 +G1 X-6.116 Y16.289 E.02433 +G1 X-36.098 Y-13.693 E1.58744 +G1 X-36.178 Y-13.444 E.00976 +G1 X-36.23 Y-13.175 E.01028 +G1 X-6.766 Y16.289 E1.56003 +G1 X-7.415 Y16.289 E.02433 +G1 X-36.292 Y-12.587 E1.52893 +G1 X-36.3 Y-11.946 E.02403 +G1 X-8.065 Y16.289 E1.49494 +M73 P21 R5 +G1 X-8.715 Y16.289 E.02433 +M73 P21 R5 +G1 X-36.3 Y-11.296 E1.46054 +G1 X-36.3 Y-10.646 E.02433 +G1 X-9.365 Y16.289 E1.42614 +G1 X-10.014 Y16.289 E.02433 +G1 X-36.3 Y-9.996 E1.39173 +G1 X-36.3 Y-9.347 E.02433 +G1 X-10.664 Y16.289 E1.35733 +G1 X-11.314 Y16.289 E.02433 +G1 X-36.3 Y-8.697 E1.32293 +G1 X-36.3 Y-8.047 E.02433 +G1 X-11.964 Y16.289 E1.28853 +G1 X-12.614 Y16.289 E.02433 +G1 X-36.3 Y-7.397 E1.25412 +G1 X-36.3 Y-6.748 E.02433 +G1 X-13.263 Y16.289 E1.21972 +M73 P22 R5 +G1 X-13.913 Y16.289 E.02433 +M73 P22 R5 +G1 X-36.3 Y-6.098 E1.18532 +G1 X-36.3 Y-5.448 E.02433 +G1 X-14.563 Y16.289 E1.15091 +G1 X-15.213 Y16.289 E.02433 +G1 X-36.3 Y-4.798 E1.11651 +G1 X-36.3 Y-4.149 E.02433 +G1 X-15.862 Y16.289 E1.08211 +G1 X-16.512 Y16.289 E.02433 +G1 X-36.3 Y-3.499 E1.04771 +G1 X-36.3 Y-2.849 E.02433 +G1 X-17.162 Y16.289 E1.0133 +G1 X-17.812 Y16.289 E.02433 +G1 X-36.3 Y-2.199 E.9789 +G1 X-36.3 Y-1.55 E.02433 +G1 X-28.763 Y5.988 E.39906 +G1 X-29.211 Y5.874 E.01729 +G1 X-29.573 Y5.827 E.01369 +G1 X-36.3 Y-.9 E.35616 +G1 X-36.3 Y-.25 E.02433 +G1 X-30.238 Y5.812 E.32096 +G1 X-30.889 Y5.81 E.02438 +G1 X-36.3 Y.4 E.28647 +G1 X-36.3 Y1.049 E.02433 +G1 X-31.541 Y5.809 E.25199 +G1 X-32.192 Y5.807 E.02438 +G1 X-36.3 Y1.699 E.2175 +G1 X-36.3 Y2.349 E.02433 +G1 X-32.829 Y5.82 E.18377 +G1 X-33.229 Y5.865 E.01505 +G1 X-33.394 Y5.905 E.00637 +G1 X-36.3 Y2.999 E.15386 +G1 X-36.3 Y3.649 E.02433 +G1 X-33.9 Y6.049 E.12709 +G1 X-34.359 Y6.239 E.01861 +G1 X-36.3 Y4.298 E.10278 +G1 X-36.3 Y4.948 E.02433 +G1 X-34.759 Y6.489 E.08158 +G1 X-35.147 Y6.751 E.01752 +G1 X-36.3 Y5.598 E.06107 +M73 P23 R5 +G1 X-36.3 Y6.248 E.02433 +M73 P23 R5 +G1 X-35.345 Y7.202 E.05056 +G1 E-.8 F2100 +;WIPE_START +G1 F4800 +G1 X-36.3 Y6.248 +G1 X-36.3 Y5.598 +;WIPE_END +G1 X-28.953 Y7.666 Z.6 F30000 +G1 X-26.352 Y8.399 Z.6 +G1 Z.2 +G1 E.8 F2100 +G1 F4800 +G1 X-18.461 Y16.289 E.41776 +G1 X-19.111 Y16.289 E.02433 +G1 X-25.85 Y9.551 E.35679 +G1 X-25.807 Y9.872 E.01214 +G1 X-25.806 Y10.244 E.01392 +G1 X-19.761 Y16.289 E.32008 +G1 X-20.411 Y16.289 E.02433 +G1 X-25.805 Y10.895 E.28559 +G1 X-25.803 Y11.547 E.02439 +G1 X-21.06 Y16.289 E.25109 +G1 X-21.71 Y16.289 E.02433 +G1 X-25.801 Y12.198 E.21659 +G1 X-25.8 Y12.512 E.01174 +G1 X-25.836 Y12.813 E.01136 +G1 X-22.36 Y16.289 E.18403 +G1 X-23.01 Y16.289 E.02433 +G1 X-25.905 Y13.394 E.15328 +G1 X-25.948 Y13.602 E.00796 +G1 X-26.049 Y13.899 E.01174 +G1 X-23.66 Y16.289 E.12653 +G1 X-24.309 Y16.289 E.02433 +G1 X-26.243 Y14.356 E.10236 +G1 X-26.349 Y14.574 E.0091 +G1 X-26.478 Y14.771 E.00878 +G1 X-24.959 Y16.289 E.0804 +G1 X-25.609 Y16.289 E.02433 +G1 X-26.75 Y15.148 E.06043 +G1 X-27.059 Y15.489 E.01722 +G1 X-26.259 Y16.289 E.04235 +G1 X-26.908 Y16.289 E.02433 +G1 X-27.402 Y15.796 E.02612 +G1 X-27.783 Y16.064 E.01746 +G1 X-27.352 Y16.495 E.02278 +M106 S255 +M106 P2 S255 +;LAYER_CHANGE +;Z:0.4 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;0.4 +G1 E-.8 F2100 +;WIPE_START +G1 F4800 +G1 X-27.783 Y16.064 +G1 X-27.402 Y15.796 +G1 X-26.908 Y16.289 +G1 X-26.681 Y16.289 +;WIPE_END +;AFTER_LAYER_CHANGE +;0.4 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +M140 S50 ; set bed temperature +G17 +G3 Z.8 I.742 J-.964 P1 F30000 +G1 X-36.005 Y9.111 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.48891 +G1 F16816.52 +G1 X-36.026 Y9.203 E.00342 +;WIDTH:0.52681 +G1 F15499.391 +G1 X-36.14 Y9.994 E.03154 +G1 X-36.14 Y10.598 E.02378 +G1 X-36.533 Y10.183 E.02251 +G1 X-36.859 Y10.008 E.0146 +G1 X-36.525 Y9.797 E.01562 +G1 X-36.232 Y9.531 E.01558 +G1 X-36.024 Y9.146 E.01725 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15499.391 +G1 X-36.026 Y9.203 +G1 X-36.14 Y9.994 +G1 X-36.14 Y10.598 +G1 X-36.512 Y10.205 +;WIPE_END +G1 X-31.991 Y4.056 Z.8 F30000 +G1 X-24.92 Y-5.56 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-24.964 Y-5.381 E.0061 +G1 X-25.149 Y-5.173 E.00922 +G1 X-25.4 Y-5.076 E.00895 +G1 X-32.69 Y-5.071 E.2418 +G1 X-33.543 Y-5.223 E.02874 +G1 X-34.366 Y-5.59 E.02992 +G1 X-35.073 Y-6.151 E.02992 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.966 Y-7.702 E.02992 +G1 X-36.059 Y-8.29 E.01975 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.094 Y-8.379 E.00356 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.129 Y-8.468 E.00394 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.164 Y-8.557 E.00432 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.199 Y-8.647 E.0047 +G1 X-36.199 Y-12.469 E.18732 +G1 X-36.099 Y-13.336 E.04282 +;WIDTH:0.655272 +G1 F12247.857 +G1 X-35.922 Y-13.855 E.02733 +G1 X-35.719 Y-14.318 E.02524 +;WIDTH:0.643373 +G1 F12490.569 +G1 X-35.251 Y-14.966 E.03914 +;WIDTH:0.655274 +G1 F12247.818 +G1 X-34.846 Y-15.336 E.02733 +G1 X-34.445 Y-15.644 E.02524 +;WIDTH:0.643373 +G1 F12490.569 +G1 X-33.722 Y-15.986 E.03914 +;WIDTH:0.655273 +G1 F12247.837 +G1 X-33.19 Y-16.116 E.02734 +G1 X-32.69 Y-16.192 E.02524 +;WIDTH:0.642397 +G1 F12510.905 +G1 X-25.479 Y-16.192 E.35226 +;WIDTH:0.64175 +G1 F12524.423 +G1 X-25.362 Y-16.11 E.00694 +;WIDTH:0.593813 +G1 F13614.278 +G1 X-25.246 Y-16.028 E.00638 +;WIDTH:0.545875 +G1 F14911.887 +G1 X-25.13 Y-15.946 E.00583 +;WIDTH:0.497937 +G1 F16482.908 +G1 X-25.014 Y-15.864 E.00527 +;WIDTH:0.449999 +G1 F18000 +G1 X-24.898 Y-15.516 E.01218 +M73 P24 R5 +G1 X-24.898 Y-5.651 E.32722 +M73 P24 R5 +G1 X-24.911 Y-5.599 E.00178 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.29 Y-5.651 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X-25.36 Y-5.505 E.00499 +G1 X-25.479 Y-5.463 E.00386 +G1 X-32.69 Y-5.463 E.22158 +G1 X-33.469 Y-5.608 E.02436 +G1 X-34.199 Y-5.945 E.02471 +G1 X-34.823 Y-6.453 E.02471 +G1 X-35.3 Y-7.1 E.02471 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.614 Y-13.275 E.02496 +G1 X-35.323 Y-14.025 E.02471 +G1 X-34.855 Y-14.679 E.02471 +G1 X-34.239 Y-15.196 E.02471 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02524 +G1 X-25.479 Y-15.704 E.22215 +G1 X-25.328 Y-15.629 E.00518 +G1 X-25.29 Y-15.516 E.00366 +G1 X-25.29 Y-5.691 E.30188 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-25.36 Y-5.505 +G1 X-25.479 Y-5.463 +G1 X-27.153 Y-5.463 +;WIPE_END +G1 X-19.699 Y-3.822 Z.8 F30000 +G1 X35.919 Y8.419 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.449999 +G1 F18000 +G1 X35.9 Y8.547 E.00427 +G1 X36.003 Y9.07 E.01771 +G1 X36.264 Y9.51 E.01695 +G1 X36.415 Y9.669 E.00727 +G1 X36.898 Y10 E.01943 +G1 X36.723 Y10.106 E.00678 +G1 X36.317 Y10.42 E.01704 +G1 X36.06 Y10.814 E.01559 +G1 X35.98 Y11.016 E.00722 +G1 X35.899 Y11.527 E.01716 +G1 X36.005 Y12.075 E.01853 +G1 X36.264 Y12.51 E.01678 +G1 X36.428 Y12.679 E.00782 +G1 X36.867 Y12.966 E.0174 +G1 X36.759 Y13.593 E.02113 +G1 X36.588 Y14.119 E.01832 +G1 X36.353 Y14.618 E.01832 +G1 X36.057 Y15.084 E.01832 +G1 X35.705 Y15.51 E.01832 +G1 X35.303 Y15.888 E.01832 +G1 X34.856 Y16.212 E.01832 +G1 X34.372 Y16.478 E.01832 +G1 X33.859 Y16.682 E.01832 +G1 X33.324 Y16.819 E.01832 +G1 X32.776 Y16.888 E.01832 +G1 X-32.776 Y16.888 E2.17448 +G1 X-33.29 Y16.823 E.01719 +G1 X-33.794 Y16.689 E.01731 +G1 X-34.348 Y16.488 E.01954 +G1 X-34.78 Y16.254 E.0163 +G1 X-35.211 Y15.954 E.0174 +;WIDTH:0.498866 +G1 F16449.315 +G1 X-35.279 Y15.862 E.00425 +;WIDTH:0.547733 +G1 F14856.986 +G1 X-35.346 Y15.77 E.00471 +;WIDTH:0.5966 +G1 F13545.731 +G1 X-35.414 Y15.678 E.00517 +;WIDTH:0.645467 +G1 F12447.162 +G1 X-35.482 Y15.585 E.00562 +G1 X-35.646 Y15.429 E.01114 +;WIDTH:0.655274 +G1 F12247.818 +G1 X-35.984 Y15.008 E.02694 +G1 X-36.276 Y14.554 E.0269 +G1 X-36.498 Y14.062 E.02694 +G1 X-36.668 Y13.551 E.0269 +;WIDTH:0.649626 +G1 F12361.835 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.205 Y12.599 E.02575 +G1 X-36.056 Y13.501 E.04517 +;WIDTH:0.643372 +G1 F12490.59 +G1 X-35.76 Y14.244 E.03914 +;WIDTH:0.655274 +G1 F12247.818 +G1 X-35.478 Y14.664 E.02524 +G1 X-35.166 Y15.062 E.02524 +;WIDTH:0.642409 +G1 F12510.656 +G1 X-35.042 Y15.133 E.00696 +;WIDTH:0.593283 +G1 F13627.387 +G1 X-34.919 Y15.204 E.00639 +;WIDTH:0.544156 +G1 F14963.022 +G1 X-34.795 Y15.274 E.00582 +;WIDTH:0.495029 +G1 F16588.919 +G1 X-34.489 Y15.499 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.75 Y16.096 E.09751 +G1 X-28.819 Y15.899 E.03157 +G1 X-28.011 Y15.499 E.02992 +G1 X-27.328 Y14.91 E.02992 +G1 X-26.812 Y14.169 E.02992 +G1 X-26.497 Y13.324 E.02992 +G1 X-26.398 Y12.501 E.02752 +G1 X-26.398 Y9.958 E.08436 +G1 X-26.534 Y9.035 E.03094 +G1 X-26.883 Y8.203 E.02992 +G1 X-27.427 Y7.484 E.02992 +G1 X-28.157 Y6.913 E.03075 +G1 X-28.957 Y6.556 E.02907 +G1 X-29.81 Y6.404 E.02874 +G1 X-32.75 Y6.404 E.09751 +G1 X-33.681 Y6.601 E.03157 +G1 X-34.489 Y7.002 E.02993 +G1 X-35.172 Y7.59 E.02991 +G1 X-35.551 Y8.112 E.02138 +G1 X-35.719 Y8.409 E.01133 +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.02241 +G1 X-35.907 Y8.609 E.01366 +;WIDTH:0.451257 +G1 F18000 +G1 X-35.924 Y8.308 E.01001 +;WIDTH:0.449999 +G1 X-36.06 Y7.814 E.01701 +G1 X-36.331 Y7.404 E.01631 +G1 X-36.725 Y7.105 E.01638 +G1 X-36.898 Y7.027 E.00631 +G1 X-36.898 Y-8.237 E.50633 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.874 Y-8.344 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.849 Y-8.451 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.825 Y-8.559 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.801 Y-8.666 E.00539 +G1 X-36.801 Y-12.493 E.18755 +;WIDTH:0.655262 +G1 F12248.058 +G1 X-36.756 Y-13.057 E.02825 +G1 X-36.659 Y-13.587 E.0269 +G1 X-36.484 Y-14.098 E.02694 +G1 X-36.258 Y-14.587 E.0269 +G1 X-35.962 Y-15.039 E.02694 +G1 X-35.621 Y-15.456 E.0269 +G1 X-35.221 Y-15.82 E.02694 +G1 X-34.787 Y-16.14 E.0269 +G1 X-34.31 Y-16.392 E.02694 +G1 X-33.81 Y-16.594 E.0269 +G1 X-33.285 Y-16.72 E.02694 +G1 X-32.69 Y-16.792 E.02994 +;WIDTH:0.642397 +G1 F12510.905 +G1 X-25.478 Y-16.791 E.35226 +;WIDTH:0.64175 +G1 F12524.423 +G1 X-25.235 Y-16.815 E.01196 +;WIDTH:0.593813 +G1 F13614.278 +G1 X-24.991 Y-16.839 E.011 +;WIDTH:0.545875 +G1 F14911.887 +G1 X-24.747 Y-16.863 E.01005 +;WIDTH:0.497937 +G1 F16482.908 +G1 X-24.503 Y-16.887 E.00909 +;WIDTH:0.449999 +G1 F18000 +G1 X32.641 Y-16.876 E1.89554 +G1 X33.324 Y-16.819 E.02274 +G1 X33.859 Y-16.682 E.01832 +G1 X34.372 Y-16.478 E.01832 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01831 +G1 X36.057 Y-15.084 E.01832 +G1 X36.353 Y-14.618 E.01831 +G1 X36.588 Y-14.119 E.01832 +G1 X36.759 Y-13.593 E.01832 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y7.027 E.64712 +G1 X36.725 Y7.105 E.00631 +G1 X36.414 Y7.331 E.01272 +G1 X36.265 Y7.49 E.00724 +G1 X36.06 Y7.814 E.01272 +G1 X35.98 Y8.016 E.00722 +G1 X35.925 Y8.38 E.0122 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.293 Y8.466 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X36.365 Y8.911 E.01385 +G1 X36.596 Y9.3 E.0139 +G1 X36.954 Y9.577 E.01392 +G1 X37.29 Y9.668 E.0107 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01408 +G1 X36.391 Y11.024 E.01393 +G1 X36.293 Y11.465 E.01388 +G1 X36.365 Y11.911 E.01388 +G1 X36.596 Y12.3 E.0139 +G1 X36.954 Y12.577 E.01392 +G1 X37.279 Y12.666 E.01035 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.396 Y17.205 E.01843 +G1 X-34.066 Y17.021 E.02136 +G1 X-34.539 Y16.834 E.01564 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.553 Y16.19 E.01848 +G1 X-35.991 Y15.779 E.01848 +G1 X-36.375 Y15.315 E.01848 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.253 E.0171 +G1 X-37.279 Y12.666 E.01811 +G1 X-37.024 Y12.609 E.00803 +G1 X-36.645 Y12.352 E.01408 +G1 X-36.544 Y12.23 E.00486 +;WIDTH:0.46511 +G1 X-36.404 Y11.965 E.01031 +;WIDTH:0.510221 +G1 X-36.264 Y11.699 E.01142 +;WIDTH:0.541202 +G1 X-36.268 Y11.208 E.01994 +G1 X-36.351 Y11.073 E.00646 +;WIDTH:0.500801 +G1 X-36.434 Y10.937 E.00594 +;WIDTH:0.4604 +G1 X-36.517 Y10.801 E.00542 +;WIDTH:0.419999 +G1 X-36.825 Y10.499 E.01326 +G1 X-37.29 Y10.309 E.01542 +G1 X-37.29 Y9.669 E.01969 +G1 X-37.024 Y9.609 E.00837 +G1 X-36.645 Y9.352 E.01408 +G1 X-36.391 Y8.976 E.01393 +G1 X-36.293 Y8.535 E.01388 +G1 X-36.365 Y8.089 E.01388 +G1 X-36.596 Y7.7 E.01392 +G1 X-36.955 Y7.423 E.01393 +G1 X-37.29 Y7.331 E.01067 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.894 Y-17.268 E2.01862 +G1 X33.397 Y-17.205 E.01559 +M73 P25 R5 +G1 X33.98 Y-17.055 E.01848 +M73 P25 R5 +G1 X34.539 Y-16.834 E.01848 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.697 Y-14.807 E.01848 +G1 X36.953 Y-14.263 E.01848 +G1 X37.139 Y-13.691 E.01848 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.331 E.60916 +G1 X37.024 Y7.391 E.00837 +G1 X36.645 Y7.648 E.01408 +G1 X36.391 Y8.024 E.01393 +G1 X36.302 Y8.426 E.01268 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.365 Y8.911 +G1 X36.596 Y9.3 +G1 X36.954 Y9.577 +G1 X37.29 Y9.668 +G1 X37.29 Y9.927 +;WIPE_END +G1 X29.658 Y9.84 Z.8 F30000 +G1 X-26.895 Y9.201 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X-26.79 Y9.987 E.02437 +G1 X-26.79 Y12.501 E.07724 +G1 X-26.886 Y13.275 E.02398 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.645 Y14.679 E.02471 +G1 X-28.261 Y15.196 E.02471 +G1 X-28.986 Y15.544 E.02471 +G1 X-29.792 Y15.704 E.02524 +G1 X-32.69 Y15.704 E.08905 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.771 Y14.756 E.02267 +G1 X-35.3 Y14.066 E.0267 +G1 X-35.602 Y13.321 E.02471 +G1 X-35.677 Y12.771 E.01706 +G1 X-35.71 Y12.163 E.01869 +;WIDTH:0.455133 +G1 X-35.728 Y11.985 E.00603 +;WIDTH:0.490267 +G1 X-35.745 Y11.806 E.00655 +;WIDTH:0.525401 +G1 X-35.763 Y11.627 E.00706 +G1 X-35.741 Y10.995 E.02489 +;WIDTH:0.481552 +G1 X-35.725 Y10.685 E.01108 +;WIDTH:0.450776 +G1 X-35.71 Y10.375 E.0103 +;WIDTH:0.419999 +G1 X-35.665 Y9.635 E.02279 +G1 X-35.585 Y9.137 E.0155 +G1 X-35.323 Y8.475 E.02188 +G1 X-34.823 Y7.786 E.02615 +G1 X-34.239 Y7.304 E.02327 +G1 X-33.514 Y6.956 E.02472 +G1 X-32.708 Y6.796 E.02524 +G1 X-29.81 Y6.796 E.08905 +G1 X-29.031 Y6.941 E.02436 +G1 X-28.324 Y7.267 E.02392 +G1 X-27.677 Y7.786 E.02548 +G1 X-27.2 Y8.434 E.02471 +G1 X-26.905 Y9.162 E.02415 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.79 Y9.987 +G1 X-26.79 Y11.154 +;WIPE_END +G1 X-34.218 Y9.399 Z.8 F30000 +G1 X-35.964 Y8.987 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.48122 +G1 F17111.567 +G1 X-36.005 Y9.111 E.00467 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F17111.567 +G1 X-35.964 Y8.987 +;WIPE_END +G1 X-34.928 Y15.659 Z.8 F30000 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.436163 +G1 F15000 +G1 X-34.579 Y15.912 E.01383 +G1 X-33.823 Y16.27 E.02679 +G1 X-33.238 Y16.412 E.0193 +G1 X-32.739 Y16.491 E.01618 +G1 X-29.674 Y16.486 E.09821 +;WIDTH:0.460306 +G1 X-29.461 Y16.467 E.00726 +;WIDTH:0.501854 +G1 X-29.249 Y16.447 E.00799 +;WIDTH:0.522628 +G1 X-29.036 Y16.428 E.00835 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-29.249 Y16.447 +;WIPE_END +G1 X-24.956 Y10.137 Z.8 F30000 +G1 X-15.095 Y-4.358 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +;TYPE:Top surface +;WIDTH:0.422469 +G1 F12000 +G1 X-14.185 Y-5.267 E.03978 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-5.877 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.704 Y-4.286 E.06959 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-16.241 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-6.413 E.09307 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-6.95 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-16.778 Y-4.286 E.11655 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-17.314 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-7.487 E.14003 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-8.024 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-17.851 Y-4.286 E.1635 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-18.388 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-8.56 E.18698 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-9.097 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-18.925 Y-4.286 E.21046 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-19.461 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-9.634 E.23394 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-10.171 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-19.998 Y-4.286 E.25742 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-20.535 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-10.707 E.28089 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-11.244 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-21.072 Y-4.286 E.30437 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-21.608 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-11.781 E.32785 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-12.318 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-22.145 Y-4.286 E.35133 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-22.682 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-12.855 E.3748 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-13.391 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-23.219 Y-4.286 E.39828 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P26 R5 +G1 X-23.755 Y-4.286 F30000 +M73 P26 R5 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-13.928 E.42176 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.113 Y-14.465 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.292 Y-4.286 E.44524 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.829 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.113 Y-15.002 E.46871 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.114 Y-15.538 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-25.278 Y-4.374 E.48831 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.621 Y-4.567 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.215 Y-15.973 E.49891 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.397 Y-16.327 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.881 Y-5.844 E.45853 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.881 Y-6.381 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.666 Y-16.596 E.4468 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.02 Y-16.779 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.88 Y-6.918 E.43131 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.88 Y-7.455 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.469 Y-16.866 E.41165 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-16.006 Y-16.866 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.88 Y-7.992 E.38817 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.88 Y-8.528 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-16.542 Y-16.866 E.3647 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-17.079 Y-16.866 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.88 Y-9.065 E.34122 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.88 Y-9.602 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-17.616 Y-16.866 E.31774 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-18.152 Y-16.867 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.88 Y-10.139 E.29426 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.88 Y-10.676 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-18.689 Y-16.867 E.27079 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-19.226 Y-16.867 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.88 Y-11.213 E.24731 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.88 Y-11.749 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-19.762 Y-16.867 E.22383 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-20.299 Y-16.867 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.88 Y-12.286 E.20035 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.88 Y-12.823 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-20.836 Y-16.867 E.17688 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-21.372 Y-16.867 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.879 Y-13.36 E.1534 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.879 Y-13.897 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-21.909 Y-16.867 E.12992 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-22.446 Y-16.867 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.879 Y-14.434 E.10644 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.879 Y-14.971 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-22.982 Y-16.867 E.08297 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.519 Y-16.868 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.885 Y-15.502 E.05973 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.03 Y-15.893 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.056 Y-16.868 E.04263 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.61 Y-16.85 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-25.327 Y-16.133 E.03139 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-24.61 Y-16.85 +;WIPE_END +G1 X-17.223 Y-14.931 Z.8 F30000 +G1 X-13.906 Y-14.07 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.419999 +G1 F15000 +G1 X-11.484 Y-16.492 E.10527 +G1 X-14.263 Y-16.493 E.08541 +G1 X-14.009 Y-16.016 E.0166 +G1 X-13.908 Y-15.568 E.0141 +G1 X-13.906 Y-14.11 E.04482 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-13.455 Y-15.162 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.571622 +G1 F14185.684 +G1 X-12.577 Y-16.039 E.05349 +G1 X-13.544 Y-16.04 E.04167 +G1 X-13.455 Y-15.569 E.02064 +G1 X-13.455 Y-15.202 E.01582 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F14185.684 +G1 X-13.455 Y-15.569 +G1 X-13.544 Y-16.04 +G1 X-12.577 Y-16.039 +G1 X-12.709 Y-15.908 +;WIPE_END +G1 X-19.967 Y-13.548 Z.8 F30000 +G1 X-36.473 Y-8.183 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.511425 +G1 F15000 +G1 X-36.449 Y-7.901 E.01079 +;WIDTH:0.536188 +G1 X-36.426 Y-7.62 E.01136 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.329 Y-4.895 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.396012 +G1 F15000 +G1 X-35.764 Y-5.275 E.01661 +G1 X-35.764 Y-4.46 E.02343 +G1 X-35.357 Y-4.867 E.01655 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.759 Y-4.949 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-34.991 Y-5.085 E.00825 +G1 X-35.709 Y-5.711 E.02927 +G1 X-36.129 Y-6.291 E.02199 +G1 X-36.129 Y-3.579 E.08332 +G1 X-34.787 Y-4.92 E.05829 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.124 Y-5.051 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-34.704 Y-5.343 E.01996 +G1 X-35.461 Y-5.995 E.03071 +G1 X-36.032 Y-6.816 E.03072 +;WIDTH:0.443723 +G1 X-36.154 Y-7.054 E.00874 +;WIDTH:0.491172 +G1 X-36.275 Y-7.293 E.00978 +;WIDTH:0.53862 +G1 X-36.397 Y-7.531 E.01081 +;WIDTH:0.574029 +G1 F14121.396 +G1 X-36.423 Y-7.58 E.00239 +;WIDTH:0.565 +G1 F14365.624 +G1 X-36.444 Y-7.376 E.00869 +;WIDTH:0.523571 +G1 F15000 +G1 X-36.464 Y-7.173 E.008 +;WIDTH:0.482143 +M73 P27 R5 +G1 X-36.485 Y-6.97 E.00731 +M73 P27 R5 +;WIDTH:0.421109 +G1 X-36.506 Y-6.766 E.0063 +G1 X-36.506 Y-2.669 E.12629 +G1 X-34.152 Y-5.022 E.10258 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X33.716 Y16.699 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.420222 +G1 F15000 +G1 X36.435 Y13.979 E.11824 +G1 X36.576 Y13.546 E.014 +G1 X36.632 Y13.248 E.00933 +G1 X33.242 Y16.639 E.14744 +G1 X32.648 Y16.7 E.01835 +G1 X36.433 Y12.914 E.1646 +G1 X36.303 Y12.821 E.00491 +G1 X36.149 Y12.664 E.00675 +G1 X32.114 Y16.7 E.17546 +G1 X31.581 Y16.7 E.01641 +G1 X35.937 Y12.343 E.18944 +G1 X35.835 Y12.16 E.00644 +G1 X35.784 Y11.963 E.00625 +G1 X31.047 Y16.7 E.20595 +G1 X30.513 Y16.7 E.01641 +G1 X35.715 Y11.498 E.22617 +G1 X35.712 Y11.44 E.00179 +G1 X35.803 Y10.94 E.01562 +G1 X35.851 Y10.828 E.00374 +G1 X29.98 Y16.7 E.25529 +G1 X29.446 Y16.7 E.01641 +G1 X36.315 Y9.83 E.29868 +G1 X36.174 Y9.703 E.00584 +G1 X36.069 Y9.543 E.00589 +G1 X28.913 Y16.7 E.31116 +G1 X28.379 Y16.7 E.01641 +G1 X35.868 Y9.211 E.32562 +G1 X35.803 Y9.06 E.00504 +G1 X35.752 Y8.793 E.00835 +G1 X27.845 Y16.7 E.34377 +G1 X27.312 Y16.7 E.01641 +G1 X35.748 Y8.263 E.36681 +G1 X35.805 Y7.935 E.01025 +G1 X35.901 Y7.712 E.00747 +G1 X36.105 Y7.39 E.01171 +G1 X36.305 Y7.177 E.00897 +G1 X36.613 Y6.954 E.0117 +G1 X36.709 Y6.91 E.00326 +G1 X36.709 Y6.768 E.00435 +G1 X26.778 Y16.7 E.43181 +G1 X26.245 Y16.7 E.01641 +G1 X36.709 Y6.235 E.45501 +G1 X36.709 Y5.701 E.01641 +G1 X25.729 Y16.682 E.47745 +G1 X25.195 Y16.682 E.01641 +G1 X36.709 Y5.168 E.50065 +G1 X36.709 Y4.634 E.01641 +G1 X24.661 Y16.682 E.52385 +G1 X24.128 Y16.682 E.01641 +G1 X36.709 Y4.1 E.54705 +G1 X36.709 Y3.567 E.01641 +G1 X23.594 Y16.682 E.57025 +G1 X23.061 Y16.682 E.01641 +G1 X36.709 Y3.033 E.59345 +G1 X36.709 Y2.5 E.01641 +G1 X22.527 Y16.682 E.61665 +G1 X21.994 Y16.682 E.01641 +G1 X36.709 Y1.966 E.63985 +G1 X36.709 Y1.433 E.01641 +G1 X21.46 Y16.682 E.66305 +G1 X20.926 Y16.682 E.01641 +G1 X36.709 Y.899 E.68625 +G1 X36.709 Y.365 E.01641 +G1 X20.393 Y16.682 E.70946 +G1 X19.859 Y16.682 E.01641 +G1 X36.709 Y-.168 E.73266 +G1 X36.709 Y-.702 E.01641 +G1 X19.326 Y16.682 E.75586 +G1 X18.792 Y16.682 E.01641 +G1 X36.709 Y-1.235 E.77906 +G1 X36.709 Y-1.769 E.01641 +G1 X18.258 Y16.682 E.80226 +G1 X17.725 Y16.682 E.01641 +G1 X36.709 Y-2.303 E.82546 +G1 X36.709 Y-2.836 E.01641 +G1 X17.191 Y16.682 E.84866 +M73 P28 R5 +G1 X16.658 Y16.682 E.01641 +M73 P28 R5 +G1 X36.709 Y-3.37 E.87186 +G1 X36.709 Y-3.903 E.01641 +G1 X16.124 Y16.682 E.89506 +G1 X15.59 Y16.682 E.01641 +G1 X36.709 Y-4.437 E.91826 +G1 X36.709 Y-4.97 E.01641 +G1 X15.057 Y16.682 E.94146 +G1 X14.523 Y16.682 E.01641 +G1 X36.709 Y-5.504 E.96466 +G1 X36.709 Y-6.038 E.01641 +G1 X13.99 Y16.682 E.98786 +G1 X13.456 Y16.682 E.01641 +G1 X36.709 Y-6.571 E1.01106 +G1 X36.709 Y-7.105 E.01641 +G1 X12.923 Y16.682 E1.03426 +G1 X12.389 Y16.682 E.01641 +G1 X36.709 Y-7.638 E1.05746 +G1 X36.709 Y-8.172 E.01641 +G1 X11.855 Y16.682 E1.08066 +G1 X11.322 Y16.682 E.01641 +G1 X36.709 Y-8.706 E1.10386 +G1 X36.709 Y-9.239 E.01641 +G1 X10.788 Y16.682 E1.12707 +G1 X10.255 Y16.682 E.01641 +G1 X36.709 Y-9.773 E1.15027 +G1 X36.709 Y-10.306 E.01641 +G1 X9.721 Y16.682 E1.17347 +G1 X9.187 Y16.682 E.01641 +G1 X36.709 Y-10.84 E1.19667 +G1 X36.709 Y-11.373 E.01641 +G1 X8.654 Y16.682 E1.21987 +G1 X8.12 Y16.682 E.01641 +G1 X36.709 Y-11.907 E1.24307 +G1 X36.709 Y-12.441 E.01641 +G1 X7.587 Y16.682 E1.26627 +G1 X7.053 Y16.682 E.01641 +G1 X36.68 Y-12.945 E1.28818 +G1 X36.603 Y-13.402 E.01425 +G1 X6.52 Y16.682 E1.30805 +G1 X5.986 Y16.682 E.01641 +G1 X36.487 Y-13.819 E1.3262 +G1 X36.412 Y-14.049 E.00743 +M73 P29 R5 +G1 X36.339 Y-14.205 E.00529 +M73 P29 R5 +G1 X5.452 Y16.682 E1.34297 +G1 X4.919 Y16.682 E.01641 +G1 X36.164 Y-14.563 E1.35857 +G1 X35.957 Y-14.89 E.01189 +G1 X4.385 Y16.682 E1.37276 +G1 X3.852 Y16.682 E.01641 +G1 X35.724 Y-15.191 E1.38584 +G1 X35.471 Y-15.471 E.01161 +G1 X3.318 Y16.682 E1.39803 +G1 X2.784 Y16.682 E.01641 +G1 X35.196 Y-15.729 E1.40926 +G1 X34.888 Y-15.956 E.01173 +G1 X2.251 Y16.682 E1.4191 +G1 X1.717 Y16.682 E.01641 +G1 X34.559 Y-16.16 E1.42799 +G1 X34.207 Y-16.341 E.01219 +G1 X1.184 Y16.682 E1.43586 +G1 X.65 Y16.682 E.01641 +G1 X33.824 Y-16.492 E1.44244 +G1 X33.403 Y-16.604 E.01342 +G1 X.116 Y16.682 E1.44729 +G1 X-.417 Y16.682 E.01641 +G1 X32.942 Y-16.677 E1.45047 +G1 X32.858 Y-16.688 E.00262 +G1 X32.419 Y-16.688 E.01348 +G1 X-.951 Y16.682 E1.45094 +G1 X-1.484 Y16.682 E.01641 +G1 X31.886 Y-16.688 E1.45094 +G1 X31.352 Y-16.688 E.0164 +G1 X-2.018 Y16.682 E1.45094 +G1 X-2.551 Y16.682 E.01641 +G1 X30.819 Y-16.688 E1.45095 +G1 X30.285 Y-16.688 E.0164 +G1 X-3.085 Y16.682 E1.45095 +G1 X-3.619 Y16.682 E.01641 +G1 X29.752 Y-16.688 E1.45096 +M73 P30 R5 +G1 X29.218 Y-16.688 E.0164 +M73 P30 R5 +G1 X-4.152 Y16.682 E1.45096 +G1 X-4.686 Y16.682 E.01641 +G1 X28.685 Y-16.688 E1.45097 +G1 X28.151 Y-16.689 E.0164 +G1 X-5.219 Y16.682 E1.45097 +G1 X-5.753 Y16.682 E.01641 +G1 X27.618 Y-16.689 E1.45097 +G1 X27.084 Y-16.689 E.0164 +G1 X-6.287 Y16.682 E1.45098 +G1 X-6.82 Y16.682 E.01641 +G1 X26.551 Y-16.689 E1.45098 +G1 X26.017 Y-16.689 E.0164 +G1 X-7.354 Y16.682 E1.45099 +G1 X-7.887 Y16.682 E.01641 +G1 X25.484 Y-16.689 E1.45099 +G1 X24.95 Y-16.689 E.0164 +G1 X-8.421 Y16.682 E1.45099 +G1 X-8.954 Y16.682 E.01641 +G1 X24.417 Y-16.689 E1.451 +G1 X23.883 Y-16.689 E.0164 +G1 X-9.488 Y16.682 E1.451 +G1 X-10.022 Y16.682 E.01641 +G1 X23.35 Y-16.689 E1.45101 +G1 X22.816 Y-16.69 E.0164 +G1 X-10.555 Y16.682 E1.45101 +G1 X-11.089 Y16.682 E.01641 +G1 X22.283 Y-16.69 E1.45102 +G1 X21.749 Y-16.69 E.0164 +G1 X-11.622 Y16.682 E1.45102 +G1 X-12.156 Y16.682 E.01641 +G1 X21.216 Y-16.69 E1.45102 +M73 P30 R4 +G1 X20.682 Y-16.69 E.0164 +M73 P30 R4 +G1 X-12.69 Y16.682 E1.45103 +G1 X-13.223 Y16.682 E.01641 +G1 X20.149 Y-16.69 E1.45103 +G1 X19.615 Y-16.69 E.0164 +G1 X-13.757 Y16.682 E1.45104 +M73 P31 R4 +G1 X-14.29 Y16.682 E.01641 +M73 P31 R4 +G1 X19.082 Y-16.69 E1.45104 +G1 X18.548 Y-16.69 E.0164 +G1 X-14.824 Y16.682 E1.45104 +G1 X-15.357 Y16.682 E.01641 +G1 X18.015 Y-16.69 E1.45105 +G1 X17.481 Y-16.69 E.0164 +G1 X-15.891 Y16.682 E1.45105 +G1 X-16.425 Y16.682 E.01641 +G1 X16.948 Y-16.691 E1.45106 +G1 X16.414 Y-16.691 E.0164 +G1 X-16.958 Y16.682 E1.45106 +G1 X-17.492 Y16.682 E.01641 +G1 X15.881 Y-16.691 E1.45107 +G1 X15.347 Y-16.691 E.0164 +G1 X-18.025 Y16.682 E1.45107 +G1 X-18.559 Y16.682 E.01641 +G1 X14.814 Y-16.691 E1.45107 +G1 X14.281 Y-16.691 E.0164 +G1 X-19.093 Y16.682 E1.45108 +G1 X-19.626 Y16.682 E.01641 +G1 X13.747 Y-16.691 E1.45108 +G1 X13.214 Y-16.691 E.0164 +G1 X-20.16 Y16.682 E1.45109 +G1 X-20.693 Y16.682 E.01641 +G1 X12.68 Y-16.691 E1.45109 +G1 X12.147 Y-16.691 E.0164 +G1 X-21.227 Y16.682 E1.45109 +G1 X-21.761 Y16.682 E.01641 +G1 X11.613 Y-16.692 E1.4511 +G1 X11.08 Y-16.692 E.0164 +G1 X-22.294 Y16.682 E1.4511 +G1 X-22.828 Y16.682 E.01641 +G1 X10.546 Y-16.692 E1.45111 +G1 X10.013 Y-16.692 E.0164 +G1 X-23.361 Y16.682 E1.45111 +M73 P32 R4 +G1 X-23.895 Y16.682 E.01641 +M73 P32 R4 +G1 X9.479 Y-16.692 E1.45112 +G1 X8.946 Y-16.692 E.0164 +G1 X-24.428 Y16.682 E1.45112 +G1 X-24.962 Y16.682 E.01641 +G1 X8.412 Y-16.692 E1.45112 +G1 X7.879 Y-16.692 E.0164 +G1 X-25.496 Y16.682 E1.45113 +G1 X-26.029 Y16.682 E.01641 +G1 X7.345 Y-16.692 E1.45113 +G1 X6.812 Y-16.692 E.0164 +G1 X-26.563 Y16.682 E1.45114 +G1 X-27.096 Y16.682 E.01641 +G1 X6.278 Y-16.693 E1.45114 +G1 X5.745 Y-16.693 E.0164 +G1 X-27.63 Y16.682 E1.45114 +G1 X-28.164 Y16.682 E.01641 +G1 X5.211 Y-16.693 E1.45115 +G1 X4.678 Y-16.693 E.0164 +G1 X-28.527 Y16.512 E1.44376 +G1 X-26.35 Y14.319 E.09501 +G1 X-26.578 Y14.091 E.00993 +G1 X-26.539 Y13.99 E.00333 +G1 X4.144 Y-16.693 E1.3341 +G1 X3.611 Y-16.693 E.0164 +G1 X-26.294 Y13.212 E1.30028 +G1 X-26.222 Y12.606 E.01875 +G1 X3.077 Y-16.693 E1.27395 +G1 X2.544 Y-16.693 E.0164 +G1 X-26.21 Y12.061 E1.25024 +G1 X-26.212 Y11.529 E.01637 +G1 X2.01 Y-16.693 E1.2271 +G1 X1.477 Y-16.693 E.0164 +G1 X-26.213 Y10.996 E1.20396 +G1 X-26.214 Y10.464 E.01637 +G1 X.943 Y-16.693 E1.18082 +G1 X.41 Y-16.694 E.0164 +G1 X-26.216 Y9.932 E1.15768 +G1 X-26.274 Y9.456 E.01472 +G1 X-.124 Y-16.694 E1.13702 +G1 X-.657 Y-16.694 E.0164 +G1 X-26.353 Y9.002 E1.11727 +G1 X-26.501 Y8.616 E.0127 +G1 X-1.191 Y-16.694 E1.1005 +G1 X-1.724 Y-16.694 E.0164 +G1 X-26.652 Y8.233 E1.08385 +G1 X-26.708 Y8.125 E.00376 +G1 X-26.863 Y7.911 E.00812 +G1 X-2.258 Y-16.694 E1.06983 +M73 P33 R4 +G1 X-2.791 Y-16.694 E.0164 +M73 P33 R4 +G1 X-13.909 Y-5.576 E.48343 +G1 X-13.906 Y-6.113 E.01653 +G1 X-3.325 Y-16.694 E.46006 +G1 X-3.858 Y-16.694 E.0164 +G1 X-13.906 Y-6.647 E.43686 +G1 X-13.906 Y-7.181 E.01641 +G1 X-4.392 Y-16.694 E.41367 +G1 X-4.925 Y-16.695 E.0164 +G1 X-13.906 Y-7.714 E.39047 +G1 X-13.906 Y-8.248 E.01641 +G1 X-5.459 Y-16.695 E.36728 +G1 X-5.992 Y-16.695 E.0164 +G1 X-13.906 Y-8.781 E.34408 +G1 X-13.906 Y-9.315 E.01641 +G1 X-6.526 Y-16.695 E.32088 +G1 X-7.059 Y-16.695 E.0164 +G1 X-13.906 Y-9.848 E.29769 +G1 X-13.906 Y-10.382 E.01641 +G1 X-7.593 Y-16.695 E.27449 +G1 X-8.126 Y-16.695 E.0164 +G1 X-13.906 Y-10.916 E.25129 +G1 X-13.906 Y-11.449 E.01641 +G1 X-8.66 Y-16.695 E.2281 +G1 X-9.193 Y-16.695 E.0164 +G1 X-13.906 Y-11.983 E.2049 +G1 X-13.906 Y-12.516 E.01641 +G1 X-9.727 Y-16.695 E.1817 +G1 X-10.26 Y-16.695 E.0164 +G1 X-13.906 Y-13.05 E.15851 +G1 X-13.906 Y-13.584 E.01641 +G1 X-10.624 Y-16.865 E.14269 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.197 Y-4.288 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-27.087 Y7.601 E.51696 +G1 X-27.175 Y7.48 E.0046 +G1 X-27.335 Y7.316 E.00706 +G1 X-15.941 Y-4.078 E.49543 +G1 X-16.474 Y-4.078 E.01641 +G1 X-27.629 Y7.076 E.485 +G1 X-27.923 Y6.837 E.01166 +G1 X-17.008 Y-4.078 E.47458 +G1 X-17.542 Y-4.078 E.01641 +G1 X-28.275 Y6.656 E.46671 +G1 X-28.638 Y6.485 E.01233 +G1 X-18.075 Y-4.078 E.45928 +G1 X-18.609 Y-4.078 E.01641 +G1 X-29.04 Y6.353 E.45356 +G1 X-29.485 Y6.265 E.01396 +G1 X-19.142 Y-4.078 E.44972 +G1 X-19.676 Y-4.078 E.01641 +G1 X-29.969 Y6.215 E.44756 +G1 X-30.503 Y6.215 E.01641 +G1 X-20.209 Y-4.078 E.44756 +G1 X-20.743 Y-4.078 E.01641 +G1 X-31.036 Y6.215 E.44756 +G1 X-31.57 Y6.215 E.01641 +G1 X-21.277 Y-4.078 E.44756 +G1 X-21.81 Y-4.078 E.01641 +G1 X-32.104 Y6.215 E.44756 +G1 X-32.637 Y6.215 E.01641 +G1 X-22.344 Y-4.078 E.44756 +G1 X-22.877 Y-4.078 E.01641 +G1 X-33.273 Y6.318 E.45201 +G1 X-33.577 Y6.371 E.00949 +G1 X-33.762 Y6.431 E.00598 +G1 X-34.06 Y6.571 E.01013 +G1 X-23.411 Y-4.078 E.46304 +G1 X-23.945 Y-4.078 E.01641 +G1 X-35.854 Y7.831 E.51782 +G1 X-35.901 Y7.712 E.00395 +G1 X-36.048 Y7.492 E.00813 +G1 X-24.478 Y-4.078 E.50308 +G1 X-25.004 Y-4.086 E.01616 +G1 X-36.291 Y7.201 E.49076 +G1 X-36.593 Y6.969 E.0117 +G1 X-25.418 Y-4.205 E.48586 +G1 X-25.548 Y-4.266 E.00441 +G1 X-25.752 Y-4.405 E.00758 +G1 X-36.709 Y6.552 E.47644 +G1 X-36.709 Y6.019 E.01641 +G1 X-25.964 Y-4.727 E.46722 +G1 X-26.02 Y-4.882 E.00507 +G1 X-26.342 Y-4.882 E.00991 +G1 X-36.709 Y5.485 E.45077 +G1 X-36.709 Y4.952 E.01641 +G1 X-26.876 Y-4.882 E.42757 +G1 X-27.409 Y-4.882 E.01641 +G1 X-36.709 Y4.418 E.40437 +G1 X-36.709 Y3.884 E.01641 +G1 X-27.943 Y-4.882 E.38117 +G1 X-28.477 Y-4.882 E.01641 +G1 X-36.709 Y3.351 E.35797 +G1 X-36.709 Y2.817 E.01641 +G1 X-29.01 Y-4.882 E.33477 +M73 P34 R4 +G1 X-29.544 Y-4.882 E.01641 +M73 P34 R4 +G1 X-36.709 Y2.284 E.31157 +G1 X-36.709 Y1.75 E.01641 +G1 X-30.077 Y-4.882 E.28837 +G1 X-30.611 Y-4.882 E.01641 +G1 X-36.709 Y1.217 E.26517 +G1 X-36.709 Y.683 E.01641 +G1 X-31.144 Y-4.882 E.24196 +G1 X-31.678 Y-4.882 E.01641 +G1 X-36.709 Y.149 E.21876 +G1 X-36.709 Y-.384 E.01641 +G1 X-32.212 Y-4.882 E.19556 +G1 X-32.737 Y-4.89 E.01615 +G1 X-36.709 Y-.918 E.17273 +G1 X-36.709 Y-1.451 E.01641 +G1 X-33.191 Y-4.97 E.15298 +G1 X-33.637 Y-5.057 E.01399 +G1 X-36.879 Y-1.815 E.14095 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-35.465 Y-3.23 +;WIPE_END +G1 X-31.862 Y-9.958 Z.8 F30000 +G1 X-30.463 Y-12.572 Z.8 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.546524 +G1 F12000 +G1 X-30.449 Y-12.488 E.00349 +;WIDTH:0.497394 +G1 X-30.436 Y-12.404 E.00315 +;WIDTH:0.462004 +G1 X-29.881 Y-11.648 E.03205 +;WIDTH:0.45699 +G1 X-29.004 Y-10.771 E.04183 +;WIDTH:0.456004 +G1 X-28.633 Y-10.363 E.01857 +;WIDTH:0.462984 +G1 X-28.312 Y-9.878 E.0199 +;WIDTH:0.479824 +G1 X-28.123 Y-9.391 E.01861 +;WIDTH:0.48569 +G1 X-28.089 Y-8.861 E.01914 +;WIDTH:0.501257 +G1 X-28.188 Y-8.337 E.01993 +G1 X-28.296 Y-8.118 E.00913 +;WIDTH:0.503208 +G1 X-28.562 Y-7.807 E.01533 +G1 X-28.999 Y-7.592 E.0183 +;WIDTH:0.489869 +G1 X-29.494 Y-7.52 E.01821 +;WIDTH:0.478167 +G1 X-29.97 Y-7.581 E.017 +;WIDTH:0.488488 +G1 X-30.394 Y-7.787 E.01712 +;WIDTH:0.491514 +G1 X-30.717 Y-8.122 E.01701 +G1 X-30.85 Y-8.378 E.01055 +;WIDTH:0.494137 +G1 X-30.985 Y-8.831 E.01738 +G1 X-30.552 Y-8.887 E.01606 +G1 X-30.439 Y-8.531 E.01374 +;WIDTH:0.482134 +G1 X-30.25 Y-8.248 E.01217 +;WIDTH:0.479228 +G1 X-29.897 Y-8.011 E.01514 +G1 X-29.424 Y-7.949 E.01696 +;WIDTH:0.479511 +G1 X-28.974 Y-8.074 E.0166 +;WIDTH:0.499671 +G1 X-28.675 Y-8.371 E.0157 +G1 X-28.535 Y-8.844 E.01835 +;WIDTH:0.49 +G1 X-28.551 Y-9.294 E.01642 +;WIDTH:0.481285 +G1 X-28.696 Y-9.704 E.01553 +;WIDTH:0.45985 +G1 X-28.978 Y-10.135 E.01751 +;WIDTH:0.455996 +G1 X-29.331 Y-10.519 E.01753 +;WIDTH:0.45699 +G1 X-30.216 Y-11.396 E.04204 +G1 X-30.768 Y-12.164 E.03193 +;WIDTH:0.499442 +G1 X-30.828 Y-12.342 E.00699 +;WIDTH:0.547548 +G1 X-30.888 Y-12.521 E.00773 +;WIDTH:0.595654 +G1 X-30.948 Y-12.699 E.00847 +G1 X-30.973 Y-13.096 E.01793 +G1 X-30.734 Y-13.115 E.01082 +;WIDTH:0.557973 +G1 X-30.494 Y-13.134 E.01008 +;WIDTH:0.520292 +G1 X-30.255 Y-13.152 E.00934 +;WIDTH:0.482611 +G1 X-27.992 Y-13.154 E.08106 +;WIDTH:0.479059 +G1 X-27.993 Y-12.718 E.01548 +G1 X-28.21 Y-12.717 E.00771 +;WIDTH:0.482611 +G1 X-30.254 Y-12.713 E.07324 +;WIDTH:0.520292 +G1 X-30.328 Y-12.694 E.00297 +;WIDTH:0.557973 +G1 X-30.402 Y-12.675 E.00321 +;WIDTH:0.595654 +G1 X-30.477 Y-12.656 E.00344 +G1 X-30.469 Y-12.612 E.00203 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-30.449 Y-12.488 +G1 X-30.436 Y-12.404 +G1 X-29.881 Y-11.648 +G1 X-29.279 Y-11.046 +;WIPE_END +G1 X-32.236 Y-13.138 Z.8 F30000 +G1 Z.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.506358 +G1 F12000 +G1 X-32.235 Y-12.465 E.0254 +;WIDTH:0.504949 +G1 X-32.694 Y-12.457 E.01728 +;WIDTH:0.506358 +G1 X-32.702 Y-13.131 E.02543 +G1 X-32.276 Y-13.137 E.01611 +;LAYER_CHANGE +;Z:0.6 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;0.6 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-32.235 Y-12.465 +G1 X-32.694 Y-12.457 +G1 X-32.702 Y-13.131 +G1 X-32.508 Y-13.134 +;WIPE_END +;AFTER_LAYER_CHANGE +;0.6 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z1 I-1.202 J-.189 P1 F30000 +G1 X-36.005 Y9.111 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.488907 +G1 F16816.634 +G1 X-36.026 Y9.203 E.00342 +;WIDTH:0.526803 +G1 F15499.614 +G1 X-36.14 Y10.007 E.03204 +G1 X-36.142 Y10.593 E.02309 +G1 X-36.535 Y10.181 E.02246 +G1 X-36.859 Y10.008 E.01449 +G1 X-36.525 Y9.797 E.01562 +G1 X-36.232 Y9.531 E.01558 +G1 X-36.024 Y9.146 E.01725 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15499.614 +G1 X-36.026 Y9.203 +G1 X-36.14 Y10.007 +G1 X-36.142 Y10.593 +G1 X-36.518 Y10.198 +;WIPE_END +G1 X-30.342 Y5.714 Z1 F30000 +G1 X-15.478 Y-5.076 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-32.69 Y-5.071 E.57093 +G1 X-33.543 Y-5.223 E.02874 +G1 X-34.366 Y-5.59 E.02992 +G1 X-35.073 Y-6.151 E.02992 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.966 Y-7.702 E.02992 +G1 X-36.059 Y-8.29 E.01975 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.094 Y-8.379 E.00356 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.129 Y-8.468 E.00394 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.164 Y-8.557 E.00432 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.199 Y-8.647 E.0047 +G1 X-36.199 Y-12.469 E.18732 +G1 X-36.099 Y-13.336 E.04282 +;WIDTH:0.655271 +G1 F12247.877 +G1 X-35.922 Y-13.855 E.02733 +G1 X-35.719 Y-14.318 E.02524 +;WIDTH:0.643373 +G1 F12490.569 +G1 X-35.251 Y-14.966 E.03914 +;WIDTH:0.655276 +G1 F12247.778 +G1 X-34.846 Y-15.336 E.02733 +G1 X-34.445 Y-15.644 E.02524 +;WIDTH:0.643373 +G1 F12490.569 +G1 X-33.722 Y-15.986 E.03914 +;WIDTH:0.655273 +G1 F12247.837 +G1 X-33.19 Y-16.116 E.02734 +G1 X-32.69 Y-16.192 E.02524 +;WIDTH:0.642409 +G1 F12510.656 +G1 X-15.479 Y-16.192 E.84078 +G1 X-15.362 Y-16.11 E.00695 +;WIDTH:0.594307 +G1 F13602.075 +G1 X-15.246 Y-16.028 E.00639 +;WIDTH:0.546204 +G1 F14902.124 +G1 X-15.13 Y-15.946 E.00583 +;WIDTH:0.498102 +G1 F16476.943 +G1 X-15.014 Y-15.864 E.00528 +;WIDTH:0.449999 +G1 F18000 +G1 X-14.898 Y-15.516 E.01218 +G1 X-14.898 Y-5.651 E.32722 +G1 X-14.961 Y-5.388 E.00899 +G1 X-15.153 Y-5.17 E.00962 +G1 X-15.4 Y-5.076 E.00876 +G1 X-15.438 Y-5.076 E.00128 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.479 Y-5.463 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X-32.69 Y-5.463 E.52885 +G1 X-33.469 Y-5.608 E.02436 +G1 X-34.199 Y-5.945 E.02471 +G1 X-34.823 Y-6.453 E.02471 +G1 X-35.3 Y-7.1 E.02471 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.614 Y-13.275 E.02496 +G1 X-35.323 Y-14.025 E.02471 +G1 X-34.855 Y-14.679 E.02471 +G1 X-34.239 Y-15.196 E.02471 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02524 +G1 X-15.479 Y-15.704 E.52942 +G1 X-15.328 Y-15.629 E.00518 +G1 X-15.29 Y-15.516 E.00366 +G1 X-15.29 Y-5.651 E.30311 +G1 X-15.355 Y-5.509 E.0048 +G1 X-15.441 Y-5.477 E.00283 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-17.441 Y-5.475 +;WIPE_END +G1 X-10.055 Y-3.552 Z1 F30000 +G1 X35.919 Y8.419 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.449999 +G1 F18000 +G1 X35.9 Y8.547 E.00427 +G1 X36.003 Y9.071 E.01772 +G1 X36.265 Y9.51 E.01696 +G1 X36.415 Y9.669 E.00726 +G1 X36.898 Y10 E.01943 +G1 X36.724 Y10.106 E.00677 +G1 X36.317 Y10.42 E.01705 +G1 X36.06 Y10.814 E.0156 +G1 X35.98 Y11.016 E.00722 +G1 X35.899 Y11.527 E.01716 +G1 X36.005 Y12.075 E.01853 +G1 X36.265 Y12.51 E.01679 +G1 X36.428 Y12.679 E.00781 +G1 X36.867 Y12.966 E.0174 +G1 X36.759 Y13.593 E.02113 +G1 X36.588 Y14.119 E.01832 +G1 X36.353 Y14.618 E.01832 +G1 X36.057 Y15.084 E.01832 +G1 X35.705 Y15.51 E.01832 +G1 X35.303 Y15.888 E.01832 +G1 X34.856 Y16.212 E.01832 +G1 X34.372 Y16.478 E.01832 +G1 X33.859 Y16.682 E.01832 +G1 X33.324 Y16.819 E.01832 +G1 X32.776 Y16.888 E.01832 +G1 X-32.776 Y16.888 E2.17448 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.372 Y16.478 E.01832 +G1 X-34.856 Y16.212 E.01832 +G1 X-35.303 Y15.888 E.01832 +G1 X-35.705 Y15.51 E.01831 +G1 X-36.041 Y15.104 E.01746 +G1 X-36.307 Y14.691 E.0163 +G1 X-36.54 Y14.221 E.0174 +;WIDTH:0.498867 +G1 F16449.307 +G1 X-36.555 Y14.108 E.00425 +;WIDTH:0.547734 +G1 F14856.972 +G1 X-36.57 Y13.994 E.00471 +;WIDTH:0.596601 +G1 F13545.712 +G1 X-36.584 Y13.881 E.00517 +;WIDTH:0.645468 +G1 F12447.141 +G1 X-36.599 Y13.767 E.00562 +G1 X-36.668 Y13.551 E.01115 +;WIDTH:0.649624 +G1 F12361.877 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.196 Y12.587 E.02646 +G1 X-36.07 Y13.461 E.04363 +;WIDTH:0.642407 +G1 F12510.696 +G1 X-35.996 Y13.582 E.00696 +;WIDTH:0.593296 +G1 F13627.048 +G1 X-35.922 Y13.704 E.00639 +;WIDTH:0.544185 +G1 F14962.147 +G1 X-35.848 Y13.826 E.00582 +;WIDTH:0.495074 +G1 F16587.268 +G1 X-35.688 Y14.169 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-35.395 Y14.603 E.01736 +M73 P35 R4 +G1 X-35.073 Y15.016 E.01736 +M73 P35 R4 +G1 X-34.489 Y15.499 E.02512 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.75 Y16.096 E.09751 +G1 X-28.819 Y15.899 E.03157 +G1 X-28.011 Y15.499 E.02992 +G1 X-27.328 Y14.91 E.02992 +G1 X-26.812 Y14.169 E.02992 +G1 X-26.497 Y13.324 E.02992 +G1 X-26.398 Y12.501 E.02752 +G1 X-26.398 Y9.958 E.08436 +G1 X-26.534 Y9.035 E.03094 +G1 X-26.883 Y8.203 E.02992 +G1 X-27.427 Y7.484 E.02992 +G1 X-28.134 Y6.924 E.02992 +G1 X-28.957 Y6.556 E.02992 +G1 X-29.81 Y6.404 E.02874 +G1 X-32.75 Y6.404 E.09751 +G1 X-33.681 Y6.601 E.03157 +G1 X-34.489 Y7.001 E.02992 +G1 X-35.172 Y7.59 E.02992 +G1 X-35.551 Y8.112 E.02138 +G1 X-35.719 Y8.409 E.01133 +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.02241 +G1 X-35.907 Y8.609 E.01366 +;WIDTH:0.451257 +G1 F18000 +G1 X-35.924 Y8.308 E.01001 +;WIDTH:0.449999 +G1 X-36.06 Y7.814 E.01701 +G1 X-36.331 Y7.404 E.0163 +G1 X-36.724 Y7.106 E.01635 +G1 X-36.898 Y7.027 E.00634 +G1 X-36.898 Y-8.237 E.50633 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.874 Y-8.344 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.849 Y-8.451 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.825 Y-8.559 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.801 Y-8.666 E.00539 +G1 X-36.801 Y-12.493 E.18755 +;WIDTH:0.655262 +G1 F12248.058 +G1 X-36.756 Y-13.057 E.02825 +G1 X-36.659 Y-13.587 E.0269 +G1 X-36.484 Y-14.098 E.02694 +G1 X-36.258 Y-14.587 E.0269 +G1 X-35.962 Y-15.039 E.02694 +G1 X-35.621 Y-15.456 E.0269 +G1 X-35.221 Y-15.82 E.02694 +G1 X-34.787 Y-16.14 E.0269 +G1 X-34.31 Y-16.392 E.02694 +G1 X-33.81 Y-16.594 E.0269 +G1 X-33.285 Y-16.72 E.02694 +G1 X-32.69 Y-16.792 E.02994 +;WIDTH:0.642409 +G1 F12510.656 +G1 X-15.479 Y-16.792 E.84078 +G1 X-15.234 Y-16.816 E.01198 +;WIDTH:0.594307 +G1 F13602.075 +G1 X-14.99 Y-16.84 E.01102 +;WIDTH:0.546204 +G1 F14902.124 +G1 X-14.746 Y-16.864 E.01006 +;WIDTH:0.498102 +G1 F16476.943 +G1 X-14.502 Y-16.888 E.0091 +;WIDTH:0.449999 +G1 F18000 +G1 X32.776 Y-16.888 E1.5683 +G1 X33.324 Y-16.819 E.01832 +G1 X33.859 Y-16.682 E.01832 +G1 X34.372 Y-16.478 E.01832 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01831 +G1 X36.057 Y-15.084 E.01832 +G1 X36.353 Y-14.618 E.01831 +G1 X36.588 Y-14.119 E.01832 +G1 X36.759 Y-13.593 E.01832 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y7.027 E.64712 +G1 X36.725 Y7.105 E.00631 +G1 X36.414 Y7.331 E.01272 +G1 X36.265 Y7.49 E.00724 +G1 X36.06 Y7.814 E.01272 +G1 X35.98 Y8.016 E.00722 +G1 X35.925 Y8.38 E.0122 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.293 Y8.465 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X36.365 Y8.911 E.01388 +G1 X36.596 Y9.3 E.01391 +G1 X36.954 Y9.577 E.01392 +G1 X37.29 Y9.668 E.01069 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01408 +G1 X36.391 Y11.024 E.01393 +G1 X36.293 Y11.465 E.01388 +G1 X36.365 Y11.911 E.01388 +G1 X36.596 Y12.3 E.01391 +G1 X36.954 Y12.577 E.01392 +G1 X37.279 Y12.666 E.01035 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.553 Y16.19 E.01848 +G1 X-35.991 Y15.779 E.01848 +G1 X-36.375 Y15.315 E.01848 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.254 E.01708 +G1 X-37.279 Y12.666 E.01813 +G1 X-37.024 Y12.608 E.00804 +G1 X-36.895 Y12.534 E.00458 +G1 X-36.571 Y12.26 E.01303 +;WIDTH:0.469935 +G1 X-36.413 Y11.972 E.01141 +;WIDTH:0.519871 +G1 X-36.255 Y11.685 E.01274 +;WIDTH:0.543309 +G1 X-36.271 Y11.194 E.02002 +G1 X-36.356 Y11.059 E.00651 +;WIDTH:0.502206 +G1 X-36.441 Y10.924 E.00598 +;WIDTH:0.461103 +G1 X-36.526 Y10.789 E.00544 +;WIDTH:0.419999 +G1 X-36.826 Y10.498 E.01283 +G1 X-37.29 Y10.309 E.0154 +G1 X-37.29 Y9.668 E.01969 +G1 X-37.024 Y9.608 E.00838 +G1 X-36.644 Y9.352 E.01408 +G1 X-36.391 Y8.976 E.01392 +G1 X-36.293 Y8.535 E.01388 +G1 X-36.365 Y8.089 E.01388 +G1 X-36.596 Y7.7 E.01391 +G1 X-36.954 Y7.423 E.01392 +G1 X-37.29 Y7.332 E.01069 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.397 Y-17.205 E.01848 +G1 X33.98 Y-17.055 E.01848 +G1 X34.539 Y-16.834 E.01848 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.697 Y-14.807 E.01848 +G1 X36.953 Y-14.263 E.01848 +G1 X37.139 Y-13.691 E.01848 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.331 E.60916 +G1 X37.024 Y7.391 E.00837 +G1 X36.645 Y7.648 E.01408 +G1 X36.391 Y8.024 E.01393 +G1 X36.302 Y8.426 E.01265 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.365 Y8.911 +G1 X36.596 Y9.3 +G1 X36.954 Y9.577 +G1 X37.29 Y9.668 +G1 X37.29 Y9.926 +;WIPE_END +G1 X29.658 Y9.836 Z1 F30000 +G1 X-26.901 Y9.172 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X-26.898 Y9.179 E.00022 +G1 X-26.79 Y9.987 E.02504 +G1 X-26.79 Y12.501 E.07724 +G1 X-26.886 Y13.275 E.02398 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.645 Y14.679 E.02471 +G1 X-28.261 Y15.196 E.02471 +G1 X-28.986 Y15.544 E.02471 +G1 X-29.792 Y15.704 E.02524 +G1 X-32.69 Y15.704 E.08905 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.823 Y14.714 E.02471 +G1 X-35.3 Y14.066 E.02471 +G1 X-35.577 Y13.383 E.02266 +G1 X-35.677 Y12.77 E.01907 +G1 X-35.71 Y12.13 E.01969 +;WIDTH:0.455133 +G1 X-35.728 Y11.963 E.00566 +;WIDTH:0.490267 +G1 X-35.745 Y11.795 E.00614 +;WIDTH:0.525401 +G1 X-35.763 Y11.628 E.00662 +G1 X-35.741 Y10.975 E.02568 +;WIDTH:0.481411 +G1 X-35.725 Y10.667 E.01103 +;WIDTH:0.450705 +G1 X-35.71 Y10.359 E.01025 +;WIDTH:0.419999 +G1 X-35.665 Y9.635 E.02228 +G1 X-35.585 Y9.137 E.0155 +G1 X-35.323 Y8.475 E.02188 +G1 X-34.823 Y7.786 E.02615 +G1 X-34.239 Y7.304 E.02327 +G1 X-33.514 Y6.956 E.02471 +G1 X-32.708 Y6.796 E.02524 +G1 X-29.81 Y6.796 E.08905 +G1 X-29.031 Y6.941 E.02436 +G1 X-28.301 Y7.278 E.02471 +G1 X-27.677 Y7.786 E.02471 +G1 X-27.2 Y8.434 E.02471 +G1 X-26.916 Y9.135 E.02326 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.898 Y9.179 +G1 X-26.79 Y9.987 +G1 X-26.79 Y11.125 +;WIPE_END +G1 X-34.247 Y9.495 Z1 F30000 +G1 X-36.005 Y9.111 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.00467 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.157 Y6.392 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.419999 +G1 F15000 +G1 X-36.506 Y4.044 E.10205 +G1 X-36.506 Y6.782 E.08414 +G1 X-36.068 Y7.116 E.01692 +G1 X-35.731 Y7.6 E.01812 +G1 X-35.691 Y7.638 E.00171 +G1 X-35.389 Y7.256 E.01496 +G1 X-34.572 Y6.593 E.03235 +G1 X-34.193 Y6.409 E.01291 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.795 Y6.288 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +M73 P36 R4 +G1 F15000 +M73 P36 R4 +G1 X-36.129 Y4.954 E.05798 +G1 X-36.129 Y6.593 E.05037 +G1 X-35.663 Y6.992 E.01885 +G1 X-34.826 Y6.313 E.03311 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.335 Y6.26 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.389785 +G1 F15000 +G1 X-35.767 Y5.828 E.01728 +G1 X-35.767 Y6.415 E.01659 +G1 X-35.643 Y6.51 E.00442 +G1 X-35.366 Y6.285 E.01009 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.15 Y14.098 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.434223 +G1 F15000 +G1 X-35.964 Y14.491 E.01384 +G1 X-35.413 Y15.236 E.02954 +G1 X-35.04 Y15.571 E.01598 +G1 X-34.633 Y15.878 E.01627 +G1 X-33.851 Y16.258 E.02771 +G1 X-33.246 Y16.409 E.0199 +G1 X-32.75 Y16.491 E.01602 +G1 X-29.81 Y16.492 E.09373 +;WIDTH:0.447724 +G1 X-29.586 Y16.473 E.00743 +;WIDTH:0.487695 +G1 X-29.361 Y16.454 E.00817 +;WIDTH:0.527666 +G1 X-29.137 Y16.435 E.0089 +;WIDTH:0.547651 +G1 F14859.401 +G1 X-28.912 Y16.416 E.00927 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.829 Y15.76 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.382762 +G1 F15000 +G1 X-26.256 Y15.333 E.01673 +G1 X-26.59 Y15.76 E.015 +G1 X-25.869 Y15.76 E.01995 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.964 Y16.119 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-26.292 Y14.791 E.05772 +G1 X-26.849 Y15.517 E.02811 +G1 X-27.587 Y16.119 E.02928 +G1 X-25.004 Y16.119 E.07939 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.053 Y16.496 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-26.396 Y14.154 E.10179 +G1 X-26.568 Y14.531 E.01276 +G1 X-27.148 Y15.287 E.02927 +G1 X-27.931 Y15.909 E.03072 +;WIDTH:0.443233 +G1 X-28.226 Y16.066 E.0109 +;WIDTH:0.489701 +G1 X-28.521 Y16.223 E.01217 +;WIDTH:0.536169 +G1 X-28.815 Y16.38 E.01343 +;WIDTH:0.573483 +G1 F14135.927 +G1 X-28.872 Y16.412 E.00282 +;WIDTH:0.566618 +G1 F14321.245 +G1 X-28.618 Y16.433 E.01091 +;WIDTH:0.524727 +G1 F15000 +G1 X-28.363 Y16.454 E.01004 +;WIDTH:0.482836 +G1 X-28.108 Y16.475 E.00916 +;WIDTH:0.422667 +G1 X-27.853 Y16.496 E.00791 +G1 X-24.093 Y16.496 E.11634 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.928 Y-4.691 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.395564 +G1 F15000 +G1 X-15.721 Y-4.452 E.00907 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-15.928 Y-4.691 +;WIPE_END +G1 X-10.841 Y-10.381 Z1 F30000 +G1 X-5.095 Y-16.809 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +;TYPE:Top surface +;WIDTH:0.422256 +G1 F12000 +G1 X-4.185 Y-15.899 E.03974 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-15.291 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.692 Y-16.869 E.06901 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-6.228 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-14.754 E.09246 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-14.218 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-6.765 Y-16.869 E.11592 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-7.301 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-13.681 E.13937 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-13.145 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-7.837 Y-16.869 E.16282 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-8.374 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-12.608 E.18627 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-12.072 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-8.91 Y-16.869 E.20972 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-9.447 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-11.535 E.23317 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-10.999 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-9.983 Y-16.869 E.25662 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.52 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-10.462 E.28007 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-9.926 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-11.056 Y-16.869 E.30353 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-11.593 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-9.389 E.32698 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-8.853 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-12.129 Y-16.869 E.35043 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-12.666 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-8.317 E.37388 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-7.78 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-13.202 Y-16.869 E.39733 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-13.739 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-7.244 E.42078 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-6.707 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.275 Y-16.869 E.44423 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.763 Y-16.821 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.113 Y-6.171 E.46559 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.113 Y-5.635 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.212 Y-16.733 E.48516 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.621 Y-16.606 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.213 Y-5.198 E.49871 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P37 R4 +G1 X-4.395 Y-4.843 F30000 +M73 P37 R4 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.879 Y-15.327 E.45832 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.879 Y-14.791 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.662 Y-4.573 E.44666 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.015 Y-4.39 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.879 Y-14.255 E.43123 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.879 Y-13.718 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.448 Y-4.287 E.4123 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.983 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.879 Y-13.182 E.38891 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.88 Y-12.646 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-6.519 Y-4.286 E.36546 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-7.056 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.88 Y-12.109 E.34201 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.88 Y-11.573 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-7.592 Y-4.286 E.31857 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-8.129 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.88 Y-11.036 E.29512 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.88 Y-10.5 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-8.665 Y-4.286 E.27167 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-9.202 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.88 Y-9.964 E.24822 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.88 Y-9.427 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-9.738 Y-4.286 E.22478 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.275 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.88 Y-8.891 E.20133 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.88 Y-8.355 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-10.811 Y-4.286 E.17788 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-11.348 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.88 Y-7.818 E.15443 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.88 Y-7.282 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-11.884 Y-4.286 E.13099 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-12.421 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.881 Y-6.745 E.10754 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.881 Y-6.209 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-12.957 Y-4.286 E.08409 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-13.493 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.881 Y-5.673 E.06064 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.02 Y-5.275 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.03 Y-4.286 E.04327 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.566 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.35 Y-5.069 E.03427 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.835 Y-5.018 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.143 Y-4.325 E.03028 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-15.835 Y-5.018 +;WIPE_END +G1 X-23.379 Y-6.175 Z1 F30000 +G1 X-36.473 Y-8.183 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.506967 +G1 F15000 +G1 X-36.454 Y-7.955 E.00863 +;WIDTH:0.527272 +G1 X-36.435 Y-7.728 E.009 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-36.454 Y-7.955 +;WIPE_END +G1 X-28.853 Y-8.651 Z1 F30000 +G1 X36.333 Y-14.614 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.421495 +G1 F15000 +G1 X35.182 Y-15.742 E.04973 +G1 X34.755 Y-16.053 E.0163 +G1 X34.292 Y-16.307 E.0163 +G1 X33.982 Y-16.43 E.01028 +G1 X36.436 Y-13.975 E.10709 +G1 X36.576 Y-13.546 E.01392 +G1 X36.634 Y-13.243 E.00954 +G1 X33.236 Y-16.64 E.14821 +G1 X32.764 Y-16.7 E.01468 +G1 X32.641 Y-16.7 E.00379 +G1 X36.699 Y-12.642 E.17702 +G1 X36.709 Y-12.096 E.01684 +G1 X32.106 Y-16.7 E.20083 +G1 X31.571 Y-16.7 E.01652 +G1 X36.709 Y-11.561 E.22419 +G1 X36.709 Y-11.025 E.01652 +G1 X31.035 Y-16.7 E.24755 +G1 X30.5 Y-16.7 E.01652 +G1 X36.709 Y-10.49 E.2709 +G1 X36.709 Y-9.955 E.01652 +G1 X29.964 Y-16.7 E.29426 +G1 X29.429 Y-16.7 E.01652 +G1 X36.709 Y-9.419 E.31762 +G1 X36.709 Y-8.884 E.01652 +G1 X28.894 Y-16.7 E.34098 +G1 X28.358 Y-16.7 E.01652 +G1 X36.709 Y-8.348 E.36433 +G1 X36.709 Y-7.813 E.01652 +G1 X27.823 Y-16.7 E.38769 +M73 P38 R4 +G1 X27.288 Y-16.7 E.01652 +M73 P38 R4 +G1 X36.709 Y-7.278 E.41105 +G1 X36.709 Y-6.742 E.01652 +G1 X26.752 Y-16.7 E.43441 +G1 X26.217 Y-16.7 E.01652 +G1 X36.709 Y-6.207 E.45776 +G1 X36.709 Y-5.672 E.01652 +G1 X25.681 Y-16.7 E.48112 +G1 X25.146 Y-16.7 E.01652 +G1 X36.709 Y-5.136 E.50448 +G1 X36.709 Y-4.601 E.01652 +G1 X24.611 Y-16.7 E.52783 +G1 X24.075 Y-16.7 E.01652 +G1 X36.709 Y-4.065 E.55119 +G1 X36.709 Y-3.53 E.01652 +G1 X23.54 Y-16.7 E.57455 +G1 X23.004 Y-16.7 E.01652 +G1 X36.709 Y-2.995 E.59791 +G1 X36.709 Y-2.459 E.01652 +G1 X22.469 Y-16.7 E.62126 +G1 X21.934 Y-16.7 E.01652 +G1 X36.709 Y-1.924 E.64462 +G1 X36.709 Y-1.388 E.01652 +G1 X21.398 Y-16.7 E.66798 +G1 X20.863 Y-16.7 E.01652 +G1 X36.709 Y-.853 E.69134 +G1 X36.709 Y-.318 E.01652 +G1 X20.328 Y-16.7 E.71469 +G1 X19.792 Y-16.7 E.01652 +G1 X36.709 Y.218 E.73805 +G1 X36.709 Y.753 E.01652 +G1 X19.257 Y-16.7 E.76141 +G1 X18.721 Y-16.7 E.01652 +G1 X36.709 Y1.288 E.78477 +G1 X36.709 Y1.824 E.01652 +G1 X18.186 Y-16.7 E.80812 +G1 X17.651 Y-16.7 E.01652 +G1 X36.709 Y2.359 E.83148 +G1 X36.709 Y2.895 E.01652 +G1 X17.115 Y-16.7 E.85484 +G1 X16.58 Y-16.7 E.01652 +G1 X36.709 Y3.43 E.8782 +G1 X36.709 Y3.965 E.01652 +G1 X16.044 Y-16.7 E.90155 +G1 X15.509 Y-16.7 E.01652 +G1 X36.709 Y4.501 E.92491 +G1 X36.709 Y5.036 E.01652 +G1 X14.974 Y-16.7 E.94827 +G1 X14.438 Y-16.7 E.01652 +G1 X36.709 Y5.572 E.97163 +G1 X36.709 Y6.107 E.01652 +G1 X13.903 Y-16.7 E.99498 +G1 X13.368 Y-16.7 E.01652 +G1 X36.709 Y6.642 E1.01834 +G1 X36.709 Y6.91 E.00826 +G1 X36.539 Y7.007 E.00605 +G1 X12.832 Y-16.7 E1.03426 +G1 X12.297 Y-16.7 E.01652 +G1 X36.241 Y7.245 E1.04463 +M73 P39 R4 +G1 X36.105 Y7.39 E.00614 +M73 P39 R4 +G1 X36.006 Y7.545 E.00568 +G1 X11.761 Y-16.7 E1.05774 +G1 X11.226 Y-16.7 E.01652 +G1 X35.821 Y7.896 E1.07303 +G1 X35.734 Y8.344 E.01408 +G1 X10.691 Y-16.7 E1.09258 +G1 X10.155 Y-16.7 E.01652 +G1 X35.777 Y8.922 E1.11779 +G1 X35.803 Y9.06 E.00434 +G1 X35.901 Y9.288 E.00765 +G1 X36.174 Y9.703 E.01534 +G1 X36.303 Y9.822 E.0054 +G1 X36.549 Y10 E.00936 +G1 X36.416 Y10.097 E.00507 +G1 X9.62 Y-16.7 E1.16904 +G1 X9.084 Y-16.7 E.01652 +G1 X36.137 Y10.353 E1.18023 +G1 X35.925 Y10.676 E.01192 +G1 X8.549 Y-16.7 E1.19432 +G1 X8.014 Y-16.7 E.01652 +G1 X35.785 Y11.072 E1.21158 +G1 X35.712 Y11.44 E.01158 +G1 X35.717 Y11.54 E.00308 +G1 X7.478 Y-16.7 E1.23199 +G1 X6.943 Y-16.7 E.01652 +G1 X35.867 Y12.224 E1.26187 +G1 X36.177 Y12.707 E.0177 +G1 X36.303 Y12.822 E.00526 +G1 X36.665 Y13.079 E.01369 +G1 X36.588 Y13.481 E.01263 +G1 X6.408 Y-16.7 E1.31669 +G1 X5.872 Y-16.7 E.01652 +G1 X36.463 Y13.892 E1.33461 +G1 X36.412 Y14.049 E.0051 +G1 X36.308 Y14.271 E.00758 +G1 X5.337 Y-16.7 E1.35117 +G1 X4.801 Y-16.7 E.01652 +G1 X36.125 Y14.625 E1.36658 +G1 X35.918 Y14.952 E.01197 +G1 X4.266 Y-16.7 E1.38087 +G1 X3.731 Y-16.7 E.01652 +G1 X35.677 Y15.247 E1.39375 +G1 X35.417 Y15.522 E.01168 +G1 X3.195 Y-16.7 E1.40573 +G1 X2.66 Y-16.7 E.01652 +G1 X35.135 Y15.776 E1.41682 +G1 X34.825 Y16.001 E.01182 +M73 P40 R4 +G1 X2.124 Y-16.7 E1.42665 +M73 P40 R4 +G1 X1.589 Y-16.7 E.01652 +G1 X34.488 Y16.199 E1.43529 +G1 X34.292 Y16.307 E.00691 +G1 X34.126 Y16.373 E.00549 +G1 X1.054 Y-16.7 E1.44286 +G1 X.518 Y-16.7 E.01652 +G1 X33.736 Y16.518 E1.4492 +G1 X33.31 Y16.628 E.01357 +G1 X-.017 Y-16.7 E1.45397 +G1 X-.553 Y-16.7 E.01652 +G1 X32.837 Y16.69 E1.4567 +G1 X32.311 Y16.7 E.01623 +G1 X-1.088 Y-16.7 E1.45711 +G1 X-1.623 Y-16.7 E.01652 +G1 X31.776 Y16.7 E1.45711 +G1 X31.24 Y16.7 E.01652 +G1 X-2.159 Y-16.7 E1.45711 +G1 X-2.694 Y-16.7 E.01652 +G1 X30.705 Y16.7 E1.45711 +G1 X30.17 Y16.7 E.01652 +G1 X-3.229 Y-16.7 E1.45711 +G1 X-3.765 Y-16.7 E.01652 +G1 X29.634 Y16.7 E1.45711 +G1 X29.099 Y16.7 E.01652 +G1 X-4.3 Y-16.7 E1.45711 +G1 X-4.459 Y-16.7 E.00489 +G1 X-4.311 Y-16.556 E.00636 +G1 X-4.148 Y-16.323 E.00877 +G1 X-4.002 Y-16 E.01094 +G1 X-3.944 Y-15.808 E.00617 +G1 X28.563 Y16.7 E1.41822 +G1 X28.028 Y16.7 E.01652 +G1 X-3.906 Y-15.234 E1.39317 +G1 X-3.906 Y-14.699 E.01652 +G1 X27.493 Y16.7 E1.36981 +G1 X26.957 Y16.7 E.01652 +G1 X-3.906 Y-14.163 E1.34646 +G1 X-3.906 Y-13.628 E.01652 +G1 X26.422 Y16.7 E1.3231 +G1 X25.887 Y16.7 E.01652 +G1 X-3.906 Y-13.093 E1.29974 +M73 P41 R4 +G1 X-3.906 Y-12.557 E.01652 +M73 P41 R4 +G1 X25.351 Y16.7 E1.27639 +G1 X24.816 Y16.7 E.01652 +G1 X-3.906 Y-12.022 E1.25303 +G1 X-3.906 Y-11.486 E.01652 +G1 X24.28 Y16.7 E1.22967 +G1 X23.745 Y16.7 E.01652 +G1 X-3.906 Y-10.951 E1.20631 +G1 X-3.906 Y-10.416 E.01652 +G1 X23.21 Y16.7 E1.18296 +G1 X22.674 Y16.7 E.01652 +G1 X-3.906 Y-9.88 E1.1596 +G1 X-3.906 Y-9.345 E.01652 +G1 X22.139 Y16.7 E1.13624 +G1 X21.603 Y16.7 E.01652 +G1 X-3.906 Y-8.809 E1.11288 +G1 X-3.906 Y-8.274 E.01652 +G1 X21.068 Y16.7 E1.08953 +G1 X20.533 Y16.7 E.01652 +G1 X-3.906 Y-7.739 E1.06617 +G1 X-3.906 Y-7.203 E.01652 +G1 X19.997 Y16.7 E1.04281 +G1 X19.462 Y16.7 E.01652 +G1 X-3.906 Y-6.668 E1.01945 +G1 X-3.906 Y-6.133 E.01652 +G1 X18.927 Y16.7 E.9961 +G1 X18.391 Y16.7 E.01652 +G1 X-3.908 Y-5.599 E.97283 +G1 X-3.946 Y-5.35 E.00777 +G1 X-4.004 Y-5.16 E.00613 +G1 X17.856 Y16.7 E.95369 +G1 X17.32 Y16.7 E.01652 +G1 X-4.176 Y-4.797 E.93783 +G1 X-4.275 Y-4.656 E.00531 +G1 X-4.417 Y-4.503 E.00646 +G1 X16.785 Y16.7 E.92499 +G1 X16.25 Y16.7 E.01652 +G1 X-4.735 Y-4.285 E.91551 +G1 X-5.118 Y-4.133 E.01272 +G1 X15.714 Y16.7 E.90887 +G1 X15.179 Y16.7 E.01652 +G1 X-5.598 Y-4.077 E.90642 +G1 X-6.117 Y-4.061 E.01602 +G1 X14.643 Y16.7 E.90571 +G1 X14.108 Y16.7 E.01652 +G1 X-6.652 Y-4.061 E.90571 +G1 X-7.187 Y-4.061 E.01652 +G1 X13.573 Y16.7 E.90571 +G1 X13.037 Y16.7 E.01652 +G1 X-7.723 Y-4.061 E.90571 +G1 X-8.258 Y-4.061 E.01652 +G1 X12.502 Y16.7 E.90571 +M73 P42 R4 +G1 X11.966 Y16.7 E.01652 +M73 P42 R4 +G1 X-8.794 Y-4.061 E.90571 +G1 X-9.329 Y-4.061 E.01652 +G1 X11.431 Y16.7 E.90571 +G1 X10.896 Y16.7 E.01652 +G1 X-9.864 Y-4.061 E.90571 +G1 X-10.4 Y-4.061 E.01652 +G1 X10.36 Y16.7 E.90571 +G1 X9.825 Y16.7 E.01652 +G1 X-10.935 Y-4.061 E.90571 +G1 X-11.471 Y-4.061 E.01652 +G1 X9.29 Y16.7 E.90571 +G1 X8.754 Y16.7 E.01652 +G1 X-12.006 Y-4.061 E.90571 +G1 X-12.541 Y-4.061 E.01652 +G1 X8.219 Y16.7 E.90571 +G1 X7.683 Y16.7 E.01652 +G1 X-13.077 Y-4.061 E.90571 +G1 X-13.612 Y-4.061 E.01652 +G1 X7.148 Y16.7 E.90571 +G1 X6.613 Y16.7 E.01652 +G1 X-14.147 Y-4.061 E.90571 +G1 X-14.683 Y-4.061 E.01652 +G1 X6.077 Y16.7 E.90571 +G1 X5.542 Y16.7 E.01652 +G1 X-15.293 Y-4.136 E.90898 +G1 X-15.512 Y-4.179 E.00688 +G1 X-15.692 Y-3.999 E.00785 +G1 X5.006 Y16.7 E.90301 +G1 X4.471 Y16.7 E.01652 +G1 X-17.11 Y-4.882 E.94154 +G1 X-17.646 Y-4.882 E.01652 +G1 X3.936 Y16.7 E.94154 +G1 X3.4 Y16.7 E.01652 +G1 X-18.181 Y-4.882 E.94154 +G1 X-18.717 Y-4.882 E.01652 +G1 X2.865 Y16.7 E.94154 +G1 X2.33 Y16.7 E.01652 +G1 X-19.252 Y-4.882 E.94154 +G1 X-19.787 Y-4.882 E.01652 +G1 X1.794 Y16.7 E.94154 +G1 X1.259 Y16.7 E.01652 +G1 X-20.323 Y-4.882 E.94154 +G1 X-20.858 Y-4.882 E.01652 +G1 X.723 Y16.7 E.94154 +G1 X.188 Y16.7 E.01652 +G1 X-21.394 Y-4.882 E.94154 +G1 X-21.929 Y-4.882 E.01652 +G1 X-.347 Y16.7 E.94154 +G1 X-.883 Y16.7 E.01652 +G1 X-22.464 Y-4.882 E.94154 +G1 X-23 Y-4.882 E.01652 +G1 X-1.418 Y16.7 E.94154 +M73 P43 R4 +G1 X-1.954 Y16.7 E.01652 +M73 P43 R4 +G1 X-23.535 Y-4.882 E.94154 +G1 X-24.07 Y-4.882 E.01652 +G1 X-2.489 Y16.7 E.94154 +G1 X-3.024 Y16.7 E.01652 +G1 X-24.606 Y-4.882 E.94154 +G1 X-25.141 Y-4.882 E.01652 +G1 X-3.56 Y16.7 E.94154 +G1 X-4.095 Y16.7 E.01652 +G1 X-25.677 Y-4.882 E.94154 +G1 X-26.212 Y-4.882 E.01652 +G1 X-4.63 Y16.7 E.94154 +G1 X-5.166 Y16.7 E.01652 +G1 X-26.747 Y-4.882 E.94154 +G1 X-27.283 Y-4.882 E.01652 +G1 X-5.701 Y16.7 E.94154 +G1 X-6.237 Y16.7 E.01652 +G1 X-27.818 Y-4.882 E.94154 +G1 X-28.354 Y-4.882 E.01652 +G1 X-6.772 Y16.7 E.94154 +G1 X-7.307 Y16.7 E.01652 +G1 X-28.889 Y-4.882 E.94154 +G1 X-29.424 Y-4.882 E.01652 +G1 X-7.843 Y16.7 E.94154 +G1 X-8.378 Y16.7 E.01652 +G1 X-29.96 Y-4.882 E.94154 +G1 X-30.495 Y-4.882 E.01652 +G1 X-8.914 Y16.7 E.94154 +G1 X-9.449 Y16.7 E.01652 +G1 X-31.03 Y-4.882 E.94154 +G1 X-31.566 Y-4.882 E.01652 +G1 X-9.984 Y16.7 E.94154 +G1 X-10.52 Y16.7 E.01652 +G1 X-32.101 Y-4.882 E.94154 +G1 X-32.637 Y-4.882 E.01652 +G1 X-11.055 Y16.7 E.94154 +G1 X-11.59 Y16.7 E.01652 +G1 X-33.269 Y-4.979 E.94575 +G1 X-33.577 Y-5.037 E.00968 +G1 X-33.762 Y-5.098 E.00601 +G1 X-34.066 Y-5.24 E.01036 +G1 X-12.126 Y16.7 E.95718 +G1 X-12.661 Y16.7 E.01652 +G1 X-36.553 Y-7.192 E1.04233 +G1 X-36.692 Y-6.922 E.00937 +G1 X-36.692 Y-6.796 E.0039 +G1 X-13.197 Y16.7 E1.02503 +G1 X-13.732 Y16.7 E.01652 +G1 X-36.692 Y-6.26 E1.00167 +G1 X-36.692 Y-5.725 E.01652 +G1 X-14.267 Y16.7 E.97832 +G1 X-14.803 Y16.7 E.01652 +G1 X-36.692 Y-5.19 E.95496 +M73 P44 R4 +G1 X-36.692 Y-4.654 E.01652 +M73 P44 R4 +G1 X-15.338 Y16.7 E.9316 +G1 X-15.874 Y16.7 E.01652 +G1 X-36.692 Y-4.119 E.90824 +G1 X-36.692 Y-3.583 E.01652 +G1 X-16.409 Y16.7 E.88489 +G1 X-16.944 Y16.7 E.01652 +G1 X-36.692 Y-3.048 E.86153 +G1 X-36.692 Y-2.513 E.01652 +G1 X-17.48 Y16.7 E.83817 +G1 X-18.015 Y16.7 E.01652 +G1 X-36.692 Y-1.977 E.81481 +G1 X-36.692 Y-1.442 E.01652 +G1 X-28.861 Y6.389 E.34162 +G1 X-29.529 Y6.256 E.02101 +G1 X-36.692 Y-.906 E.31248 +G1 X-36.692 Y-.371 E.01652 +G1 X-30.105 Y6.215 E.28735 +G1 X-30.641 Y6.215 E.01652 +G1 X-36.692 Y.164 E.26399 +G1 X-36.692 Y.7 E.01652 +G1 X-31.176 Y6.215 E.24063 +G1 X-31.712 Y6.215 E.01652 +G1 X-36.692 Y1.235 E.21727 +G1 X-36.692 Y1.77 E.01652 +G1 X-32.247 Y6.215 E.19392 +G1 X-32.768 Y6.229 E.01609 +G1 X-36.692 Y2.306 E.17117 +G1 X-36.692 Y2.841 E.01652 +G1 X-33.224 Y6.309 E.15129 +G1 X-33.668 Y6.4 E.01399 +G1 X-36.879 Y3.189 E.14008 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-35.465 Y4.604 +;WIPE_END +G1 X-28.504 Y7.734 Z1 F30000 +G1 X-26.71 Y8.54 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-18.551 Y16.7 E.35598 +G1 X-19.086 Y16.7 E.01652 +G1 X-26.274 Y9.512 E.31358 +G1 X-26.209 Y10.111 E.0186 +G1 X-19.621 Y16.7 E.28742 +G1 X-20.157 Y16.7 E.01652 +G1 X-26.209 Y10.647 E.26406 +G1 X-26.209 Y11.182 E.01652 +G1 X-20.692 Y16.7 E.24071 +G1 X-21.227 Y16.7 E.01652 +G1 X-26.209 Y11.718 E.21735 +G1 X-26.209 Y12.253 E.01652 +G1 X-21.763 Y16.7 E.19399 +G1 X-22.298 Y16.7 E.01652 +G1 X-26.24 Y12.758 E.17197 +G1 X-26.297 Y13.236 E.01486 +G1 X-22.834 Y16.7 E.1511 +G1 X-23.369 Y16.7 E.01652 +G1 X-26.539 Y13.53 E.13829 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-25.125 Y14.944 +;WIPE_END +G1 X-23.256 Y7.544 Z1 F30000 +G1 X-19.107 Y-8.882 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X-19.211 Y-8.855 E.00332 +;WIDTH:0.427052 +G1 X-19.426 Y-8.94 E.00724 +G1 X-20.677 Y-11.1 E.07814 +;WIDTH:0.469865 +G1 X-20.69 Y-11.185 E.00298 +;WIDTH:0.515372 +G1 X-20.704 Y-11.269 E.0033 +;WIDTH:0.560879 +G1 X-20.717 Y-11.354 E.00361 +G1 X-20.648 Y-11.371 E.00297 +;WIDTH:0.527373 +G1 X-20.58 Y-11.388 E.00278 +;WIDTH:0.493867 +G1 X-20.512 Y-11.404 E.00258 +;WIDTH:0.46036 +G1 X-19.155 Y-11.404 E.04615 +;WIDTH:0.465453 +G1 X-18.944 Y-11.404 E.00728 +G1 X-18.946 Y-9.065 E.08056 +;WIDTH:0.462469 +G1 X-19.08 Y-8.912 E.00694 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-19.211 Y-8.855 +G1 X-19.426 Y-8.94 +G1 X-20.241 Y-10.347 +;WIPE_END +G1 X-18.521 Y-11.404 Z1 F30000 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.636894 +G1 F12000 +G1 X-18.521 Y-11.352 E.00253 +;WIDTH:0.594034 +G1 X-18.521 Y-11.3 E.00235 +;WIDTH:0.551174 +G1 X-18.521 Y-11.247 E.00217 +;WIDTH:0.508314 +G1 X-18.521 Y-11.195 E.00198 +;WIDTH:0.465453 +G1 X-18.526 Y-8.948 E.07736 +;WIDTH:0.462469 +G1 X-18.507 Y-8.421 E.01805 +;WIDTH:0.419999 +G1 X-18.509 Y-7.529 E.02738 +G1 X-19.064 Y-7.537 E.01705 +G1 X-19.467 Y-8.236 E.0248 +;WIDTH:0.427052 +G1 X-21.007 Y-10.909 E.09655 +;WIDTH:0.458976 +G1 X-21.058 Y-11.033 E.00455 +;WIDTH:0.493594 +G1 X-21.11 Y-11.157 E.00492 +;WIDTH:0.528211 +G1 X-21.161 Y-11.28 E.0053 +;WIDTH:0.560879 +G1 X-21.188 Y-11.772 E.02076 +G1 X-20.962 Y-11.788 E.00953 +;WIDTH:0.527373 +G1 X-20.737 Y-11.805 E.00892 +;WIDTH:0.493867 +G1 X-20.512 Y-11.822 E.0083 +;WIDTH:0.46036 +G1 X-18.944 Y-11.822 E.05333 +;WIDTH:0.465447 +G1 X-18.945 Y-13.115 E.04454 +;WIDTH:0.464459 +G1 X-18.523 Y-13.116 E.01446 +G1 X-18.523 Y-12.906 E.00724 +;WIDTH:0.507568 +G1 X-18.523 Y-12.635 E.01026 +;WIDTH:0.550677 +G1 X-18.522 Y-12.364 E.01121 +;WIDTH:0.593786 +G1 X-18.522 Y-12.093 E.01216 +;WIDTH:0.636894 +G1 X-18.521 Y-11.822 E.01311 +G1 X-18.486 Y-11.822 E.0017 +;WIDTH:0.595021 +G1 X-18.451 Y-11.822 E.00158 +;WIDTH:0.553148 +G1 X-18.416 Y-11.822 E.00146 +;WIDTH:0.511274 +G1 X-18.125 Y-11.822 E.01111 +;WIDTH:0.485394 +G1 X-17.834 Y-11.821 E.0105 +;WIDTH:0.459514 +G1 X-17.835 Y-11.406 E.01409 +;WIDTH:0.503859 +G1 X-18.006 Y-11.406 E.00645 +;WIDTH:0.548204 +G1 X-18.178 Y-11.405 E.00707 +;WIDTH:0.592549 +G1 X-18.35 Y-11.405 E.00769 +;WIDTH:0.636894 +G1 X-18.481 Y-11.404 E.00637 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-18.521 Y-11.352 +G1 X-18.521 Y-11.3 +G1 X-18.521 Y-11.247 +G1 X-18.521 Y-11.195 +G1 X-18.525 Y-9.418 +;WIPE_END +G1 X-18.922 Y-8.404 Z1 F30000 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.647228 +G1 F12410.89 +G1 X-18.959 Y-8.338 E.00372 +G1 X-19.035 Y-8.338 E.00372 +G1 X-19.073 Y-8.404 E.00372 +G1 X-19.035 Y-8.469 E.00372 +G1 X-18.959 Y-8.469 E.00372 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P44 R3 +G1 E-.8 F2100 +M73 P44 R3 +;WIPE_START +G1 F12410.89 +G1 X-19.035 Y-8.469 +G1 X-19.073 Y-8.404 +G1 X-19.035 Y-8.338 +G1 X-18.959 Y-8.338 +G1 X-18.922 Y-8.404 +;WIPE_END +G1 X-22.235 Y-12.421 Z1 F30000 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.504948 +G1 F12000 +G1 X-22.694 Y-12.413 E.01728 +;WIDTH:0.506358 +G1 X-22.702 Y-13.087 E.02543 +G1 X-22.236 Y-13.094 E.01762 +G1 X-22.235 Y-12.461 E.02389 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-22.694 Y-12.413 +G1 X-22.702 Y-13.087 +G1 X-22.236 Y-13.094 +G1 X-22.235 Y-12.695 +;WIPE_END +G1 X-29.865 Y-12.503 Z1 F30000 +G1 X-30.449 Y-12.488 Z1 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.497393 +G1 F12000 +G1 X-30.436 Y-12.404 E.00315 +;WIDTH:0.46201 +G1 X-29.883 Y-11.65 E.03194 +;WIDTH:0.456912 +G1 X-29.004 Y-10.771 E.04193 +;WIDTH:0.456007 +G1 X-28.633 Y-10.363 E.01857 +;WIDTH:0.462985 +G1 X-28.312 Y-9.878 E.0199 +;WIDTH:0.479825 +G1 X-28.123 Y-9.391 E.01861 +;WIDTH:0.485692 +G1 X-28.089 Y-8.861 E.01914 +;WIDTH:0.501258 +G1 X-28.188 Y-8.337 E.01993 +G1 X-28.296 Y-8.118 E.00913 +;WIDTH:0.503208 +G1 X-28.562 Y-7.807 E.01533 +G1 X-28.999 Y-7.592 E.0183 +;WIDTH:0.489869 +G1 X-29.494 Y-7.52 E.01821 +;WIDTH:0.478167 +G1 X-29.97 Y-7.581 E.017 +;WIDTH:0.48849 +G1 X-30.394 Y-7.787 E.01712 +;WIDTH:0.491514 +G1 X-30.717 Y-8.122 E.01701 +G1 X-30.85 Y-8.378 E.01055 +;WIDTH:0.494138 +G1 X-30.985 Y-8.831 E.01738 +G1 X-30.552 Y-8.887 E.01606 +G1 X-30.439 Y-8.531 E.01374 +;WIDTH:0.482133 +G1 X-30.25 Y-8.248 E.01217 +;WIDTH:0.479228 +G1 X-29.897 Y-8.011 E.01514 +G1 X-29.424 Y-7.949 E.01696 +;WIDTH:0.479511 +G1 X-28.974 Y-8.074 E.0166 +;WIDTH:0.499672 +G1 X-28.675 Y-8.371 E.0157 +G1 X-28.535 Y-8.844 E.01835 +;WIDTH:0.490001 +G1 X-28.551 Y-9.294 E.01642 +;WIDTH:0.481287 +G1 X-28.696 Y-9.704 E.01553 +;WIDTH:0.459851 +G1 X-28.978 Y-10.135 E.01751 +;WIDTH:0.455999 +G1 X-29.331 Y-10.519 E.01753 +;WIDTH:0.456912 +G1 X-30.217 Y-11.398 E.0421 +G1 X-30.768 Y-12.164 E.03185 +;WIDTH:0.499441 +G1 X-30.828 Y-12.342 E.00699 +;WIDTH:0.547548 +G1 X-30.888 Y-12.521 E.00773 +;WIDTH:0.595654 +G1 X-30.948 Y-12.699 E.00847 +G1 X-30.973 Y-13.096 E.01793 +G1 X-30.734 Y-13.115 E.01082 +;WIDTH:0.557973 +G1 X-30.494 Y-13.134 E.01008 +;WIDTH:0.520292 +G1 X-30.255 Y-13.152 E.00934 +;WIDTH:0.482611 +G1 X-27.992 Y-13.154 E.08106 +;WIDTH:0.479059 +G1 X-27.993 Y-12.718 E.01548 +G1 X-28.21 Y-12.717 E.00771 +;WIDTH:0.482611 +G1 X-30.254 Y-12.713 E.07324 +;WIDTH:0.520292 +G1 X-30.328 Y-12.694 E.00297 +;WIDTH:0.557973 +G1 X-30.402 Y-12.675 E.00321 +;WIDTH:0.595654 +G1 X-30.477 Y-12.656 E.00344 +G1 X-30.463 Y-12.572 E.00383 +;WIDTH:0.546524 +G1 X-30.456 Y-12.528 E.00185 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-30.436 Y-12.404 +G1 X-29.883 Y-11.65 +G1 X-29.219 Y-10.986 +;WIPE_END +G1 X-32.236 Y-13.138 Z1 F30000 +G1 Z.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.506358 +M73 P45 R3 +G1 F12000 +M73 P45 R3 +G1 X-32.235 Y-12.465 E.0254 +;WIDTH:0.504949 +G1 X-32.694 Y-12.457 E.01728 +;WIDTH:0.506358 +G1 X-32.702 Y-13.131 E.02543 +G1 X-32.276 Y-13.137 E.01611 +;LAYER_CHANGE +;Z:0.8 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;0.8 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-32.235 Y-12.465 +G1 X-32.694 Y-12.457 +G1 X-32.702 Y-13.131 +G1 X-32.508 Y-13.134 +;WIPE_END +;AFTER_LAYER_CHANGE +;0.8 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z1.2 I-1.202 J-.188 P1 F30000 +G1 X-35.997 Y9.126 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.524325 +G1 F15579.398 +G1 X-36.096 Y9.468 E.01394 +;WIDTH:0.526794 +G1 F15499.903 +G1 X-36.14 Y9.994 E.02085 +G1 X-36.14 Y10.599 E.02382 +G1 X-36.53 Y10.186 E.02238 +G1 X-36.859 Y10.008 E.01476 +G1 X-36.525 Y9.797 E.01562 +G1 X-36.239 Y9.541 E.01511 +G1 X-36.017 Y9.161 E.01735 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15499.903 +G1 X-36.096 Y9.468 +G1 X-36.14 Y9.994 +G1 X-36.14 Y10.599 +G1 X-36.518 Y10.198 +;WIPE_END +G1 X-29.688 Y6.792 Z1.2 F30000 +G1 X-4.919 Y-5.562 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-4.961 Y-5.388 E.00596 +G1 X-5.153 Y-5.17 E.00962 +G1 X-5.414 Y-5.071 E.00925 +G1 X-32.69 Y-5.071 E.90479 +G1 X-33.543 Y-5.223 E.02874 +G1 X-34.366 Y-5.59 E.02992 +G1 X-35.073 Y-6.151 E.02992 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.966 Y-7.702 E.02992 +G1 X-36.059 Y-8.29 E.01975 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.094 Y-8.379 E.00356 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.129 Y-8.468 E.00394 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.164 Y-8.557 E.00432 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.199 Y-8.647 E.0047 +G1 X-36.199 Y-12.469 E.18732 +G1 X-36.099 Y-13.336 E.04282 +;WIDTH:0.655272 +G1 F12247.857 +G1 X-35.922 Y-13.855 E.02733 +G1 X-35.719 Y-14.318 E.02524 +;WIDTH:0.643373 +G1 F12490.569 +G1 X-35.251 Y-14.966 E.03914 +;WIDTH:0.655274 +G1 F12247.818 +G1 X-34.846 Y-15.336 E.02733 +G1 X-34.445 Y-15.644 E.02524 +;WIDTH:0.643373 +G1 F12490.569 +G1 X-33.722 Y-15.986 E.03914 +;WIDTH:0.655272 +G1 F12247.857 +G1 X-33.19 Y-16.116 E.02734 +G1 X-32.69 Y-16.192 E.02524 +;WIDTH:0.642409 +G1 F12510.656 +G1 X-5.479 Y-16.192 E1.32929 +G1 X-5.362 Y-16.11 E.00695 +;WIDTH:0.594307 +G1 F13602.075 +G1 X-5.246 Y-16.028 E.00639 +;WIDTH:0.546204 +G1 F14902.124 +G1 X-5.13 Y-15.946 E.00583 +;WIDTH:0.498102 +G1 F16476.943 +G1 X-5.014 Y-15.864 E.00528 +;WIDTH:0.449999 +G1 F18000 +G1 X-4.898 Y-15.516 E.01218 +G1 X-4.898 Y-5.651 E.32722 +G1 X-4.91 Y-5.601 E.00171 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.29 Y-5.651 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X-5.355 Y-5.509 E.0048 +G1 X-5.479 Y-5.463 E.00406 +G1 X-32.69 Y-5.463 E.83612 +G1 X-33.469 Y-5.608 E.02436 +G1 X-34.199 Y-5.945 E.02471 +G1 X-34.823 Y-6.453 E.02471 +G1 X-35.3 Y-7.1 E.02471 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.614 Y-13.275 E.02496 +G1 X-35.323 Y-14.025 E.02471 +G1 X-34.855 Y-14.679 E.02471 +G1 X-34.239 Y-15.196 E.02471 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02524 +G1 X-5.479 Y-15.704 E.83669 +G1 X-5.328 Y-15.629 E.00518 +G1 X-5.29 Y-15.516 E.00366 +G1 X-5.29 Y-5.691 E.30188 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-5.355 Y-5.509 +G1 X-5.479 Y-5.463 +G1 X-7.153 Y-5.463 +;WIPE_END +G1 X.111 Y-3.121 Z1.2 F30000 +G1 X35.919 Y8.42 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.449999 +G1 F18000 +G1 X35.9 Y8.548 E.00428 +G1 X36.004 Y9.072 E.01772 +G1 X36.265 Y9.51 E.01694 +G1 X36.414 Y9.669 E.00724 +G1 X36.898 Y10 E.01943 +G1 X36.724 Y10.106 E.00675 +G1 X36.318 Y10.419 E.01704 +G1 X36.06 Y10.813 E.01559 +G1 X35.98 Y11.015 E.00724 +G1 X35.898 Y11.528 E.01722 +G1 X36.005 Y12.076 E.01852 +G1 X36.265 Y12.51 E.01679 +G1 X36.428 Y12.679 E.00777 +G1 X36.867 Y12.966 E.01742 +G1 X36.759 Y13.593 E.02113 +G1 X36.588 Y14.119 E.01832 +G1 X36.353 Y14.618 E.01832 +G1 X36.057 Y15.084 E.01832 +G1 X35.705 Y15.51 E.01832 +G1 X35.303 Y15.888 E.01832 +G1 X34.856 Y16.212 E.01832 +G1 X34.372 Y16.478 E.01832 +G1 X33.859 Y16.682 E.01832 +G1 X33.324 Y16.819 E.01832 +G1 X32.776 Y16.888 E.01832 +G1 X-32.776 Y16.888 E2.17448 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.372 Y16.478 E.01832 +G1 X-34.856 Y16.212 E.01832 +G1 X-35.303 Y15.888 E.01832 +G1 X-35.705 Y15.51 E.01831 +G1 X-36.041 Y15.104 E.01746 +G1 X-36.307 Y14.691 E.0163 +G1 X-36.54 Y14.221 E.0174 +;WIDTH:0.498867 +G1 F16449.307 +G1 X-36.555 Y14.108 E.00425 +;WIDTH:0.547734 +G1 F14856.972 +G1 X-36.57 Y13.994 E.00471 +;WIDTH:0.596601 +G1 F13545.712 +G1 X-36.584 Y13.881 E.00517 +;WIDTH:0.645468 +G1 F12447.141 +G1 X-36.599 Y13.767 E.00562 +G1 X-36.668 Y13.551 E.01115 +;WIDTH:0.649623 +G1 F12361.897 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.196 Y12.587 E.02646 +G1 X-36.07 Y13.461 E.04363 +;WIDTH:0.642407 +G1 F12510.696 +G1 X-35.996 Y13.582 E.00696 +;WIDTH:0.593299 +G1 F13626.991 +G1 X-35.922 Y13.704 E.00639 +;WIDTH:0.54419 +G1 F14962.008 +G1 X-35.848 Y13.826 E.00582 +;WIDTH:0.495081 +G1 F16587.011 +G1 X-35.688 Y14.169 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-35.395 Y14.603 E.01736 +G1 X-35.073 Y15.016 E.01736 +G1 X-34.489 Y15.499 E.02512 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.75 Y16.096 E.09751 +G1 X-28.819 Y15.899 E.03157 +G1 X-28.011 Y15.499 E.02992 +G1 X-27.328 Y14.91 E.02992 +G1 X-26.812 Y14.169 E.02992 +G1 X-26.497 Y13.324 E.02992 +G1 X-26.398 Y12.501 E.02752 +G1 X-26.398 Y9.958 E.08436 +G1 X-26.534 Y9.035 E.03094 +G1 X-26.883 Y8.203 E.02992 +G1 X-27.427 Y7.484 E.02992 +G1 X-28.134 Y6.924 E.02992 +G1 X-28.957 Y6.556 E.02992 +G1 X-29.81 Y6.404 E.02874 +G1 X-32.75 Y6.404 E.09751 +G1 X-33.681 Y6.601 E.03157 +G1 X-34.475 Y6.991 E.02935 +G1 X-34.952 Y7.371 E.02023 +G1 X-35.373 Y7.832 E.02072 +G1 X-35.532 Y8.087 E.00996 +;WIDTH:0.487443 +G1 F16872.037 +G1 X-35.691 Y8.339 E.01079 +;WIDTH:0.524886 +G1 F15561.263 +G1 X-35.851 Y8.59 E.0117 +G1 X-35.893 Y8.389 E.00808 +;WIDTH:0.480128 +G1 F17154.306 +G1 X-36.06 Y7.814 E.02132 +;WIDTH:0.449999 +G1 F18000 +G1 X-36.34 Y7.395 E.01673 +G1 X-36.725 Y7.105 E.01596 +G1 X-36.898 Y7.027 E.00631 +G1 X-36.898 Y-8.237 E.50633 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.874 Y-8.344 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.849 Y-8.451 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.825 Y-8.559 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.801 Y-8.666 E.00539 +G1 X-36.801 Y-12.493 E.18755 +;WIDTH:0.655262 +G1 F12248.058 +G1 X-36.756 Y-13.057 E.02825 +G1 X-36.659 Y-13.587 E.0269 +G1 X-36.484 Y-14.098 E.02694 +G1 X-36.258 Y-14.587 E.0269 +G1 X-35.962 Y-15.039 E.02694 +G1 X-35.621 Y-15.456 E.0269 +G1 X-35.221 Y-15.82 E.02694 +G1 X-34.787 Y-16.14 E.0269 +G1 X-34.31 Y-16.392 E.02694 +G1 X-33.81 Y-16.594 E.0269 +G1 X-33.285 Y-16.72 E.02694 +G1 X-32.69 Y-16.792 E.02994 +;WIDTH:0.642409 +G1 F12510.656 +G1 X-5.479 Y-16.792 E1.32929 +G1 X-5.234 Y-16.816 E.01198 +;WIDTH:0.594307 +G1 F13602.075 +G1 X-4.99 Y-16.84 E.01102 +;WIDTH:0.546204 +G1 F14902.124 +G1 X-4.746 Y-16.864 E.01006 +;WIDTH:0.498102 +G1 F16476.943 +G1 X-4.502 Y-16.888 E.0091 +;WIDTH:0.449999 +G1 F18000 +G1 X32.776 Y-16.888 E1.23658 +G1 X33.324 Y-16.819 E.01832 +G1 X33.859 Y-16.682 E.01832 +G1 X34.372 Y-16.478 E.01832 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01831 +G1 X36.057 Y-15.084 E.01832 +G1 X36.353 Y-14.618 E.01832 +G1 X36.588 Y-14.119 E.01832 +G1 X36.759 Y-13.593 E.01832 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +M73 P46 R3 +G1 X36.898 Y7.027 E.64712 +M73 P46 R3 +G1 X36.724 Y7.106 E.00633 +G1 X36.414 Y7.331 E.01272 +G1 X36.265 Y7.49 E.00723 +G1 X36.06 Y7.814 E.01272 +G1 X35.98 Y8.016 E.00722 +G1 X35.925 Y8.381 E.01222 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.293 Y8.465 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X36.364 Y8.905 E.01368 +G1 X36.596 Y9.3 E.01409 +G1 X36.954 Y9.577 E.01391 +G1 X37.29 Y9.669 E.01069 +G1 X37.29 Y10.332 E.02037 +G1 X37.024 Y10.392 E.00838 +G1 X36.645 Y10.648 E.01405 +G1 X36.391 Y11.023 E.01392 +G1 X36.293 Y11.465 E.01393 +G1 X36.365 Y11.911 E.01388 +G1 X36.596 Y12.3 E.0139 +G1 X36.955 Y12.577 E.01393 +G1 X37.279 Y12.666 E.01033 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.553 Y16.19 E.01848 +G1 X-35.991 Y15.779 E.01848 +G1 X-36.375 Y15.315 E.01848 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.254 E.01707 +G1 X-37.279 Y12.666 E.01814 +G1 X-37.024 Y12.608 E.00804 +G1 X-36.895 Y12.534 E.00458 +G1 X-36.573 Y12.262 E.01294 +;WIDTH:0.453485 +G1 X-36.467 Y12.07 E.00736 +;WIDTH:0.486971 +G1 X-36.361 Y11.877 E.00796 +;WIDTH:0.520456 +G1 X-36.254 Y11.684 E.00856 +;WIDTH:0.538045 +G1 X-36.264 Y11.23 E.01834 +G1 X-36.344 Y11.093 E.0064 +;WIDTH:0.498697 +G1 X-36.423 Y10.956 E.00589 +;WIDTH:0.459348 +G1 X-36.503 Y10.819 E.00538 +;WIDTH:0.419999 +G1 X-36.824 Y10.5 E.0139 +G1 X-37.29 Y10.309 E.01546 +G1 X-37.29 Y9.668 E.01969 +G1 X-37.024 Y9.608 E.00838 +G1 X-36.645 Y9.352 E.01405 +G1 X-36.391 Y8.977 E.01392 +G1 X-36.293 Y8.535 E.01391 +G1 X-36.365 Y8.089 E.01388 +G1 X-36.596 Y7.7 E.01392 +G1 X-36.955 Y7.423 E.01393 +G1 X-37.29 Y7.331 E.01067 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.397 Y-17.205 E.01848 +G1 X33.98 Y-17.055 E.01848 +G1 X34.539 Y-16.834 E.01848 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.697 Y-14.807 E.01848 +G1 X36.953 Y-14.263 E.01848 +G1 X37.139 Y-13.691 E.01848 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.332 E.60916 +G1 X37.024 Y7.392 E.00838 +G1 X36.644 Y7.648 E.01408 +G1 X36.391 Y8.024 E.01392 +G1 X36.302 Y8.426 E.01267 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.364 Y8.905 +G1 X36.596 Y9.3 +G1 X36.954 Y9.577 +G1 X37.29 Y9.669 +G1 X37.29 Y9.927 +;WIPE_END +G1 X29.658 Y9.833 Z1.2 F30000 +G1 X-26.916 Y9.136 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X-26.898 Y9.179 E.00144 +G1 X-26.79 Y9.987 E.02504 +G1 X-26.79 Y12.501 E.07724 +G1 X-26.886 Y13.275 E.02398 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.645 Y14.679 E.02471 +G1 X-28.261 Y15.196 E.02471 +G1 X-28.986 Y15.544 E.02471 +G1 X-29.792 Y15.704 E.02524 +G1 X-32.69 Y15.704 E.08905 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.823 Y14.714 E.02471 +G1 X-35.3 Y14.066 E.02471 +G1 X-35.577 Y13.383 E.02266 +G1 X-35.677 Y12.77 E.01907 +G1 X-35.71 Y12.113 E.02021 +;WIDTH:0.455295 +G1 X-35.728 Y11.941 E.00581 +;WIDTH:0.490591 +G1 X-35.745 Y11.769 E.00631 +;WIDTH:0.525887 +G1 X-35.763 Y11.597 E.00681 +G1 X-35.741 Y11.024 E.02259 +;WIDTH:0.481488 +G1 X-35.725 Y10.712 E.01116 +;WIDTH:0.450744 +G1 X-35.71 Y10.4 E.01038 +;WIDTH:0.419999 +G1 X-35.68 Y9.574 E.02538 +G1 X-35.492 Y8.841 E.02327 +G1 X-35.084 Y8.097 E.02608 +G1 X-34.662 Y7.636 E.01919 +G1 X-34.235 Y7.3 E.0167 +G1 X-33.514 Y6.956 E.02454 +G1 X-32.708 Y6.796 E.02524 +G1 X-29.81 Y6.796 E.08905 +G1 X-29.031 Y6.941 E.02436 +G1 X-28.301 Y7.278 E.02471 +G1 X-27.677 Y7.786 E.02471 +G1 X-27.2 Y8.434 E.02471 +G1 X-26.931 Y9.099 E.02205 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.898 Y9.179 +G1 X-26.79 Y9.987 +G1 X-26.79 Y11.085 +;WIPE_END +G1 X-34.255 Y9.497 Z1.2 F30000 +G1 X-35.997 Y9.126 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.531362 +G1 F15354.945 +G1 X-35.868 Y8.616 E.02094 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15354.945 +G1 X-35.997 Y9.126 +;WIPE_END +G1 X-33.688 Y16.401 Z1.2 F30000 +G1 X-33.649 Y16.525 Z1.2 +G1 Z.8 +G1 E.8 F2100 +;TYPE:Sparse infill +;WIDTH:0.45 +G1 F16200 +G1 X-33.286 Y16.619 E.01244 +G1 X-32.763 Y16.685 E.01747 +G1 X-32.036 Y16.685 E.02411 +G1 X-31.651 Y16.299 E.0181 +G1 X-29.764 Y16.292 E.06258 +G1 X-29.371 Y16.685 E.01843 +G1 X-24.361 Y16.685 E.16622 +G1 X-23.933 Y16.257 E.02007 +G1 X-24.284 Y16.161 E.0121 +G1 X-24.732 Y15.971 E.01613 +G1 X-25.155 Y15.726 E.0162 +G1 X-25.38 Y15.549 E.00951 +G1 E-.8 F2100 +;WIPE_START +G1 F16200 +G1 X-25.155 Y15.726 +G1 X-24.732 Y15.971 +G1 X-24.284 Y16.161 +G1 X-23.933 Y16.257 +G1 X-24.197 Y16.521 +;WIPE_END +G1 X-27.706 Y9.743 Z1.2 F30000 +G1 X-36.694 Y-7.619 Z1.2 +G1 Z.8 +G1 E.8 F2100 +G1 F16200 +G1 X-36.694 Y-5.991 E.05401 +G1 X-35.171 Y-4.468 E.07144 +G1 X-35.377 Y-4.274 E.00937 +G1 X-35.688 Y-3.877 E.01672 +G1 X-35.942 Y-3.456 E.01632 +G1 X-36.073 Y-3.178 E.01018 +G1 X-36.234 Y-2.719 E.01616 +G1 X-36.331 Y-2.27 E.01523 +G1 X-36.356 Y-2.024 E.00821 +G1 X-36.694 Y-1.685 E.01587 +G1 X-36.694 Y1.685 E.11182 +G1 X-36.38 Y2 E.01475 +G1 X-36.379 Y3.091 E.0362 +G1 X-36.279 Y3.888 E.02663 +G1 X-36.227 Y4.075 E.00646 +G1 X-35.953 Y4.766 E.02465 +G1 X-35.771 Y5.067 E.01168 +G1 X-36.694 Y5.991 E.04333 +G1 X-36.694 Y6.9 E.03018 +G1 X-36.605 Y6.941 E.00325 +G1 X-36.195 Y7.252 E.0171 +G1 X-36.129 Y7.335 E.00349 +G1 X4.905 Y-4.358 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +;TYPE:Top surface +;WIDTH:0.422469 +G1 F12000 +G1 X5.815 Y-5.267 E.03978 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-5.877 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X4.296 Y-4.286 E.06959 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X3.759 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-6.413 E.09307 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P47 R3 +G1 X5.887 Y-6.95 F30000 +M73 P47 R3 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X3.222 Y-4.286 E.11655 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X2.686 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-7.487 E.14003 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-8.024 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X2.149 Y-4.286 E.1635 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.612 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-8.56 E.18698 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-9.097 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X1.075 Y-4.286 E.21046 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X.539 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-9.634 E.23394 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-10.171 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X.002 Y-4.286 E.25742 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-.535 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-10.707 E.28089 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-11.244 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-1.072 Y-4.286 E.30437 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-1.608 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-11.781 E.32785 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-12.318 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-2.145 Y-4.286 E.35133 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-2.682 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-12.855 E.3748 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-13.391 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-3.219 Y-4.286 E.39828 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-3.755 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-13.928 E.42176 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.887 Y-14.465 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.292 Y-4.286 E.44524 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.829 Y-4.286 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.887 Y-15.002 E.46871 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.886 Y-15.538 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.278 Y-4.374 E.48831 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.621 Y-4.567 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.785 Y-15.973 E.49891 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.431 Y-16.156 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.881 Y-5.844 E.45102 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.881 Y-6.381 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X4.894 Y-16.156 E.42753 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X4.515 Y-16.313 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.88 Y-6.918 E.41095 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.88 Y-7.455 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X4.356 Y-16.691 E.40399 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X3.997 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.88 Y-7.992 E.3883 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.88 Y-8.528 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X3.461 Y-16.869 E.36482 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X2.924 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.88 Y-9.065 E.34134 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.88 Y-9.602 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X2.387 Y-16.869 E.31786 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.85 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.88 Y-10.139 E.29438 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.88 Y-10.676 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X1.314 Y-16.869 E.2709 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P48 R3 +G1 X.777 Y-16.869 F30000 +M73 P48 R3 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.88 Y-11.213 E.24742 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.88 Y-11.749 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X.24 Y-16.869 E.22393 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-.297 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.88 Y-12.286 E.20045 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.88 Y-12.823 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-.833 Y-16.869 E.17697 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-1.37 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.879 Y-13.36 E.15349 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.879 Y-13.897 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-1.907 Y-16.869 E.13001 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-2.444 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.879 Y-14.434 E.10653 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.879 Y-14.971 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-2.981 Y-16.869 E.08304 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-3.517 Y-16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.885 Y-15.502 E.0598 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.03 Y-15.893 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.054 Y-16.869 E.04269 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.608 Y-16.852 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.327 Y-16.133 E.03145 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-4.608 Y-16.852 +;WIPE_END +G1 X2.644 Y-14.474 Z1.2 F30000 +G1 X36.694 Y-3.308 Z1.2 +G1 Z.8 +G1 E.8 F2100 +;TYPE:Sparse infill +;WIDTH:0.45 +G1 F16200 +G1 X36.694 Y-1.933 E.04561 +G1 X36.543 Y-1.968 E.00514 +G1 X36.5 Y-1.88 E.00326 +G1 X36.434 Y-1.946 E.0031 +G1 X36.337 Y-2.097 E.00595 +G1 X36.284 Y-2.518 E.01409 +G1 X36.234 Y-2.719 E.00685 +G1 X35.942 Y-3.456 E.02631 +G1 X35.841 Y-3.636 E.00685 +G1 X35.405 Y-4.237 E.02461 +G1 X35.172 Y-4.468 E.0109 +G1 X36.694 Y-5.991 E.07143 +G1 X36.694 Y-9.361 E.11182 +G1 X36.385 Y-9.671 E.01452 +G1 X36.385 Y-12.493 E.09361 +G1 X36.293 Y-13.265 E.02578 +G1 X36.563 Y-13.535 E.01266 +G1 X36.398 Y-14.043 E.01773 +G1 X36.174 Y-14.52 E.01747 +G1 X35.892 Y-14.964 E.01747 +G1 X35.556 Y-15.37 E.01747 +G1 X35.173 Y-15.731 E.01747 +G1 X34.747 Y-16.04 E.01747 +G1 X34.285 Y-16.294 E.01747 +G1 X33.796 Y-16.488 E.01747 +G1 X33.286 Y-16.619 E.01747 +G1 X32.763 Y-16.685 E.01747 +G1 X32.036 Y-16.685 E.02411 +G1 X31.73 Y-16.378 E.01438 +G1 X29.679 Y-16.376 E.06802 +G1 X29.371 Y-16.685 E.01445 +G1 X24.361 Y-16.685 E.16622 +G1 X24.048 Y-16.372 E.01467 +G1 X22.01 Y-16.37 E.06761 +G1 X21.695 Y-16.685 E.01475 +G1 X16.685 Y-16.685 E.16622 +G1 X14.685 Y-14.685 E.0938 +G1 X14.856 Y-14.447 E.0097 +G1 X15 Y-14.148 E.01102 +G1 X15.158 Y-14.469 E.01187 +G1 X15.613 Y-15.091 E.02555 +G1 X14.019 Y-16.685 E.07474 +G1 X9.009 Y-16.685 E.16622 +G1 X8.702 Y-16.378 E.01436 +G1 X6.65 Y-16.378 E.06809 +G1 X6.343 Y-16.685 E.01437 +G1 X4.715 Y-16.685 E.05401 +G1 X36.405 Y9.963 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.38292 +G1 F15000 +G1 X36.341 Y10 E.00204 +G1 X36.406 Y10.035 E.00204 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.634 Y16.313 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.421912 +G1 F15000 +G1 X35.572 Y15.374 E.04098 +G1 X35.904 Y14.973 E.01608 +G1 X36.187 Y14.527 E.01632 +G1 X36.437 Y13.974 E.01873 +G1 X33.98 Y16.431 E.10729 +G1 X33.8 Y16.502 E.00597 +G1 X33.235 Y16.64 E.01798 +G1 X36.634 Y13.241 E.14846 +G1 X36.665 Y13.079 E.00509 +G1 X36.428 Y12.911 E.00897 +G1 X32.639 Y16.7 E.16548 +G1 X32.103 Y16.7 E.01655 +G1 X36.145 Y12.658 E.17652 +G1 X35.932 Y12.335 E.01195 +G1 X31.567 Y16.7 E.19063 +G1 X31.031 Y16.7 E.01655 +G1 X35.782 Y11.949 E.20749 +G1 X35.714 Y11.481 E.0146 +G1 X30.495 Y16.7 E.22793 +G1 X29.959 Y16.7 E.01655 +G1 X35.849 Y10.81 E.25722 +G1 X36.161 Y10.319 E.01797 +G1 X36.114 Y10.273 E.00202 +G1 X36.152 Y9.971 E.00939 +G1 X29.423 Y16.7 E.29387 +G1 X28.888 Y16.7 E.01655 +G1 X36.059 Y9.528 E.31322 +G1 X35.856 Y9.195 E.01204 +G1 X28.352 Y16.7 E.32774 +G1 X27.816 Y16.7 E.01655 +G1 X35.749 Y8.767 E.34647 +G1 X35.71 Y8.488 E.00869 +G1 X35.753 Y8.226 E.0082 +G1 X27.28 Y16.7 E.37009 +G1 X26.744 Y16.7 E.01655 +G1 X36.709 Y6.734 E.43526 +G1 X36.709 Y6.198 E.01655 +G1 X26.208 Y16.7 E.45867 +G1 X25.672 Y16.7 E.01655 +G1 X36.709 Y5.662 E.48207 +G1 X36.709 Y5.126 E.01655 +G1 X25.136 Y16.7 E.50548 +G1 X24.6 Y16.7 E.01655 +G1 X36.709 Y4.59 E.52889 +G1 X36.709 Y4.054 E.01655 +G1 X24.064 Y16.7 E.5523 +M73 P49 R3 +G1 X23.528 Y16.7 E.01655 +M73 P49 R3 +G1 X36.709 Y3.518 E.57571 +G1 X36.709 Y2.982 E.01655 +G1 X22.992 Y16.7 E.59912 +G1 X22.456 Y16.7 E.01655 +G1 X36.709 Y2.446 E.62253 +G1 X36.709 Y1.91 E.01655 +G1 X21.92 Y16.7 E.64594 +G1 X21.384 Y16.7 E.01655 +G1 X36.709 Y1.374 E.66935 +G1 X36.709 Y.838 E.01655 +G1 X20.848 Y16.7 E.69275 +G1 X20.312 Y16.7 E.01655 +G1 X36.709 Y.302 E.71616 +G1 X36.709 Y-.234 E.01655 +G1 X19.776 Y16.7 E.73957 +G1 X19.24 Y16.7 E.01655 +G1 X36.709 Y-.77 E.76298 +G1 X36.709 Y-1.306 E.01655 +G1 X18.704 Y16.7 E.78639 +G1 X18.168 Y16.7 E.01655 +G1 X36.298 Y-1.431 E.79184 +G1 X36.089 Y-1.758 E.01198 +G1 X17.632 Y16.7 E.80612 +G1 X17.096 Y16.7 E.01655 +G1 X35.937 Y-2.141 E.82287 +G1 X35.897 Y-2.457 E.00984 +G1 X35.86 Y-2.6 E.00457 +G1 X16.56 Y16.7 E.84293 +G1 X16.024 Y16.7 E.01655 +G1 X35.709 Y-2.985 E.85974 +G1 X35.548 Y-3.36 E.0126 +G1 X15.488 Y16.7 E.87611 +G1 X14.952 Y16.7 E.01655 +G1 X35.328 Y-3.676 E.88991 +G1 X35.102 Y-3.987 E.01185 +G1 X14.416 Y16.7 E.90347 +G1 X13.88 Y16.7 E.01655 +G1 X34.826 Y-4.246 E.91481 +G1 X34.534 Y-4.49 E.01175 +G1 X13.344 Y16.7 E.92547 +G1 X12.808 Y16.7 E.01655 +G1 X34.206 Y-4.698 E.93455 +G1 X34.05 Y-4.792 E.00561 +G1 X33.848 Y-4.876 E.00677 +G1 X12.272 Y16.7 E.94231 +G1 X11.736 Y16.7 E.01655 +G1 X35.66 Y-7.224 E1.04486 +G1 X35.859 Y-7.727 E.01673 +G1 X35.912 Y-8.012 E.00893 +G1 X11.2 Y16.7 E1.07928 +G1 X10.664 Y16.7 E.01655 +G1 X35.985 Y-8.621 E1.10589 +G1 X35.993 Y-9.165 E.01679 +G1 X10.128 Y16.7 E1.12963 +M73 P50 R3 +G1 X9.592 Y16.7 E.01655 +M73 P50 R3 +G1 X35.993 Y-9.701 E1.15304 +G1 X35.993 Y-10.237 E.01655 +G1 X9.056 Y16.7 E1.17645 +G1 X8.52 Y16.7 E.01655 +G1 X35.993 Y-10.773 E1.19986 +G1 X35.993 Y-11.309 E.01655 +G1 X7.984 Y16.7 E1.22327 +G1 X7.448 Y16.7 E.01655 +G1 X35.993 Y-11.845 E1.24668 +G1 X35.993 Y-12.381 E.01655 +G1 X6.913 Y16.7 E1.27008 +G1 X6.377 Y16.7 E.01655 +G1 X35.946 Y-12.87 E1.29144 +G1 X35.886 Y-13.346 E.01483 +G1 X5.841 Y16.7 E1.31225 +G1 X5.305 Y16.7 E.01655 +G1 X35.739 Y-13.735 E1.32924 +G1 X35.585 Y-14.117 E.01271 +G1 X4.769 Y16.7 E1.34591 +G1 X4.233 Y16.7 E.01655 +G1 X35.373 Y-14.441 E1.36007 +G1 X35.15 Y-14.753 E.01186 +G1 X3.697 Y16.7 E1.37371 +G1 X3.161 Y16.7 E.01655 +G1 X34.886 Y-15.026 E1.38562 +G1 X34.593 Y-15.269 E.01176 +G1 X2.625 Y16.7 E1.39622 +G1 X2.089 Y16.7 E.01655 +G1 X34.283 Y-15.494 E1.40608 +G1 X33.933 Y-15.68 E.01224 +G1 X1.553 Y16.7 E1.4142 +G1 X1.017 Y16.7 E.01655 +G1 X33.543 Y-15.827 E1.4206 +G1 X33.115 Y-15.935 E.01364 +G1 X.481 Y16.7 E1.42531 +G1 X-.055 Y16.7 E.01655 +G1 X32.631 Y-15.987 E1.42758 +M73 P51 R3 +G1 X32.095 Y-15.986 E.01657 +M73 P51 R3 +G1 X-.591 Y16.7 E1.42756 +G1 X-1.127 Y16.7 E.01655 +G1 X31.558 Y-15.986 E1.42754 +G1 X31.022 Y-15.985 E.01657 +G1 X-1.663 Y16.7 E1.42752 +G1 X-2.199 Y16.7 E.01655 +G1 X30.485 Y-15.985 E1.4275 +G1 X29.949 Y-15.985 E.01657 +G1 X-2.735 Y16.7 E1.42748 +G1 X-3.271 Y16.7 E.01655 +G1 X29.413 Y-15.984 E1.42747 +G1 X28.876 Y-15.984 E.01657 +G1 X-3.807 Y16.7 E1.42745 +G1 X-4.343 Y16.7 E.01655 +G1 X28.34 Y-15.983 E1.42743 +G1 X27.803 Y-15.983 E.01657 +G1 X-4.879 Y16.7 E1.42741 +G1 X-5.415 Y16.7 E.01655 +G1 X27.267 Y-15.982 E1.42739 +G1 X26.731 Y-15.982 E.01657 +G1 X-5.951 Y16.7 E1.42737 +G1 X-6.487 Y16.7 E.01655 +G1 X26.194 Y-15.982 E1.42735 +G1 X25.658 Y-15.981 E.01657 +G1 X-7.023 Y16.7 E1.42733 +G1 X-7.559 Y16.7 E.01655 +G1 X25.121 Y-15.981 E1.42731 +G1 X24.585 Y-15.98 E.01657 +G1 X-8.095 Y16.7 E1.42729 +G1 X-8.631 Y16.7 E.01655 +G1 X24.049 Y-15.98 E1.42728 +G1 X23.512 Y-15.979 E.01657 +G1 X-9.167 Y16.7 E1.42726 +G1 X-9.703 Y16.7 E.01655 +G1 X22.976 Y-15.979 E1.42724 +M73 P52 R3 +G1 X22.439 Y-15.978 E.01657 +M73 P52 R3 +G1 X-10.239 Y16.7 E1.42722 +G1 X-10.775 Y16.7 E.01655 +G1 X21.903 Y-15.978 E1.4272 +G1 X21.366 Y-15.978 E.01657 +G1 X-11.311 Y16.7 E1.42718 +G1 X-11.847 Y16.7 E.01655 +G1 X20.83 Y-15.977 E1.42716 +G1 X20.294 Y-15.977 E.01657 +G1 X-12.383 Y16.7 E1.42714 +G1 X-12.919 Y16.7 E.01655 +G1 X19.757 Y-15.976 E1.42712 +G1 X19.221 Y-15.976 E.01657 +G1 X-13.455 Y16.7 E1.4271 +G1 X-13.991 Y16.7 E.01655 +G1 X18.684 Y-15.975 E1.42709 +G1 X18.205 Y-15.975 E.01479 +G1 X18.135 Y-15.962 E.00222 +G1 X-14.527 Y16.7 E1.42649 +G1 X-15.062 Y16.7 E.01655 +G1 X17.468 Y-15.831 E1.42076 +G1 X17.347 Y-15.791 E.00391 +G1 X16.698 Y-15.486 E.02217 +G1 X16.565 Y-15.401 E.00486 +G1 X16.203 Y-15.101 E.01453 +G1 X15.836 Y-14.735 E.01602 +G1 X15.483 Y-14.249 E.01855 +G1 X15.407 Y-14.11 E.00488 +G1 X15.236 Y-13.679 E.01433 +G1 X14.78 Y-13.679 E.01408 +G1 X-15.598 Y16.7 E1.32678 +G1 X-16.134 Y16.7 E.01655 +G1 X4.653 Y-4.088 E.90789 +G1 X4.943 Y-4.152 E.00917 +G1 X5.218 Y-4.263 E.00918 +G1 X5.463 Y-4.407 E.00878 +G1 X5.611 Y-4.529 E.00593 +G1 X5.755 Y-4.695 E.00679 +G1 X5.897 Y-4.926 E.00837 +G1 X6.021 Y-5.23 E.01014 +G1 X6.085 Y-5.52 E.00917 +G1 X14.617 Y-14.052 E.37263 +G1 X14.518 Y-14.248 E.00679 +G1 X14.417 Y-14.387 E.00533 +G1 X6.094 Y-6.065 E.36347 +G1 X6.094 Y-6.601 E.01655 +G1 X14.191 Y-14.698 E.35362 +G1 X14.095 Y-14.83 E.00504 +G1 X13.94 Y-14.983 E.00673 +G1 X6.094 Y-7.137 E.34267 +G1 X6.094 Y-7.673 E.01655 +G1 X13.647 Y-15.226 E.32986 +G1 X13.344 Y-15.459 E.0118 +G1 X6.094 Y-8.209 E.31663 +G1 X6.094 Y-8.745 E.01655 +G1 X12.984 Y-15.635 E.30092 +G1 X12.616 Y-15.803 E.01249 +G1 X6.094 Y-9.281 E.28485 +G1 X6.094 Y-9.817 E.01655 +G1 X12.175 Y-15.897 E.26555 +G1 X11.718 Y-15.977 E.0143 +G1 X6.094 Y-10.353 E.24562 +G1 X6.094 Y-10.889 E.01655 +G1 X11.192 Y-15.987 E.22264 +G1 X10.656 Y-15.987 E.01655 +G1 X6.094 Y-11.425 E.19923 +G1 X6.094 Y-11.961 E.01655 +G1 X10.12 Y-15.987 E.17581 +G1 X9.584 Y-15.987 E.01655 +G1 X6.094 Y-12.497 E.1524 +G1 X6.094 Y-13.033 E.01655 +G1 X9.048 Y-15.986 E.12899 +G1 X8.512 Y-15.986 E.01655 +G1 X6.094 Y-13.569 E.10558 +G1 X6.094 Y-14.105 E.01655 +G1 X7.976 Y-15.986 E.08216 +G1 X7.44 Y-15.986 E.01655 +G1 X6.094 Y-14.641 E.05875 +G1 X6.094 Y-15.177 E.01655 +G1 X6.904 Y-15.986 E.03534 +G1 X6.368 Y-15.986 E.01655 +G1 X5.924 Y-15.542 E.01938 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X6.368 Y-15.986 +G1 X6.904 Y-15.986 +G1 X6.312 Y-15.395 +;WIPE_END +G1 X4.941 Y-7.886 Z1.2 F30000 +G1 X4.277 Y-4.248 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-16.67 Y16.7 E.91488 +G1 X-17.206 Y16.7 E.01655 +G1 X3.571 Y-4.078 E.90747 +M73 P53 R3 +G1 X3.035 Y-4.078 E.01655 +M73 P53 R3 +G1 X-17.742 Y16.7 E.90747 +G1 X-18.278 Y16.7 E.01655 +G1 X2.499 Y-4.078 E.90747 +G1 X1.963 Y-4.078 E.01655 +G1 X-18.814 Y16.7 E.90747 +G1 X-19.35 Y16.7 E.01655 +G1 X1.427 Y-4.078 E.90747 +G1 X.891 Y-4.078 E.01655 +G1 X-19.886 Y16.7 E.90747 +G1 X-20.422 Y16.7 E.01655 +G1 X.355 Y-4.078 E.90747 +G1 X-.181 Y-4.078 E.01655 +G1 X-20.958 Y16.7 E.90747 +G1 X-21.494 Y16.7 E.01655 +G1 X-.717 Y-4.078 E.90747 +G1 X-1.253 Y-4.078 E.01655 +G1 X-22.03 Y16.7 E.90747 +G1 X-22.566 Y16.7 E.01655 +G1 X-1.789 Y-4.078 E.90747 +G1 X-2.325 Y-4.078 E.01655 +G1 X-22.978 Y16.575 E.90205 +G1 X-22.962 Y16.304 E.0084 +G1 X-23.133 Y16.194 E.00627 +G1 X-2.86 Y-4.078 E.8854 +G1 X-3.396 Y-4.078 E.01655 +G1 X-23.459 Y15.985 E.87624 +G1 X-23.536 Y15.95 E.0026 +G1 X-23.875 Y15.865 E.01081 +G1 X-3.932 Y-4.078 E.871 +G1 X-4.468 Y-4.078 E.01655 +G1 X-24.279 Y15.733 E.86524 +G1 X-24.414 Y15.688 E.00439 +G1 X-24.645 Y15.562 E.00812 +G1 X-4.997 Y-4.085 E.85811 +G1 X-5.218 Y-4.131 E.00698 +G1 X-5.415 Y-4.204 E.00646 +G1 X-24.981 Y15.363 E.85458 +G1 X-25.288 Y15.134 E.01183 +G1 X-5.75 Y-4.404 E.85332 +G1 X-5.904 Y-4.585 E.00733 +G1 X-5.966 Y-4.725 E.00472 +G1 X-25.561 Y14.87 E.85583 +G1 X-25.789 Y14.563 E.01183 +G1 X-6.333 Y-4.893 E.84975 +G1 X-6.869 Y-4.893 E.01656 +G1 X-26.015 Y14.252 E.83618 +G1 X-26.093 Y14.11 E.00501 +G1 X-26.183 Y13.884 E.0075 +G1 X-7.405 Y-4.893 E.8201 +G1 X-7.942 Y-4.893 E.01656 +G1 X-26.335 Y13.5 E.80332 +G1 X-26.255 Y12.885 E.01917 +G1 X-8.478 Y-4.892 E.77643 +G1 X-9.014 Y-4.892 E.01656 +G1 X-26.209 Y12.303 E.75101 +G1 X-26.209 Y11.767 E.01655 +G1 X-9.55 Y-4.892 E.72759 +G1 X-10.086 Y-4.892 E.01656 +G1 X-26.209 Y11.231 E.70417 +G1 X-26.209 Y10.695 E.01655 +G1 X-10.623 Y-4.891 E.68075 +G1 X-11.159 Y-4.891 E.01656 +G1 X-26.209 Y10.159 E.65733 +G1 X-26.209 Y10 E.00491 +M73 P54 R3 +G1 X-26.253 Y9.667 E.01038 +M73 P54 R3 +G1 X-11.695 Y-4.891 E.63583 +G1 X-12.231 Y-4.891 E.01656 +G1 X-26.315 Y9.193 E.61512 +G1 X-26.337 Y9.026 E.00522 +G1 X-26.435 Y8.777 E.00826 +G1 X-12.768 Y-4.891 E.59693 +G1 X-13.304 Y-4.89 E.01656 +G1 X-26.586 Y8.392 E.5801 +G1 X-26.637 Y8.262 E.0043 +G1 X-26.769 Y8.039 E.00801 +G1 X-13.84 Y-4.89 E.5647 +G1 X-14.376 Y-4.89 E.01656 +G1 X-26.998 Y7.732 E.55127 +G1 X-27.227 Y7.425 E.01183 +G1 X-14.912 Y-4.89 E.53785 +G1 X-15.449 Y-4.889 E.01656 +G1 X-27.511 Y7.173 E.52682 +G1 X-27.803 Y6.929 E.01175 +G1 X-15.985 Y-4.889 E.51617 +G1 X-16.521 Y-4.889 E.01656 +G1 X-28.128 Y6.718 E.50695 +G1 X-28.495 Y6.549 E.01247 +G1 X-17.057 Y-4.889 E.49954 +G1 X-17.593 Y-4.888 E.01656 +G1 X-28.869 Y6.387 E.49246 +G1 X-29.316 Y6.298 E.01408 +G1 X-18.13 Y-4.888 E.48858 +G1 X-18.666 Y-4.888 E.01656 +G1 X-29.773 Y6.219 E.4851 +G1 X-30.305 Y6.215 E.01644 +G1 X-19.202 Y-4.888 E.48493 +G1 X-19.738 Y-4.888 E.01656 +G1 X-30.841 Y6.215 E.48492 +G1 X-31.377 Y6.215 E.01655 +G1 X-20.274 Y-4.887 E.48491 +G1 X-20.811 Y-4.887 E.01656 +G1 X-31.913 Y6.215 E.4849 +G1 X-32.449 Y6.215 E.01655 +G1 X-21.347 Y-4.887 E.48489 +G1 X-21.883 Y-4.887 E.01656 +G1 X-33.048 Y6.278 E.48762 +G1 X-33.509 Y6.359 E.01447 +G1 X-33.615 Y6.309 E.0036 +G1 X-22.419 Y-4.886 E.48897 +G1 X-22.955 Y-4.886 E.01656 +G1 X-33.979 Y6.138 E.48147 +G1 X-34.34 Y5.962 E.01238 +G1 X-23.492 Y-4.886 E.4738 +G1 X-24.028 Y-4.886 E.01656 +G1 X-34.643 Y5.729 E.46361 +G1 X-34.936 Y5.487 E.01176 +G1 X-24.564 Y-4.885 E.45301 +G1 X-25.1 Y-4.885 E.01656 +G1 X-35.188 Y5.202 E.44057 +G1 X-35.413 Y4.892 E.01185 +G1 X-25.636 Y-4.885 E.42701 +G1 X-26.173 Y-4.885 E.01656 +G1 X-35.614 Y4.557 E.41237 +G1 X-35.766 Y4.173 E.01275 +G1 X-26.709 Y-4.885 E.39559 +G1 X-27.245 Y-4.884 E.01656 +G1 X-35.899 Y3.769 E.37794 +G1 X-35.959 Y3.294 E.0148 +G1 X-27.781 Y-4.884 E.35717 +G1 X-28.318 Y-4.884 E.01656 +G1 X-35.987 Y2.786 E.33496 +G1 X-35.988 Y2.25 E.01653 +G1 X-28.854 Y-4.884 E.31157 +G1 X-29.39 Y-4.883 E.01656 +G1 X-35.988 Y1.715 E.28819 +G1 X-35.989 Y1.18 E.01653 +G1 X-29.926 Y-4.883 E.2648 +G1 X-30.462 Y-4.883 E.01656 +G1 X-35.99 Y.644 E.24141 +G1 X-35.99 Y.109 E.01653 +G1 X-30.999 Y-4.883 E.21802 +G1 X-31.535 Y-4.882 E.01656 +G1 X-35.991 Y-.426 E.19463 +G1 X-35.992 Y-.961 E.01653 +G1 X-32.071 Y-4.882 E.17124 +G1 X-32.607 Y-4.882 E.01656 +G1 X-35.992 Y-1.497 E.14785 +G1 X-35.959 Y-2.067 E.01763 +G1 X-33.082 Y-4.943 E.12563 +G1 X-33.511 Y-5.025 E.01348 +G1 X-33.559 Y-5.002 E.00164 +G1 X-35.801 Y-2.761 E.0979 +G1 X-35.713 Y-3.023 E.00853 +G1 X-35.593 Y-3.278 E.00872 +G1 X-35.369 Y-3.648 E.01334 +G1 X-35.08 Y-4.016 E.01447 +G1 X-34.217 Y-4.88 E.03771 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-35.08 Y-4.016 +G1 X-35.369 Y-3.648 +G1 X-35.53 Y-3.382 +;WIPE_END +G1 X-28.354 Y-5.981 Z1.2 F30000 +G1 X-9.729 Y-12.727 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.46977 +G1 F12000 +G1 X-9.344 Y-12.768 E.01345 +;WIDTH:0.473322 +G1 X-9.041 Y-12.693 E.01095 +;WIDTH:0.486479 +G1 X-8.799 Y-12.528 E.01059 +G1 X-8.567 Y-12.208 E.0143 +;WIDTH:0.491129 +G1 X-8.429 Y-11.709 E.01891 +;WIDTH:0.492344 +G1 X-8.405 Y-11.337 E.01363 +;WIDTH:0.499176 +G1 X-8.468 Y-10.831 E.01897 +G1 X-8.624 Y-10.421 E.01633 +;WIDTH:0.48825 +G1 X-8.886 Y-10.144 E.01383 +;WIDTH:0.481029 +G1 X-9.111 Y-10.039 E.00886 +;WIDTH:0.461007 +G1 X-9.369 Y-9.991 E.00894 +;WIDTH:0.451514 +G1 X-9.763 Y-10.046 E.01323 +;WIDTH:0.411918 +G1 X-10.16 Y-10.275 E.01379 +;WIDTH:0.421239 +G1 X-10.422 Y-10.665 E.01449 +;WIDTH:0.462401 +G1 X-10.481 Y-10.842 E.00638 +;WIDTH:0.503562 +G1 X-10.54 Y-11.019 E.007 +G1 X-10.483 Y-11.695 E.02547 +;WIDTH:0.473197 +M73 P55 R3 +G1 X-10.307 Y-12.193 E.01849 +M73 P55 R3 +;WIDTH:0.466992 +G1 X-10.028 Y-12.557 E.01585 +;WIDTH:0.46977 +G1 X-9.763 Y-12.707 E.01059 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-9.344 Y-12.768 +G1 X-9.041 Y-12.693 +G1 X-8.799 Y-12.528 +G1 X-8.567 Y-12.208 +G1 X-8.429 Y-11.709 +G1 X-8.425 Y-11.651 +;WIPE_END +G1 X-10.346 Y-9.97 Z1.2 F30000 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.419999 +G1 F12000 +G1 X-10.465 Y-9.955 E.00366 +;WIDTH:0.445404 +G1 X-10.522 Y-9.85 E.00391 +;WIDTH:0.470808 +G1 X-10.579 Y-9.745 E.00416 +G1 X-10.481 Y-9.12 E.02207 +;WIDTH:0.457247 +G1 X-10.297 Y-8.554 E.0201 +;WIDTH:0.468255 +G1 X-10.007 Y-8.139 E.01755 +G1 X-9.737 Y-7.965 E.01114 +;WIDTH:0.464728 +G1 X-9.26 Y-7.908 E.0165 +;WIDTH:0.478156 +G1 X-8.932 Y-8.011 E.0122 +;WIDTH:0.479886 +G1 X-8.676 Y-8.255 E.01259 +;WIDTH:0.480507 +G1 X-8.53 Y-8.573 E.01246 +G1 X-8.127 Y-8.486 E.01473 +G1 X-8.178 Y-8.282 E.00753 +G1 X-8.302 Y-8.025 E.01015 +;WIDTH:0.479563 +G1 X-8.639 Y-7.673 E.01734 +G1 X-9.03 Y-7.513 E.01505 +;WIDTH:0.476786 +G1 X-9.555 Y-7.489 E.01857 +G1 X-9.965 Y-7.604 E.01505 +;WIDTH:0.471083 +G1 X-10.304 Y-7.841 E.01445 +;WIDTH:0.468255 +G1 X-10.605 Y-8.24 E.01729 +;WIDTH:0.471959 +G1 X-10.804 Y-8.703 E.01762 +G1 X-10.894 Y-9.088 E.01385 +;WIDTH:0.469128 +G1 X-11.008 Y-9.762 E.02374 +G1 X-11.058 Y-10.613 E.0296 +;WIDTH:0.461084 +G1 X-11.023 Y-10.905 E.01003 +;WIDTH:0.500929 +G1 X-10.901 Y-11.798 E.03363 +;WIDTH:0.472154 +G1 X-10.738 Y-12.278 E.01773 +;WIDTH:0.477543 +G1 X-10.508 Y-12.664 E.0159 +G1 X-10.228 Y-12.944 E.01403 +;WIDTH:0.473813 +G1 X-9.884 Y-13.123 E.01363 +;WIDTH:0.46977 +G1 X-9.464 Y-13.193 E.01482 +;WIDTH:0.473967 +G1 X-9.075 Y-13.155 E.01372 +;WIDTH:0.481211 +G1 X-8.775 Y-13.046 E.0114 +;WIDTH:0.486479 +G1 X-8.446 Y-12.796 E.01493 +G1 X-8.151 Y-12.356 E.01915 +;WIDTH:0.491129 +G1 X-7.984 Y-11.762 E.02253 +;WIDTH:0.492344 +G1 X-7.957 Y-11.306 E.01676 +;WIDTH:0.499176 +G1 X-8.024 Y-10.736 E.02131 +G1 X-8.211 Y-10.244 E.01959 +;WIDTH:0.487391 +G1 X-8.495 Y-9.897 E.01622 +;WIDTH:0.485344 +G1 X-8.801 Y-9.691 E.01331 +;WIDTH:0.480715 +G1 X-9.23 Y-9.592 E.01571 +;WIDTH:0.454331 +G1 X-9.69 Y-9.62 E.01543 +;WIDTH:0.437349 +G1 X-9.874 Y-9.69 E.00635 +;WIDTH:0.41471 +G1 X-10.312 Y-9.949 E.01543 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.612 Y-10.295 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.454094 +G1 F18000 +G1 X-10.637 Y-10.251 E.00172 +G1 X-10.689 Y-10.251 E.00172 +G1 X-10.714 Y-10.295 E.00172 +G1 X-10.689 Y-10.34 E.00172 +G1 X-10.637 Y-10.34 E.00172 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-10.689 Y-10.34 +G1 X-10.714 Y-10.295 +G1 X-10.689 Y-10.251 +G1 X-10.637 Y-10.251 +G1 X-10.612 Y-10.295 +;WIPE_END +G1 X-12.236 Y-13.096 Z1.2 F30000 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.506358 +G1 F12000 +G1 X-12.235 Y-12.423 E.0254 +;WIDTH:0.504948 +G1 X-12.694 Y-12.415 E.01728 +;WIDTH:0.506358 +G1 X-12.702 Y-13.088 E.02543 +G1 X-12.276 Y-13.095 E.01611 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-12.235 Y-12.423 +G1 X-12.694 Y-12.415 +G1 X-12.702 Y-13.088 +G1 X-12.508 Y-13.092 +;WIPE_END +G1 X-18.944 Y-11.404 Z1.2 F30000 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.465453 +G1 F12000 +G1 X-18.946 Y-9.065 E.08056 +;WIDTH:0.462469 +G1 X-19.107 Y-8.882 E.00831 +;WIDTH:0.419999 +G1 X-19.211 Y-8.855 E.00332 +;WIDTH:0.427052 +G1 X-19.426 Y-8.94 E.00724 +G1 X-20.677 Y-11.1 E.07814 +;WIDTH:0.469864 +G1 X-20.69 Y-11.185 E.00298 +;WIDTH:0.515371 +G1 X-20.704 Y-11.269 E.0033 +;WIDTH:0.560878 +G1 X-20.717 Y-11.354 E.00361 +G1 X-20.648 Y-11.371 E.00297 +;WIDTH:0.527372 +G1 X-20.58 Y-11.388 E.00278 +;WIDTH:0.493866 +G1 X-20.512 Y-11.404 E.00258 +;WIDTH:0.46036 +G1 X-19.155 Y-11.404 E.04615 +;WIDTH:0.465453 +G1 X-18.984 Y-11.404 E.0059 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-18.521 Y-11.404 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.636894 +G1 F12000 +G1 X-18.521 Y-11.352 E.00253 +;WIDTH:0.594034 +G1 X-18.521 Y-11.3 E.00235 +;WIDTH:0.551174 +G1 X-18.521 Y-11.247 E.00217 +;WIDTH:0.508314 +G1 X-18.521 Y-11.195 E.00198 +;WIDTH:0.465453 +G1 X-18.526 Y-8.948 E.07736 +;WIDTH:0.462469 +G1 X-18.507 Y-8.421 E.01805 +;WIDTH:0.419999 +G1 X-18.509 Y-7.529 E.02738 +G1 X-19.064 Y-7.537 E.01705 +G1 X-19.467 Y-8.236 E.0248 +;WIDTH:0.427052 +G1 X-21.007 Y-10.909 E.09655 +;WIDTH:0.458975 +G1 X-21.058 Y-11.033 E.00455 +;WIDTH:0.493593 +G1 X-21.11 Y-11.157 E.00492 +;WIDTH:0.52821 +G1 X-21.161 Y-11.28 E.0053 +;WIDTH:0.560878 +G1 X-21.188 Y-11.772 E.02076 +G1 X-20.962 Y-11.788 E.00953 +;WIDTH:0.527372 +G1 X-20.737 Y-11.805 E.00892 +;WIDTH:0.493866 +G1 X-20.512 Y-11.822 E.0083 +;WIDTH:0.46036 +G1 X-18.944 Y-11.822 E.05333 +;WIDTH:0.465447 +G1 X-18.945 Y-13.115 E.04454 +;WIDTH:0.464458 +G1 X-18.523 Y-13.117 E.01446 +G1 X-18.523 Y-12.906 E.00725 +;WIDTH:0.507567 +G1 X-18.523 Y-12.635 E.01026 +;WIDTH:0.550676 +G1 X-18.522 Y-12.364 E.01121 +;WIDTH:0.593785 +G1 X-18.522 Y-12.093 E.01216 +;WIDTH:0.636894 +G1 X-18.521 Y-11.822 E.01312 +G1 X-18.486 Y-11.822 E.0017 +;WIDTH:0.595021 +G1 X-18.451 Y-11.822 E.00158 +;WIDTH:0.553148 +G1 X-18.416 Y-11.822 E.00146 +;WIDTH:0.511274 +G1 X-18.125 Y-11.822 E.01111 +;WIDTH:0.485394 +G1 X-17.834 Y-11.821 E.0105 +;WIDTH:0.459514 +G1 X-17.835 Y-11.406 E.01409 +;WIDTH:0.503859 +G1 X-18.006 Y-11.406 E.00645 +;WIDTH:0.548204 +G1 X-18.178 Y-11.405 E.00707 +;WIDTH:0.592549 +G1 X-18.35 Y-11.405 E.00769 +;WIDTH:0.636894 +G1 X-18.481 Y-11.404 E.00637 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-18.521 Y-11.352 +G1 X-18.521 Y-11.3 +G1 X-18.521 Y-11.247 +G1 X-18.521 Y-11.195 +G1 X-18.525 Y-9.418 +;WIPE_END +G1 X-18.922 Y-8.404 Z1.2 F30000 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.64723 +G1 F12410.85 +G1 X-18.959 Y-8.338 E.00372 +G1 X-19.035 Y-8.338 E.00372 +G1 X-19.073 Y-8.404 E.00372 +G1 X-19.035 Y-8.469 E.00372 +G1 X-18.959 Y-8.469 E.00372 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12410.85 +G1 X-19.035 Y-8.469 +G1 X-19.073 Y-8.404 +G1 X-19.035 Y-8.338 +G1 X-18.959 Y-8.338 +G1 X-18.922 Y-8.404 +;WIPE_END +G1 X-22.235 Y-12.421 Z1.2 F30000 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.504452 +G1 F12000 +G1 X-22.694 Y-12.413 E.01727 +;WIDTH:0.505567 +G1 X-22.703 Y-13.087 E.02541 +G1 X-22.238 Y-13.095 E.01753 +G1 X-22.235 Y-12.461 E.02389 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-22.694 Y-12.413 +G1 X-22.703 Y-13.087 +G1 X-22.238 Y-13.095 +G1 X-22.236 Y-12.695 +;WIPE_END +G1 X-29.868 Y-12.581 Z1.2 F30000 +G1 X-30.463 Y-12.572 Z1.2 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.546524 +G1 F12000 +G1 X-30.449 Y-12.488 E.00349 +;WIDTH:0.497394 +G1 X-30.436 Y-12.404 E.00315 +;WIDTH:0.462002 +G1 X-29.881 Y-11.648 E.03205 +;WIDTH:0.45699 +G1 X-29.004 Y-10.771 E.04183 +;WIDTH:0.456051 +G1 X-28.633 Y-10.363 E.01858 +;WIDTH:0.462998 +G1 X-28.312 Y-9.878 E.0199 +;WIDTH:0.479824 +G1 X-28.123 Y-9.391 E.01861 +;WIDTH:0.485691 +G1 X-28.089 Y-8.861 E.01914 +;WIDTH:0.501256 +G1 X-28.188 Y-8.337 E.01993 +G1 X-28.296 Y-8.118 E.00913 +;WIDTH:0.503209 +G1 X-28.562 Y-7.807 E.01533 +G1 X-28.999 Y-7.592 E.0183 +;WIDTH:0.489871 +G1 X-29.494 Y-7.52 E.01821 +;WIDTH:0.478168 +G1 X-29.97 Y-7.581 E.017 +;WIDTH:0.488488 +G1 X-30.394 Y-7.787 E.01712 +;WIDTH:0.491514 +G1 X-30.717 Y-8.122 E.01701 +G1 X-30.85 Y-8.379 E.01059 +;WIDTH:0.494341 +G1 X-30.985 Y-8.831 E.01734 +G1 X-30.551 Y-8.887 E.01609 +G1 X-30.439 Y-8.531 E.01372 +;WIDTH:0.482205 +G1 X-30.25 Y-8.248 E.01219 +;WIDTH:0.479228 +G1 X-29.897 Y-8.011 E.01514 +G1 X-29.424 Y-7.949 E.01696 +;WIDTH:0.479512 +G1 X-28.974 Y-8.074 E.0166 +;WIDTH:0.499672 +G1 X-28.675 Y-8.371 E.0157 +G1 X-28.535 Y-8.844 E.01835 +;WIDTH:0.490001 +G1 X-28.551 Y-9.294 E.01642 +;WIDTH:0.481286 +G1 X-28.696 Y-9.704 E.01553 +;WIDTH:0.459864 +G1 X-28.978 Y-10.135 E.0175 +;WIDTH:0.456047 +G1 X-29.331 Y-10.519 E.01754 +;WIDTH:0.45699 +G1 X-30.216 Y-11.396 E.04204 +G1 X-30.768 Y-12.164 E.03193 +;WIDTH:0.499442 +G1 X-30.828 Y-12.342 E.00699 +;WIDTH:0.547548 +G1 X-30.888 Y-12.521 E.00773 +;WIDTH:0.595654 +G1 X-30.948 Y-12.699 E.00847 +G1 X-30.973 Y-13.096 E.01793 +G1 X-30.734 Y-13.115 E.01082 +;WIDTH:0.557973 +G1 X-30.494 Y-13.134 E.01008 +;WIDTH:0.520292 +G1 X-30.255 Y-13.152 E.00934 +;WIDTH:0.482611 +G1 X-27.992 Y-13.154 E.08106 +;WIDTH:0.479059 +G1 X-27.993 Y-12.718 E.01548 +G1 X-28.21 Y-12.717 E.00771 +;WIDTH:0.482611 +G1 X-30.254 Y-12.713 E.07324 +;WIDTH:0.520292 +G1 X-30.328 Y-12.694 E.00297 +;WIDTH:0.557973 +G1 X-30.402 Y-12.675 E.00321 +;WIDTH:0.595654 +G1 X-30.477 Y-12.656 E.00344 +G1 X-30.469 Y-12.612 E.00203 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-30.449 Y-12.488 +G1 X-30.436 Y-12.404 +G1 X-29.881 Y-11.648 +G1 X-29.279 Y-11.046 +;WIPE_END +G1 X-32.235 Y-12.465 Z1.2 F30000 +G1 Z.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.50495 +G1 F12000 +G1 X-32.694 Y-12.457 E.01728 +;WIDTH:0.506358 +G1 X-32.702 Y-13.131 E.02543 +G1 X-32.236 Y-13.138 E.01762 +G1 X-32.235 Y-12.505 E.02388 +;LAYER_CHANGE +;Z:1 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;1 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-32.694 Y-12.457 +G1 X-32.702 Y-13.131 +G1 X-32.236 Y-13.138 +G1 X-32.235 Y-12.739 +;WIPE_END +;AFTER_LAYER_CHANGE +;1 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z1.4 I-1.199 J-.207 P1 F30000 +G1 X-36.005 Y9.111 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.48891 +G1 F16816.52 +G1 X-36.026 Y9.203 E.00342 +;WIDTH:0.526809 +G1 F15499.422 +G1 X-36.14 Y9.994 E.03154 +G1 X-36.14 Y10.595 E.02369 +G1 X-36.53 Y10.186 E.02228 +G1 X-36.859 Y10.008 E.01476 +G1 X-36.525 Y9.797 E.01562 +G1 X-36.232 Y9.531 E.01558 +G1 X-36.024 Y9.146 E.01725 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15499.422 +G1 X-36.026 Y9.203 +G1 X-36.14 Y9.994 +G1 X-36.14 Y10.595 +G1 X-36.514 Y10.202 +;WIPE_END +G1 X-29.377 Y7.498 Z1.4 F30000 +G1 X5.077 Y-5.555 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X5.028 Y-5.367 E.00646 +G1 X4.85 Y-5.172 E.00875 +G1 X4.587 Y-5.071 E.00935 +G1 X-32.69 Y-5.071 E1.23653 +G1 X-33.543 Y-5.223 E.02874 +G1 X-34.366 Y-5.59 E.02992 +G1 X-35.073 Y-6.151 E.02992 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.966 Y-7.702 E.02992 +G1 X-36.059 Y-8.29 E.01975 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.094 Y-8.379 E.00356 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.129 Y-8.468 E.00394 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.164 Y-8.557 E.00432 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.199 Y-8.647 E.0047 +G1 X-36.199 Y-12.469 E.18732 +G1 X-36.099 Y-13.336 E.04282 +;WIDTH:0.655272 +G1 F12247.857 +G1 X-35.922 Y-13.855 E.02733 +G1 X-35.719 Y-14.318 E.02524 +;WIDTH:0.643373 +M73 P56 R3 +G1 F12490.569 +M73 P56 R3 +G1 X-35.251 Y-14.966 E.03914 +;WIDTH:0.655274 +G1 F12247.818 +G1 X-34.846 Y-15.336 E.02733 +G1 X-34.445 Y-15.644 E.02524 +;WIDTH:0.643373 +G1 F12490.569 +G1 X-33.722 Y-15.986 E.03914 +;WIDTH:0.655273 +G1 F12247.837 +G1 X-33.19 Y-16.116 E.02734 +G1 X-32.69 Y-16.192 E.02524 +;WIDTH:0.642409 +G1 F12510.656 +G1 X4.521 Y-16.192 E1.81779 +G1 X4.638 Y-16.11 E.00695 +;WIDTH:0.594307 +G1 F13602.075 +G1 X4.754 Y-16.028 E.00639 +;WIDTH:0.546204 +G1 F14902.124 +G1 X4.87 Y-15.946 E.00583 +;WIDTH:0.498102 +G1 F16476.943 +G1 X4.986 Y-15.864 E.00528 +;WIDTH:0.449999 +G1 F18000 +G1 X5.102 Y-15.516 E.01218 +G1 X5.102 Y-5.651 E.32722 +G1 X5.087 Y-5.594 E.00197 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X4.71 Y-5.651 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X4.646 Y-5.51 E.00476 +G1 X4.521 Y-5.463 E.0041 +G1 X-32.69 Y-5.463 E1.14339 +G1 X-33.469 Y-5.608 E.02436 +G1 X-34.199 Y-5.945 E.02471 +G1 X-34.823 Y-6.453 E.02471 +G1 X-35.3 Y-7.1 E.02471 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.614 Y-13.275 E.02496 +G1 X-35.323 Y-14.025 E.02471 +G1 X-34.855 Y-14.679 E.02471 +G1 X-34.239 Y-15.196 E.02471 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02524 +G1 X4.521 Y-15.704 E1.14396 +G1 X4.672 Y-15.629 E.00518 +G1 X4.71 Y-15.516 E.00366 +G1 X4.71 Y-5.691 E.30188 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X4.646 Y-5.51 +G1 X4.521 Y-5.463 +G1 X2.847 Y-5.463 +;WIPE_END +G1 X9.885 Y-2.509 Z1.4 F30000 +G1 X35.919 Y8.419 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.449999 +G1 F18000 +G1 X35.9 Y8.547 E.00427 +G1 X36.003 Y9.071 E.01773 +G1 X36.265 Y9.51 E.01694 +G1 X36.414 Y9.669 E.00726 +G1 X36.898 Y10 E.01943 +G1 X36.725 Y10.105 E.00673 +G1 X36.317 Y10.42 E.01707 +G1 X36.06 Y10.813 E.01557 +G1 X35.98 Y11.016 E.00726 +G1 X35.899 Y11.527 E.01716 +G1 X36.005 Y12.076 E.01854 +G1 X36.265 Y12.511 E.01681 +G1 X36.428 Y12.679 E.00776 +G1 X36.867 Y12.966 E.01742 +G1 X36.759 Y13.593 E.02113 +G1 X36.588 Y14.119 E.01832 +G1 X36.353 Y14.618 E.01832 +G1 X36.057 Y15.084 E.01832 +G1 X35.705 Y15.51 E.01832 +G1 X35.303 Y15.888 E.01831 +G1 X34.856 Y16.212 E.01832 +G1 X34.372 Y16.478 E.01832 +G1 X33.859 Y16.682 E.01832 +G1 X33.324 Y16.819 E.01832 +G1 X32.776 Y16.888 E.01832 +G1 X-32.776 Y16.888 E2.17448 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.372 Y16.478 E.01832 +G1 X-34.856 Y16.212 E.01832 +G1 X-35.303 Y15.888 E.01832 +G1 X-35.705 Y15.51 E.01832 +G1 X-36.041 Y15.104 E.01746 +G1 X-36.307 Y14.691 E.0163 +G1 X-36.54 Y14.221 E.0174 +;WIDTH:0.498866 +G1 F16449.315 +G1 X-36.555 Y14.108 E.00425 +;WIDTH:0.547733 +G1 F14856.986 +G1 X-36.57 Y13.994 E.00471 +;WIDTH:0.5966 +G1 F13545.731 +G1 X-36.584 Y13.881 E.00517 +;WIDTH:0.645467 +G1 F12447.162 +G1 X-36.599 Y13.767 E.00562 +G1 X-36.668 Y13.551 E.01115 +;WIDTH:0.649626 +G1 F12361.835 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.196 Y12.587 E.02645 +G1 X-36.07 Y13.461 E.04363 +;WIDTH:0.642407 +G1 F12510.696 +G1 X-35.996 Y13.582 E.00696 +;WIDTH:0.593296 +G1 F13627.048 +G1 X-35.922 Y13.704 E.00639 +;WIDTH:0.544185 +G1 F14962.147 +G1 X-35.848 Y13.826 E.00582 +;WIDTH:0.495074 +G1 F16587.268 +G1 X-35.688 Y14.169 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-35.395 Y14.603 E.01736 +G1 X-35.073 Y15.016 E.01736 +G1 X-34.489 Y15.499 E.02512 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.759 Y16.096 E.09722 +G1 X-29.079 Y15.974 E.02292 +G1 X-28.402 Y15.724 E.02392 +G1 X-27.651 Y15.226 E.02992 +G1 X-27.047 Y14.556 E.02992 +G1 X-26.628 Y13.757 E.02992 +G1 X-26.44 Y13.023 E.02512 +G1 X-26.398 Y12.501 E.01739 +G1 X-26.398 Y9.958 E.08436 +G1 X-26.534 Y9.035 E.03094 +G1 X-26.883 Y8.203 E.02992 +G1 X-27.427 Y7.484 E.02992 +G1 X-28.134 Y6.924 E.02992 +G1 X-28.957 Y6.556 E.02992 +G1 X-29.81 Y6.404 E.02874 +G1 X-32.75 Y6.404 E.09751 +G1 X-33.681 Y6.601 E.03157 +G1 X-34.489 Y7.001 E.02992 +G1 X-35.172 Y7.59 E.02992 +G1 X-35.551 Y8.111 E.02137 +G1 X-35.719 Y8.409 E.01134 +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.02241 +G1 X-35.907 Y8.609 E.01366 +;WIDTH:0.451257 +G1 F18000 +G1 X-35.924 Y8.308 E.01001 +;WIDTH:0.449999 +G1 X-36.06 Y7.813 E.01703 +G1 X-36.331 Y7.404 E.01629 +G1 X-36.725 Y7.105 E.01638 +G1 X-36.898 Y7.027 E.00631 +G1 X-36.898 Y-8.237 E.50633 +;WIDTH:0.498595 +G1 F16459.116 +G1 X-36.874 Y-8.344 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X-36.849 Y-8.451 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X-36.825 Y-8.559 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X-36.801 Y-8.666 E.00539 +G1 X-36.801 Y-12.493 E.18755 +;WIDTH:0.655262 +G1 F12248.058 +G1 X-36.756 Y-13.057 E.02825 +G1 X-36.659 Y-13.587 E.0269 +G1 X-36.484 Y-14.098 E.02694 +G1 X-36.258 Y-14.587 E.0269 +G1 X-35.962 Y-15.039 E.02694 +G1 X-35.621 Y-15.456 E.0269 +G1 X-35.221 Y-15.82 E.02694 +G1 X-34.787 Y-16.14 E.0269 +G1 X-34.31 Y-16.392 E.02694 +G1 X-33.81 Y-16.594 E.0269 +G1 X-33.285 Y-16.72 E.02694 +G1 X-32.69 Y-16.792 E.02994 +;WIDTH:0.642409 +G1 F12510.656 +G1 X4.521 Y-16.792 E1.81779 +G1 X4.766 Y-16.816 E.01198 +;WIDTH:0.594307 +G1 F13602.075 +G1 X5.01 Y-16.84 E.01102 +;WIDTH:0.546204 +G1 F14902.124 +G1 X5.254 Y-16.864 E.01006 +;WIDTH:0.498102 +G1 F16476.943 +G1 X5.498 Y-16.888 E.0091 +;WIDTH:0.449999 +G1 F18000 +G1 X32.776 Y-16.888 E.90486 +G1 X33.324 Y-16.819 E.01832 +G1 X33.859 Y-16.682 E.01832 +G1 X34.372 Y-16.478 E.01832 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01832 +G1 X36.057 Y-15.084 E.01832 +G1 X36.353 Y-14.618 E.01832 +G1 X36.588 Y-14.119 E.01832 +G1 X36.759 Y-13.593 E.01832 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y7.027 E.64712 +G1 X36.724 Y7.106 E.00633 +G1 X36.414 Y7.331 E.01272 +G1 X36.265 Y7.49 E.00723 +G1 X36.06 Y7.814 E.01271 +G1 X35.98 Y8.016 E.00722 +G1 X35.925 Y8.38 E.0122 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.293 Y8.465 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X36.365 Y8.91 E.01386 +G1 X36.596 Y9.3 E.01391 +G1 X36.955 Y9.577 E.01395 +G1 X37.29 Y9.669 E.01067 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01407 +G1 X36.391 Y11.023 E.0139 +G1 X36.293 Y11.465 E.01392 +G1 X36.365 Y11.911 E.01389 +G1 X36.596 Y12.3 E.01392 +G1 X36.955 Y12.577 E.01392 +G1 X37.279 Y12.666 E.01033 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.553 Y16.19 E.01848 +G1 X-35.991 Y15.779 E.01848 +G1 X-36.375 Y15.315 E.01848 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.253 E.0171 +G1 X-37.279 Y12.666 E.01811 +G1 X-36.955 Y12.577 E.01033 +G1 X-36.575 Y12.265 E.01512 +;WIDTH:0.456352 +G1 X-36.466 Y12.066 E.00763 +;WIDTH:0.492704 +G1 X-36.356 Y11.868 E.0083 +;WIDTH:0.529056 +G1 X-36.247 Y11.67 E.00897 +;WIDTH:0.547536 +G1 X-36.277 Y11.167 E.02072 +G1 X-36.353 Y11.05 E.00575 +;WIDTH:0.505024 +G1 X-36.429 Y10.932 E.00526 +;WIDTH:0.462512 +G1 X-36.505 Y10.815 E.00478 +;WIDTH:0.419999 +G1 X-36.824 Y10.5 E.01378 +G1 X-37.29 Y10.309 E.01546 +G1 X-37.29 Y9.669 E.01969 +G1 X-37.024 Y9.609 E.00837 +G1 X-36.645 Y9.352 E.01408 +G1 X-36.391 Y8.976 E.01393 +G1 X-36.293 Y8.535 E.01388 +G1 X-36.365 Y8.089 E.01389 +G1 X-36.596 Y7.7 E.01392 +M73 P57 R3 +G1 X-36.955 Y7.423 E.01392 +M73 P57 R3 +G1 X-37.29 Y7.331 E.01067 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.397 Y-17.205 E.01848 +G1 X33.98 Y-17.055 E.01848 +G1 X34.539 Y-16.834 E.01848 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.697 Y-14.807 E.01848 +G1 X36.953 Y-14.263 E.01848 +G1 X37.139 Y-13.691 E.01848 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.332 E.60916 +G1 X37.024 Y7.392 E.00839 +G1 X36.644 Y7.648 E.01408 +G1 X36.391 Y8.024 E.01392 +G1 X36.302 Y8.426 E.01265 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.365 Y8.91 +G1 X36.596 Y9.3 +G1 X36.955 Y9.577 +G1 X37.29 Y9.669 +G1 X37.29 Y9.926 +;WIPE_END +G1 X29.658 Y9.827 Z1.4 F30000 +G1 X-26.931 Y9.097 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X-26.898 Y9.179 E.00272 +G1 X-26.79 Y9.987 E.02504 +G1 X-26.79 Y12.501 E.07724 +G1 X-26.82 Y12.926 E.01309 +G1 X-27.008 Y13.659 E.02327 +G1 X-27.39 Y14.367 E.02471 +G1 X-27.937 Y14.957 E.02471 +G1 X-28.612 Y15.393 E.02471 +G1 X-29.207 Y15.604 E.01937 +G1 X-29.794 Y15.704 E.01831 +G1 X-32.69 Y15.704 E.08897 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.823 Y14.714 E.02471 +G1 X-35.3 Y14.066 E.02471 +G1 X-35.577 Y13.383 E.02267 +G1 X-35.677 Y12.771 E.01906 +;WIDTH:0.422869 +G1 X-35.711 Y12.097 E.02089 +;WIDTH:0.457416 +G1 X-35.729 Y11.925 E.00583 +;WIDTH:0.491963 +G1 X-35.746 Y11.753 E.00632 +;WIDTH:0.526509 +G1 X-35.763 Y11.582 E.0068 +G1 X-35.743 Y10.954 E.02475 +;WIDTH:0.486717 +G1 X-35.727 Y10.677 E.01004 +;WIDTH:0.453358 +G1 X-35.71 Y10.4 E.00928 +;WIDTH:0.419999 +G1 X-35.665 Y9.635 E.02354 +G1 X-35.585 Y9.137 E.0155 +G1 X-35.323 Y8.475 E.02188 +G1 X-34.823 Y7.786 E.02615 +G1 X-34.239 Y7.304 E.02327 +G1 X-33.514 Y6.956 E.02471 +G1 X-32.708 Y6.796 E.02524 +G1 X-29.81 Y6.796 E.08905 +G1 X-29.031 Y6.941 E.02436 +G1 X-28.301 Y7.278 E.02471 +G1 X-27.677 Y7.786 E.02471 +G1 X-27.2 Y8.434 E.02471 +G1 X-26.946 Y9.06 E.02077 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.898 Y9.179 +G1 X-26.79 Y9.987 +G1 X-26.79 Y11.044 +;WIPE_END +G1 X-34.26 Y9.477 Z1.4 F30000 +G1 X-36.005 Y9.111 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.00467 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.134 Y7.338 F30000 +;TYPE:Sparse infill +;WIDTH:0.45 +G1 F16200 +G1 X-36.183 Y7.265 E.00292 +G1 X-36.605 Y6.941 E.01766 +G1 X-36.694 Y6.9 E.00325 +G1 X-36.694 Y5.991 E.03018 +G1 X-35.771 Y5.067 E.04333 +G1 X-35.953 Y4.766 E.01168 +G1 X-36.227 Y4.075 E.02465 +G1 X-36.279 Y3.888 E.00646 +G1 X-36.379 Y3.091 E.02663 +G1 X-36.38 Y2 E.0362 +G1 X-36.694 Y1.685 E.01475 +G1 X-36.694 Y-1.685 E.11182 +G1 X-36.356 Y-2.024 E.01587 +G1 X-36.331 Y-2.27 E.00821 +G1 X-36.234 Y-2.719 E.01524 +G1 X-36.073 Y-3.178 E.01615 +G1 X-35.942 Y-3.456 E.01018 +G1 X-35.688 Y-3.877 E.01632 +G1 X-35.377 Y-4.274 E.01672 +G1 X-35.171 Y-4.468 E.00937 +G1 X-36.694 Y-5.991 E.07144 +G1 X-36.694 Y-7.619 E.05401 +G1 E-.8 F2100 +;WIPE_START +G1 F16200 +G1 X-36.694 Y-5.991 +G1 X-36.432 Y-5.728 +;WIPE_END +G1 X-32.913 Y1.046 Z1.4 F30000 +G1 X-25.38 Y15.549 Z1.4 +G1 Z1 +G1 E.8 F2100 +G1 F16200 +G1 X-25.155 Y15.726 E.00951 +G1 X-24.732 Y15.971 E.01619 +G1 X-24.284 Y16.161 E.01613 +G1 X-23.933 Y16.257 E.0121 +G1 X-24.361 Y16.685 E.02007 +G1 X-29.371 Y16.685 E.16622 +G1 X-29.763 Y16.293 E.01839 +G1 X-31.651 Y16.299 E.06261 +G1 X-32.036 Y16.685 E.0181 +G1 X-32.763 Y16.685 E.02411 +G1 X-33.286 Y16.619 E.01747 +G1 X-33.649 Y16.525 E.01244 +G1 E-.8 F2100 +;WIPE_START +G1 F16200 +G1 X-33.286 Y16.619 +G1 X-32.763 Y16.685 +G1 X-32.036 Y16.685 +G1 X-31.774 Y16.422 +;WIPE_END +G1 X-26.108 Y11.307 Z1.4 F30000 +G1 X4.73 Y-16.533 Z1.4 +G1 Z1 +G1 E.8 F2100 +G1 F16200 +G1 X5.498 Y-16.685 E.02597 +G1 X6.343 Y-16.685 E.02804 +G1 X6.65 Y-16.378 E.01437 +G1 X8.702 Y-16.378 E.06809 +G1 X9.009 Y-16.685 E.01436 +G1 X14.019 Y-16.685 E.16622 +G1 X15.613 Y-15.091 E.07474 +G1 X15.158 Y-14.469 E.02555 +G1 X14.999 Y-14.15 E.01182 +G1 X14.855 Y-14.449 E.01099 +G1 X14.685 Y-14.685 E.00965 +G1 X16.685 Y-16.685 E.0938 +G1 X21.695 Y-16.685 E.16622 +G1 X22.01 Y-16.37 E.01475 +G1 X24.048 Y-16.372 E.06761 +G1 X24.361 Y-16.685 E.01467 +G1 X29.371 Y-16.685 E.16622 +G1 X29.679 Y-16.376 E.01445 +G1 X31.73 Y-16.378 E.06802 +G1 X32.036 Y-16.685 E.01438 +G1 X32.763 Y-16.685 E.02411 +G1 X33.286 Y-16.619 E.01747 +G1 X33.796 Y-16.488 E.01747 +G1 X34.285 Y-16.294 E.01747 +G1 X34.747 Y-16.04 E.01747 +G1 X35.173 Y-15.731 E.01747 +G1 X35.556 Y-15.37 E.01747 +G1 X35.892 Y-14.964 E.01747 +G1 X36.174 Y-14.52 E.01747 +G1 X36.398 Y-14.043 E.01747 +G1 X36.563 Y-13.535 E.01773 +G1 X36.293 Y-13.265 E.01266 +G1 X36.385 Y-12.493 E.02578 +G1 X36.385 Y-9.671 E.09361 +G1 X36.694 Y-9.361 E.01452 +G1 X36.694 Y-5.991 E.11182 +G1 X35.172 Y-4.468 E.07143 +G1 X35.405 Y-4.237 E.0109 +G1 X35.841 Y-3.636 E.02461 +G1 X35.942 Y-3.456 E.00685 +G1 X36.234 Y-2.719 E.02631 +G1 X36.284 Y-2.518 E.00685 +G1 X36.337 Y-2.097 E.01409 +G1 X36.434 Y-1.946 E.00596 +G1 X36.5 Y-1.88 E.0031 +G1 X36.543 Y-1.968 E.00326 +G1 X36.694 Y-1.933 E.00514 +G1 X36.694 Y-3.308 E.04561 +G1 E-.8 F2100 +;WIPE_START +G1 F16200 +G1 X36.694 Y-1.933 +G1 X36.543 Y-1.968 +G1 X36.5 Y-1.88 +G1 X36.434 Y-1.946 +G1 X36.337 Y-2.097 +G1 X36.325 Y-2.195 +;WIPE_END +G1 X35.14 Y-9.735 Z1.4 F30000 +G1 X34.197 Y-15.738 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +;TYPE:Top surface +;WIDTH:0.42103 +G1 F12000 +G1 X35.74 Y-14.195 E.06724 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.06 Y-13.34 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X33.358 Y-16.042 E.11772 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X32.713 Y-16.153 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.133 Y-12.732 E.14906 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P58 R3 +G1 X36.162 Y-12.168 F30000 +M73 P58 R3 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X32.175 Y-16.156 E.17377 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X31.64 Y-16.156 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.162 Y-11.633 E.19705 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.162 Y-11.099 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X31.106 Y-16.155 E.22033 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X30.572 Y-16.155 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.162 Y-10.564 E.24361 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.162 Y-10.029 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X30.037 Y-16.154 E.26689 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X29.503 Y-16.154 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.162 Y-9.495 E.29017 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.162 Y-8.96 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X28.969 Y-16.153 E.31345 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X28.434 Y-16.153 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.136 Y-8.452 E.33557 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.078 Y-7.975 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X27.9 Y-16.153 E.35634 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X27.366 Y-16.152 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.97 Y-7.548 E.37493 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.819 Y-7.165 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X26.832 Y-16.152 E.3916 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X26.297 Y-16.151 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.644 Y-6.804 E.40729 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.419 Y-6.494 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X25.763 Y-16.151 E.42076 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X25.229 Y-16.15 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.19 Y-6.19 E.43403 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.908 Y-5.936 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +M73 P58 R2 +G1 F12000 +M73 P58 R2 +G1 X24.694 Y-16.15 E.44504 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X24.16 Y-16.15 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X34.615 Y-5.694 E.45557 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.288 Y-5.487 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X23.626 Y-16.149 E.4646 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X23.092 Y-16.149 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X33.925 Y-5.316 E.47203 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.004 Y-2.702 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X22.557 Y-16.148 E.5859 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X22.023 Y-16.148 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.119 Y-2.052 E.61421 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y-.757 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X21.489 Y-16.147 E.67062 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X20.954 Y-16.147 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.879 Y-.222 E.6939 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y.312 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X20.42 Y-16.147 E.71718 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P59 R2 +G1 X19.886 Y-16.146 F30000 +M73 P59 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.879 Y.847 E.74046 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y1.382 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X19.351 Y-16.146 E.76374 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.817 Y-16.145 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.879 Y1.917 E.78702 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y2.451 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X18.283 Y-16.145 E.8103 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X17.819 Y-16.074 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.879 Y2.986 E.83053 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y3.521 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X17.378 Y-15.98 E.84972 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X17.005 Y-15.818 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.879 Y4.056 E.86597 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y4.59 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X16.642 Y-15.647 E.88181 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X16.328 Y-15.426 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.879 Y5.125 E.89547 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y5.66 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X16.036 Y-15.184 E.90822 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.762 Y-14.922 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.879 Y6.195 E.92013 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.879 Y6.729 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X15.537 Y-14.613 E.92994 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.316 Y-14.299 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.711 Y7.096 E.93224 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.402 Y7.322 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X15.15 Y-13.931 E.92604 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X12.54 Y-16.006 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.162 Y7.617 E1.0293 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.976 Y7.965 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X11.892 Y-16.119 E1.04944 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P60 R2 +G1 X11.32 Y-16.156 F30000 +M73 P60 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.894 Y8.417 E1.07077 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.978 Y9.036 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X10.785 Y-16.156 E1.09773 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X10.25 Y-16.156 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.588 Y10.181 E1.14762 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.295 Y10.423 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X9.716 Y-16.156 E1.15814 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X9.181 Y-16.156 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.082 Y10.745 E1.17218 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.944 Y11.141 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X8.647 Y-16.156 E1.18945 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X8.112 Y-16.156 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.892 Y11.624 E1.21048 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.268 Y12.535 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X7.577 Y-16.156 E1.25015 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X7.043 Y-16.156 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.748 Y13.549 E1.29436 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.621 Y13.957 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X6.508 Y-16.156 E1.31212 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.973 Y-16.156 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.872 Y-7.257 E.38776 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.699 Y-6.896 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.439 Y-16.156 E.40349 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X4.916 Y-16.144 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.494 Y-6.566 E.41734 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.256 Y-6.269 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.121 Y-15.404 E.39803 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.121 Y-14.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X13.991 Y-5.999 E.3865 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X13.701 Y-5.754 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.121 Y-14.335 E.37388 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X6.535 Y-12.92 +;WIPE_END +G1 X12.789 Y-8.545 Z1.4 F30000 +G1 X15.437 Y-6.691 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.465 Y14.336 E.91624 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P61 R2 +G1 X36.285 Y14.69 F30000 +M73 P61 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X16.26 Y-5.334 E.87256 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.736 Y-5.323 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.077 Y15.018 E.88632 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.841 Y15.316 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X15.202 Y-5.323 E.89933 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.667 Y-5.323 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.586 Y15.596 E.91152 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.31 Y15.855 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.132 Y-5.323 E.9228 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X15.546 Y-3.909 +;WIPE_END +G1 X10.009 Y-9.162 Z1.4 F30000 +G1 X5.121 Y-13.8 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.003 Y16.082 E1.30207 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.676 Y16.29 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.12 Y-13.265 E1.28783 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.12 Y-12.731 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X34.327 Y16.476 E1.27264 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X33.944 Y16.628 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.12 Y-12.196 E1.25595 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.12 Y-11.661 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X33.529 Y16.747 E1.23785 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X33.078 Y16.831 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.12 Y-11.127 E1.21822 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.12 Y-10.592 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X32.581 Y16.869 E1.19659 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X32.047 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.12 Y-10.058 E1.1733 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.12 Y-9.523 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X31.512 Y16.869 E1.15 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P62 R2 +G1 X30.977 Y16.869 F30000 +M73 P62 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.12 Y-8.988 E1.12671 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.12 Y-8.454 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X30.442 Y16.869 E1.10341 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X29.908 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.119 Y-7.919 E1.08011 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.119 Y-7.384 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X29.373 Y16.869 E1.05682 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X28.838 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.119 Y-6.85 E1.03352 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.119 Y-6.315 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X28.304 Y16.869 E1.01023 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X27.769 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.119 Y-5.781 E.98693 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.023 Y-5.342 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X27.234 Y16.869 E.9678 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X26.699 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X4.73 Y-5.1 E.95727 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X4.236 Y-5.059 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X26.165 Y16.869 E.95549 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X25.63 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X3.702 Y-5.059 E.95549 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X3.167 Y-5.059 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X25.095 Y16.869 E.95548 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X24.56 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X2.633 Y-5.059 E.95548 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X2.098 Y-5.059 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X24.026 Y16.869 E.95547 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X23.491 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X1.563 Y-5.058 E.95547 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.029 Y-5.058 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X22.956 Y16.869 E.95546 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X22.422 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X.494 Y-5.058 E.95546 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P63 R2 +G1 X-.041 Y-5.058 F30000 +M73 P63 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X21.887 Y16.869 E.95545 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X21.352 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-.575 Y-5.058 E.95545 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-1.11 Y-5.058 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X20.817 Y16.869 E.95544 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X20.283 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-1.644 Y-5.058 E.95544 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-2.179 Y-5.058 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X19.748 Y16.869 E.95543 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X19.213 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-2.714 Y-5.058 E.95543 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-3.248 Y-5.058 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X18.678 Y16.869 E.95543 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.144 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-3.783 Y-5.057 E.95542 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.317 Y-5.057 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X17.609 Y16.869 E.95542 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X17.074 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.852 Y-5.057 E.95541 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.387 Y-5.057 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X16.54 Y16.869 E.95541 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X16.005 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.921 Y-5.057 E.9554 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-6.456 Y-5.057 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X15.47 Y16.869 E.9554 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.935 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-6.991 Y-5.057 E.95539 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-7.525 Y-5.057 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.401 Y16.869 E.95539 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X13.866 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-8.06 Y-5.057 E.95538 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P64 R2 +G1 X-8.594 Y-5.056 F30000 +M73 P64 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X13.331 Y16.869 E.95538 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X12.796 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-9.129 Y-5.056 E.95537 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-9.664 Y-5.056 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X12.262 Y16.869 E.95537 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X11.727 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-10.198 Y-5.056 E.95537 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.733 Y-5.056 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X11.192 Y16.869 E.95536 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X10.658 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-11.268 Y-5.056 E.95536 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-11.802 Y-5.056 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X10.123 Y16.869 E.95535 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X9.588 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-12.337 Y-5.056 E.95535 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-12.871 Y-5.056 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X9.053 Y16.869 E.95534 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X8.519 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-13.406 Y-5.055 E.95534 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-13.941 Y-5.055 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X7.984 Y16.869 E.95533 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X7.449 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.475 Y-5.055 E.95533 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.01 Y-5.055 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X6.914 Y16.869 E.95532 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X6.38 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.545 Y-5.055 E.95532 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-16.079 Y-5.055 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X5.845 Y16.869 E.95531 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.31 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-16.614 Y-5.055 E.95531 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-17.148 Y-5.055 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X4.776 Y16.869 E.95531 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P65 R2 +G1 X4.241 Y16.869 F30000 +M73 P65 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-17.683 Y-5.055 E.9553 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-18.218 Y-5.055 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X3.706 Y16.869 E.9553 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X3.171 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-18.752 Y-5.054 E.95529 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-19.287 Y-5.054 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X2.637 Y16.869 E.95529 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X2.102 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-19.822 Y-5.054 E.95528 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-20.356 Y-5.054 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X1.567 Y16.869 E.95528 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.032 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-20.891 Y-5.054 E.95527 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-21.425 Y-5.054 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X.498 Y16.869 E.95527 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-.037 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-21.96 Y-5.054 E.95526 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-22.495 Y-5.054 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-.572 Y16.869 E.95526 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-1.106 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-23.029 Y-5.054 E.95525 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.564 Y-5.053 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-1.641 Y16.869 E.95525 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-2.176 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.099 Y-5.053 E.95525 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.633 Y-5.053 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-2.711 Y16.869 E.95524 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-3.245 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-25.168 Y-5.053 E.95524 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.702 Y-5.053 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-3.78 Y16.869 E.95523 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P66 R2 +G1 X-4.315 Y16.869 F30000 +M73 P66 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.237 Y-5.053 E.95523 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.772 Y-5.053 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.85 Y16.869 E.95522 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.384 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-27.306 Y-5.053 E.95522 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-27.841 Y-5.053 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.919 Y16.869 E.95521 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-6.454 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-28.375 Y-5.053 E.95521 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-28.91 Y-5.052 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-6.988 Y16.869 E.9552 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-7.523 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-29.445 Y-5.052 E.9552 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-29.979 Y-5.052 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-8.058 Y16.869 E.95519 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-8.593 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-30.514 Y-5.052 E.95519 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-31.049 Y-5.052 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-9.127 Y16.869 E.95519 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-9.662 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-31.583 Y-5.052 E.95518 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-32.118 Y-5.052 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-10.197 Y16.869 E.95518 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.732 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-32.652 Y-5.052 E.95517 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-33.296 Y-5.161 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-11.266 Y16.869 E.95991 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-11.801 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-33.764 Y-5.093 E.95699 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.127 Y-4.922 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-12.336 Y16.869 E.94953 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-12.871 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-34.48 Y-4.74 E.94161 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P67 R2 +G1 X-34.779 Y-4.504 F30000 +M73 P67 R2 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-13.405 Y16.869 E.93131 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-13.94 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.071 Y-4.262 E.92076 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.324 Y-3.98 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.475 Y16.869 E.90849 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.009 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.55 Y-3.671 E.89503 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.752 Y-3.338 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.544 Y16.869 E.88051 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-16.079 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.917 Y-2.969 E.86441 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.045 Y-2.562 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-16.614 Y16.869 E.84669 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-17.148 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.125 Y-2.108 E.82689 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.162 Y-1.61 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-17.683 Y16.869 E.80521 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-18.218 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.837 Y8.25 E.37558 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.495 Y9.126 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-18.753 Y16.869 E.33738 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-19.287 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.414 Y9.742 E.31056 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.379 Y10.312 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-19.822 Y16.869 E.28572 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-20.357 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.38 Y10.846 E.26244 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.38 Y11.381 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-20.891 Y16.869 E.23915 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-21.426 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.38 Y11.915 E.21587 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.381 Y12.45 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-21.961 Y16.869 E.19258 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-22.496 Y16.869 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.417 Y12.948 E.17087 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.514 Y13.386 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-23.85 Y16.05 E.11607 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.715 Y15.719 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.242 Y14.192 E.06653 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-24.828 Y15.606 +;WIPE_END +G1 X-27.593 Y8.492 Z1.4 F30000 +G1 X-28.228 Y6.859 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.162 Y-1.075 E.34572 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.161 Y-.539 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-29.109 Y6.512 E.30726 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-29.766 Y6.39 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.16 Y-.004 E.2786 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.16 Y.532 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-30.306 Y6.385 E.25505 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-30.841 Y6.385 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.159 Y1.067 E.23172 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.158 Y1.602 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-31.376 Y6.385 E.20839 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-31.91 Y6.385 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.157 Y2.138 E.18506 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.157 Y2.673 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +M73 P68 R2 +G1 F12000 +M73 P68 R2 +G1 X-32.445 Y6.385 E.16173 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-32.934 Y6.431 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.139 Y3.226 E.13964 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.061 Y3.839 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-33.389 Y6.51 E.1164 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-34.804 Y5.096 +;WIPE_END +G1 X-27.347 Y3.468 Z1.4 F30000 +G1 X13.868 Y-5.531 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.419999 +G1 F15000 +G1 X16.166 Y-5.531 E.07062 +G1 X16.189 Y-5.596 E.00213 +G1 X15.64 Y-6.069 E.02227 +G1 X15.167 Y-6.712 E.02451 +G1 X15.01 Y-7.055 E.01161 +G1 X14.61 Y-6.37 E.02437 +G1 X14.254 Y-5.957 E.01673 +G1 X13.845 Y-5.61 E.0165 +G1 X13.857 Y-5.569 E.00131 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.656 Y-5.883 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.370188 +G1 F15000 +G1 X15.337 Y-5.883 E.01814 +G1 X15.004 Y-6.337 E.01501 +G1 X14.681 Y-5.914 E.01419 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X15.004 Y-6.337 +G1 X15.337 Y-5.883 +G1 X14.656 Y-5.883 +;WIPE_END +G1 X7.388 Y-8.212 Z1.4 F30000 +G1 X1.27 Y-10.172 Z1.4 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.54678 +G1 F12000 +G1 X1.293 Y-10.144 E.00148 +;WIDTH:0.5131 +G1 X1.316 Y-10.116 E.00138 +;WIDTH:0.479419 +G1 X1.339 Y-10.088 E.00128 +;WIDTH:0.449928 +G1 X1.705 Y-9.695 E.01782 +;WIDTH:0.479339 +G1 X1.877 Y-9.257 E.01671 +;WIDTH:0.491169 +G1 X1.916 Y-8.841 E.01526 +G1 X1.823 Y-8.305 E.01988 +;WIDTH:0.487822 +G1 X1.672 Y-8.005 E.01217 +G1 X1.316 Y-7.673 E.01765 +;WIDTH:0.460273 +G1 X.882 Y-7.505 E.01583 +;WIDTH:0.447154 +G1 X.379 Y-7.463 E.01663 +;WIDTH:0.456748 +G1 X-.027 Y-7.53 E.01388 +;WIDTH:0.466184 +G1 X-.462 Y-7.746 E.01675 +;WIDTH:0.474927 +G1 X-.801 Y-8.159 E.01879 +;WIDTH:0.488223 +G1 X-.949 Y-8.644 E.0184 +G1 X-.92 Y-9.236 E.02152 +;WIDTH:0.473399 +G1 X-.765 Y-9.64 E.01519 +;WIDTH:0.451607 +G1 X-.65 Y-9.814 E.00694 +;WIDTH:0.47811 +G1 X-.534 Y-9.934 E.00595 +;WIDTH:0.511757 +G1 X-.417 Y-10.055 E.0064 +;WIDTH:0.545404 +G1 X-.301 Y-10.176 E.00686 +G1 X-.327 Y-10.215 E.00193 +;WIDTH:0.499286 +G1 X-.732 Y-10.593 E.02063 +;WIDTH:0.469224 +G1 X-.917 Y-10.872 E.01162 +;WIDTH:0.492258 +G1 X-1.06 Y-11.325 E.01739 +;WIDTH:0.49551 +G1 X-1.064 Y-11.841 E.01903 +;WIDTH:0.501363 +G1 X-.945 Y-12.331 E.01884 +G1 X-.693 Y-12.75 E.01826 +;WIDTH:0.480368 +G1 X-.428 Y-12.974 E.01235 +;WIDTH:0.46834 +G1 X-.05 Y-13.136 E.01426 +;WIDTH:0.458325 +G1 X.565 Y-13.202 E.02092 +;WIDTH:0.463138 +G1 X1.051 Y-13.126 E.01685 +;WIDTH:0.471287 +G1 X1.498 Y-12.905 E.01739 +;WIDTH:0.482713 +G1 X1.757 Y-12.647 E.01312 +;WIDTH:0.492602 +G1 X1.976 Y-12.149 E.01996 +;WIDTH:0.494799 +G1 X2.046 Y-11.599 E.02038 +G1 X1.977 Y-11.076 E.01944 +;WIDTH:0.48083 +G1 X1.89 Y-10.84 E.00899 +;WIDTH:0.47176 +G1 X1.697 Y-10.569 E.01161 +;WIDTH:0.479631 +G1 X1.555 Y-10.437 E.00692 +;WIDTH:0.513206 +G1 X1.413 Y-10.304 E.00745 +;WIDTH:0.54678 +G1 X1.3 Y-10.199 E.00634 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X.916 Y-10.402 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X.652 Y-10.397 E.01084 +;WIDTH:0.500659 +G1 X.388 Y-10.392 E.00985 +;WIDTH:0.499971 +G1 X.218 Y-10.398 E.00635 +;WIDTH:0.545404 +G1 X.047 Y-10.403 E.00698 +;WIDTH:0.432734 +G1 X-.345 Y-10.745 E.01653 +;WIDTH:0.474445 +G1 X-.576 Y-11.162 E.01676 +;WIDTH:0.485698 +G1 X-.627 Y-11.562 E.01454 +;WIDTH:0.486412 +G1 X-.596 Y-11.917 E.01288 +G1 X-.416 Y-12.396 E.0185 +;WIDTH:0.473517 +G1 X-.185 Y-12.626 E.01144 +;WIDTH:0.46834 +G1 X.016 Y-12.726 E.00775 +;WIDTH:0.458325 +G1 X.594 Y-12.8 E.01972 +;WIDTH:0.451815 +G1 X1.077 Y-12.674 E.01665 +;WIDTH:0.473056 +G1 X1.334 Y-12.478 E.01133 +;WIDTH:0.477685 +G1 X1.462 Y-12.269 E.00868 +;WIDTH:0.487701 +G1 X1.549 Y-12.001 E.01021 +G1 X1.59 Y-11.508 E.01795 +;WIDTH:0.485535 +G1 X1.494 Y-11.004 E.01851 +;WIDTH:0.47176 +G1 X1.29 Y-10.707 E.01259 +;WIDTH:0.463934 +G1 X1.165 Y-10.605 E.00551 +;WIDTH:0.505357 +G1 X1.04 Y-10.504 E.00606 +;WIDTH:0.54678 +G1 X.947 Y-10.428 E.00496 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.047 Y-9.82 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.510389 +G1 F12000 +G1 X1.173 Y-9.699 E.00665 +;WIDTH:0.473998 +G1 X1.299 Y-9.579 E.00613 +;WIDTH:0.480142 +G1 X1.45 Y-9.178 E.01525 +;WIDTH:0.501265 +G1 X1.458 Y-8.756 E.01578 +G1 X1.364 Y-8.345 E.01574 +;WIDTH:0.484876 +G1 X1.091 Y-8.02 E.01528 +;WIDTH:0.460273 +G1 X.697 Y-7.875 E.01429 +;WIDTH:0.444484 +G1 X.305 Y-7.869 E.01281 +;WIDTH:0.455696 +G1 X-.133 Y-8.023 E.01562 +;WIDTH:0.475392 +G1 X-.403 Y-8.328 E.01435 +;WIDTH:0.488223 +G1 X-.504 Y-8.664 E.01274 +G1 X-.494 Y-9.174 E.01852 +;WIDTH:0.476215 +G1 X-.34 Y-9.559 E.01464 +;WIDTH:0.476547 +G1 X-.218 Y-9.683 E.00615 +;WIDTH:0.510976 +G1 X-.096 Y-9.808 E.00664 +;WIDTH:0.545404 +G1 X.026 Y-9.932 E.00713 +G1 X.271 Y-9.954 E.0101 +;WIDTH:0.499209 +G1 X.517 Y-9.976 E.00917 +;WIDTH:0.499897 +G1 X.719 Y-9.958 E.00755 +;WIDTH:0.54678 +G1 X.921 Y-9.94 E.00832 +G1 X1.018 Y-9.847 E.00552 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X1.173 Y-9.699 +G1 X1.299 Y-9.579 +G1 X1.45 Y-9.178 +G1 X1.458 Y-8.756 +G1 X1.364 Y-8.345 +G1 X1.146 Y-8.086 +;WIPE_END +G1 X-2.235 Y-12.423 Z1.4 F30000 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.504948 +G1 F12000 +G1 X-2.694 Y-12.415 E.01728 +;WIDTH:0.506358 +G1 X-2.702 Y-13.088 E.02543 +G1 X-2.236 Y-13.096 E.01762 +G1 X-2.235 Y-12.463 E.02389 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-2.694 Y-12.415 +G1 X-2.702 Y-13.088 +G1 X-2.236 Y-13.096 +G1 X-2.235 Y-12.697 +;WIPE_END +G1 X-9.852 Y-12.67 Z1.4 F30000 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.476462 +G1 F12000 +G1 X-9.472 Y-12.772 E.01389 +;WIDTH:0.47198 +G1 X-9.041 Y-12.693 E.0153 +;WIDTH:0.487017 +G1 X-8.782 Y-12.514 E.01139 +G1 X-8.65 Y-12.346 E.00772 +;WIDTH:0.481416 +G1 X-8.45 Y-11.867 E.01858 +;WIDTH:0.487411 +G1 X-8.397 Y-11.344 E.01901 +;WIDTH:0.487684 +G1 X-8.45 Y-10.843 E.01826 +;WIDTH:0.492132 +G1 X-8.623 Y-10.423 E.01665 +G1 X-8.886 Y-10.144 E.01402 +;WIDTH:0.481029 +G1 X-9.111 Y-10.039 E.00886 +;WIDTH:0.461007 +G1 X-9.379 Y-9.993 E.00925 +;WIDTH:0.450937 +G1 X-9.854 Y-10.077 E.01606 +;WIDTH:0.41663 +G1 X-10.246 Y-10.359 E.01471 +;WIDTH:0.453537 +G1 X-10.338 Y-10.524 E.00631 +;WIDTH:0.490443 +G1 X-10.429 Y-10.688 E.00688 +;WIDTH:0.527349 +G1 X-10.521 Y-10.853 E.00744 +G1 X-10.514 Y-11.445 E.02335 +;WIDTH:0.48697 +G1 X-10.413 Y-11.919 E.01754 +;WIDTH:0.475205 +G1 X-10.317 Y-12.172 E.00953 +;WIDTH:0.474364 +G1 X-10.06 Y-12.525 E.01536 +;WIDTH:0.476462 +G1 X-9.884 Y-12.647 E.00755 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-9.472 Y-12.772 +G1 X-9.041 Y-12.693 +G1 X-8.782 Y-12.514 +G1 X-8.65 Y-12.346 +G1 X-8.45 Y-11.867 +G1 X-8.442 Y-11.784 +;WIPE_END +G1 X-10.417 Y-9.973 Z1.4 F30000 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.428767 +G1 F12000 +G1 X-10.527 Y-9.904 E.00409 +;WIDTH:0.465595 +G1 X-10.579 Y-9.763 E.00517 +G1 X-10.484 Y-9.119 E.02241 +;WIDTH:0.460883 +G1 X-10.297 Y-8.554 E.02027 +;WIDTH:0.46813 +G1 X-10.007 Y-8.139 E.01753 +G1 X-9.738 Y-7.964 E.01115 +;WIDTH:0.465661 +G1 X-9.26 Y-7.908 E.01655 +;WIDTH:0.478156 +G1 X-8.932 Y-8.011 E.0122 +;WIDTH:0.479888 +G1 X-8.676 Y-8.255 E.01259 +;WIDTH:0.480507 +G1 X-8.53 Y-8.573 E.01246 +G1 X-8.127 Y-8.486 E.01472 +G1 X-8.178 Y-8.282 E.00753 +G1 X-8.302 Y-8.025 E.01016 +;WIDTH:0.479563 +G1 X-8.639 Y-7.673 E.01734 +G1 X-9.03 Y-7.513 E.01505 +;WIDTH:0.476867 +G1 X-9.557 Y-7.489 E.01864 +G1 X-9.906 Y-7.579 E.01274 +;WIDTH:0.46813 +G1 X-10.303 Y-7.841 E.0165 +G1 X-10.613 Y-8.253 E.01787 +;WIDTH:0.472362 +G1 X-10.819 Y-8.745 E.01866 +;WIDTH:0.472466 +G1 X-10.958 Y-9.342 E.02144 +G1 X-11.052 Y-10.071 E.02572 +;WIDTH:0.463737 +G1 X-11.035 Y-10.288 E.00749 +;WIDTH:0.498707 +G1 X-11.017 Y-10.506 E.00811 +;WIDTH:0.533677 +G1 X-10.98 Y-11.25 E.02979 +;WIDTH:0.487808 +G1 X-10.874 Y-11.872 E.02286 +;WIDTH:0.466817 +G1 X-10.671 Y-12.404 E.01969 +;WIDTH:0.474364 +G1 X-10.377 Y-12.816 E.01779 +G1 X-10.085 Y-13.03 E.01272 +;WIDTH:0.470177 +G1 X-9.701 Y-13.168 E.01421 +;WIDTH:0.46921 +G1 X-9.426 Y-13.196 E.00961 +G1 X-8.927 Y-13.107 E.01759 +;WIDTH:0.476872 +G1 X-8.604 Y-12.936 E.01291 +;WIDTH:0.484481 +G1 X-8.333 Y-12.667 E.01376 +;WIDTH:0.495099 +G1 X-8.098 Y-12.218 E.01865 +G1 X-7.985 Y-11.774 E.0169 +;WIDTH:0.490484 +G1 X-7.956 Y-11.291 E.01765 +;WIDTH:0.48985 +G1 X-8.023 Y-10.703 E.02154 +;WIDTH:0.492132 +G1 X-8.222 Y-10.223 E.01901 +G1 X-8.515 Y-9.875 E.01666 +;WIDTH:0.485565 +G1 X-8.801 Y-9.691 E.01227 +;WIDTH:0.480715 +G1 X-9.229 Y-9.592 E.01567 +;WIDTH:0.454365 +G1 X-9.688 Y-9.623 E.01542 +;WIDTH:0.444584 +G1 X-9.962 Y-9.729 E.00961 +;WIDTH:0.405249 +G1 X-10.258 Y-9.928 E.01053 +;WIDTH:0.41663 +G1 X-10.378 Y-9.962 E.00382 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.61 Y-10.297 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.455652 +G1 F18000 +G1 X-10.636 Y-10.252 E.00174 +G1 X-10.687 Y-10.252 E.00174 +G1 X-10.713 Y-10.297 E.00174 +G1 X-10.687 Y-10.342 E.00174 +G1 X-10.636 Y-10.342 E.00174 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-10.687 Y-10.342 +G1 X-10.713 Y-10.297 +G1 X-10.687 Y-10.252 +G1 X-10.636 Y-10.252 +G1 X-10.61 Y-10.297 +;WIPE_END +G1 X-12.238 Y-13.097 Z1.4 F30000 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.505567 +G1 F12000 +G1 X-12.235 Y-12.423 E.0254 +;WIDTH:0.504452 +G1 X-12.694 Y-12.415 E.01727 +;WIDTH:0.505567 +G1 X-12.703 Y-13.089 E.02541 +G1 X-12.278 Y-13.096 E.01603 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-12.235 Y-12.423 +G1 X-12.694 Y-12.415 +G1 X-12.703 Y-13.089 +G1 X-12.51 Y-13.092 +;WIPE_END +G1 X-18.944 Y-11.404 Z1.4 F30000 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.465453 +G1 F12000 +G1 X-18.946 Y-9.065 E.08056 +;WIDTH:0.462469 +G1 X-19.107 Y-8.882 E.00831 +;WIDTH:0.419999 +G1 X-19.211 Y-8.855 E.00332 +;WIDTH:0.427052 +G1 X-19.426 Y-8.94 E.00724 +G1 X-20.677 Y-11.1 E.07814 +;WIDTH:0.469867 +G1 X-20.69 Y-11.185 E.00298 +;WIDTH:0.515373 +G1 X-20.704 Y-11.269 E.0033 +;WIDTH:0.560879 +G1 X-20.717 Y-11.354 E.00361 +G1 X-20.648 Y-11.371 E.00297 +;WIDTH:0.527373 +G1 X-20.58 Y-11.388 E.00278 +;WIDTH:0.493867 +G1 X-20.512 Y-11.404 E.00258 +;WIDTH:0.46036 +G1 X-19.155 Y-11.404 E.04615 +;WIDTH:0.465453 +G1 X-18.984 Y-11.404 E.0059 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-18.521 Y-11.404 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.636894 +G1 F12000 +G1 X-18.521 Y-11.352 E.00253 +;WIDTH:0.594034 +G1 X-18.521 Y-11.3 E.00235 +;WIDTH:0.551174 +G1 X-18.521 Y-11.247 E.00217 +;WIDTH:0.508314 +G1 X-18.521 Y-11.195 E.00198 +;WIDTH:0.465453 +G1 X-18.526 Y-8.948 E.07736 +;WIDTH:0.462469 +G1 X-18.507 Y-8.421 E.01805 +;WIDTH:0.419999 +G1 X-18.509 Y-7.529 E.02738 +G1 X-19.064 Y-7.537 E.01705 +G1 X-19.467 Y-8.236 E.0248 +;WIDTH:0.427052 +G1 X-21.007 Y-10.909 E.09655 +;WIDTH:0.458977 +G1 X-21.058 Y-11.033 E.00455 +;WIDTH:0.493595 +G1 X-21.11 Y-11.157 E.00492 +;WIDTH:0.528212 +G1 X-21.161 Y-11.28 E.0053 +;WIDTH:0.560879 +G1 X-21.188 Y-11.772 E.02076 +G1 X-20.962 Y-11.788 E.00953 +;WIDTH:0.527373 +G1 X-20.737 Y-11.805 E.00892 +;WIDTH:0.493867 +G1 X-20.512 Y-11.822 E.0083 +;WIDTH:0.46036 +G1 X-18.944 Y-11.822 E.05333 +;WIDTH:0.465447 +G1 X-18.945 Y-13.115 E.04454 +;WIDTH:0.464459 +G1 X-18.523 Y-13.117 E.01446 +G1 X-18.523 Y-12.906 E.00725 +;WIDTH:0.507568 +G1 X-18.523 Y-12.635 E.01026 +;WIDTH:0.550677 +G1 X-18.522 Y-12.364 E.01121 +;WIDTH:0.593786 +G1 X-18.522 Y-12.093 E.01217 +;WIDTH:0.636894 +G1 X-18.521 Y-11.822 E.01312 +G1 X-18.486 Y-11.822 E.0017 +;WIDTH:0.595021 +G1 X-18.451 Y-11.822 E.00158 +;WIDTH:0.553148 +G1 X-18.416 Y-11.822 E.00146 +;WIDTH:0.511274 +G1 X-18.125 Y-11.822 E.01111 +;WIDTH:0.485394 +G1 X-17.834 Y-11.821 E.0105 +;WIDTH:0.459514 +G1 X-17.835 Y-11.406 E.01409 +;WIDTH:0.503859 +G1 X-18.006 Y-11.406 E.00645 +;WIDTH:0.548204 +G1 X-18.178 Y-11.405 E.00707 +;WIDTH:0.592549 +G1 X-18.35 Y-11.405 E.00769 +;WIDTH:0.636894 +G1 X-18.481 Y-11.404 E.00637 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-18.521 Y-11.352 +G1 X-18.521 Y-11.3 +G1 X-18.521 Y-11.247 +G1 X-18.521 Y-11.195 +G1 X-18.525 Y-9.418 +;WIPE_END +G1 X-18.922 Y-8.404 Z1.4 F30000 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.64723 +G1 F12410.85 +G1 X-18.959 Y-8.338 E.00372 +G1 X-19.035 Y-8.338 E.00372 +G1 X-19.073 Y-8.404 E.00372 +G1 X-19.035 Y-8.469 E.00372 +G1 X-18.959 Y-8.469 E.00372 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12410.85 +G1 X-19.035 Y-8.469 +G1 X-19.073 Y-8.404 +G1 X-19.035 Y-8.338 +G1 X-18.959 Y-8.338 +G1 X-18.922 Y-8.404 +;WIPE_END +G1 X-22.235 Y-12.421 Z1.4 F30000 +G1 Z1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.504453 +M73 P69 R2 +G1 F12000 +M73 P69 R2 +G1 X-22.694 Y-12.413 E.01727 +;WIDTH:0.505568 +G1 X-22.703 Y-13.087 E.02542 +G1 X-22.238 Y-13.095 E.01753 +G1 X-22.235 Y-12.461 E.02391 +;LAYER_CHANGE +;Z:1.2 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;1.2 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-22.694 Y-12.413 +G1 X-22.703 Y-13.087 +G1 X-22.238 Y-13.095 +G1 X-22.236 Y-12.696 +;WIPE_END +;AFTER_LAYER_CHANGE +;1.2 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z1.6 I.049 J1.216 P1 F30000 +G1 X-12.238 Y-13.097 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.50557 +G1 F12000 +G1 X-12.235 Y-12.423 E.02541 +;WIDTH:0.504454 +G1 X-12.694 Y-12.415 E.01727 +;WIDTH:0.50557 +G1 X-12.703 Y-13.089 E.02542 +G1 X-12.278 Y-13.096 E.01603 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-12.235 Y-12.423 +G1 X-12.694 Y-12.415 +G1 X-12.703 Y-13.089 +G1 X-12.511 Y-13.092 +;WIPE_END +G1 X-8.83 Y-12.56 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.479856 +G1 F12000 +G1 X-8.65 Y-12.346 E.00995 +;WIDTH:0.481416 +G1 X-8.45 Y-11.867 E.01858 +;WIDTH:0.487411 +G1 X-8.397 Y-11.344 E.01901 +;WIDTH:0.487685 +G1 X-8.45 Y-10.843 E.01826 +;WIDTH:0.492134 +G1 X-8.623 Y-10.423 E.01665 +G1 X-8.886 Y-10.144 E.01403 +;WIDTH:0.481028 +G1 X-9.111 Y-10.039 E.00886 +;WIDTH:0.461007 +G1 X-9.379 Y-9.993 E.00925 +;WIDTH:0.450938 +G1 X-9.854 Y-10.077 E.01606 +;WIDTH:0.41663 +G1 X-10.246 Y-10.359 E.01471 +;WIDTH:0.454105 +G1 X-10.335 Y-10.499 E.00557 +;WIDTH:0.49158 +G1 X-10.424 Y-10.64 E.00608 +;WIDTH:0.529055 +G1 X-10.513 Y-10.78 E.00659 +G1 X-10.539 Y-11.206 E.01688 +;WIDTH:0.490439 +G1 X-10.48 Y-11.713 E.01862 +;WIDTH:0.468783 +G1 X-10.302 Y-12.201 E.01801 +;WIDTH:0.46779 +G1 X-10.13 Y-12.455 E.01065 +G1 X-9.9 Y-12.648 E.01038 +;WIDTH:0.466849 +G1 X-9.51 Y-12.767 E.01408 +;WIDTH:0.473822 +G1 X-9.041 Y-12.694 E.01667 +;WIDTH:0.479856 +G1 X-8.864 Y-12.582 E.00746 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-8.65 Y-12.346 +G1 X-8.45 Y-11.867 +G1 X-8.397 Y-11.344 +G1 X-8.45 Y-10.843 +G1 X-8.501 Y-10.72 +;WIPE_END +G1 X-10.327 Y-9.967 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.419999 +G1 F12000 +G1 X-10.464 Y-9.955 E.00422 +;WIDTH:0.445403 +G1 X-10.522 Y-9.85 E.00392 +;WIDTH:0.470806 +G1 X-10.579 Y-9.745 E.00417 +G1 X-10.481 Y-9.12 E.02207 +;WIDTH:0.457247 +G1 X-10.297 Y-8.554 E.0201 +;WIDTH:0.468254 +G1 X-10.007 Y-8.139 E.01755 +G1 X-9.737 Y-7.965 E.01114 +;WIDTH:0.464727 +G1 X-9.26 Y-7.908 E.01651 +;WIDTH:0.478155 +G1 X-8.932 Y-8.011 E.0122 +;WIDTH:0.479888 +G1 X-8.676 Y-8.255 E.01259 +;WIDTH:0.480508 +G1 X-8.53 Y-8.573 E.01246 +G1 X-8.127 Y-8.486 E.01472 +G1 X-8.178 Y-8.282 E.00753 +G1 X-8.302 Y-8.025 E.01015 +;WIDTH:0.479563 +G1 X-8.639 Y-7.673 E.01734 +G1 X-9.03 Y-7.513 E.01505 +;WIDTH:0.476785 +G1 X-9.555 Y-7.489 E.01857 +G1 X-9.965 Y-7.604 E.01505 +;WIDTH:0.471082 +G1 X-10.304 Y-7.841 E.01445 +;WIDTH:0.468256 +G1 X-10.605 Y-8.24 E.01729 +;WIDTH:0.47196 +G1 X-10.804 Y-8.703 E.01762 +G1 X-10.894 Y-9.088 E.01385 +;WIDTH:0.469126 +G1 X-11.008 Y-9.762 E.02374 +G1 X-11.057 Y-10.279 E.01802 +;WIDTH:0.456351 +G1 X-11.037 Y-10.44 E.00548 +;WIDTH:0.492703 +G1 X-11.017 Y-10.602 E.00596 +;WIDTH:0.529055 +G1 X-10.982 Y-11.265 E.02629 +;WIDTH:0.490439 +G1 X-10.901 Y-11.798 E.01968 +;WIDTH:0.472133 +G1 X-10.738 Y-12.278 E.01773 +;WIDTH:0.470555 +G1 X-10.524 Y-12.644 E.01476 +;WIDTH:0.469828 +G1 X-10.263 Y-12.914 E.01307 +;WIDTH:0.475346 +G1 X-9.887 Y-13.119 E.01509 +G1 X-9.428 Y-13.188 E.01637 +;WIDTH:0.473876 +G1 X-8.929 Y-13.11 E.01772 +;WIDTH:0.480956 +G1 X-8.616 Y-12.949 E.01257 +;WIDTH:0.484481 +G1 X-8.333 Y-12.667 E.01438 +;WIDTH:0.495099 +G1 X-8.098 Y-12.218 E.01865 +G1 X-7.985 Y-11.774 E.0169 +;WIDTH:0.490484 +G1 X-7.956 Y-11.291 E.01765 +;WIDTH:0.489851 +G1 X-8.023 Y-10.703 E.02154 +;WIDTH:0.492134 +G1 X-8.222 Y-10.223 E.01901 +G1 X-8.515 Y-9.875 E.01666 +;WIDTH:0.485565 +G1 X-8.801 Y-9.691 E.01227 +;WIDTH:0.480714 +G1 X-9.229 Y-9.592 E.01567 +;WIDTH:0.454365 +G1 X-9.688 Y-9.623 E.01542 +;WIDTH:0.444583 +G1 X-9.894 Y-9.7 E.0072 +;WIDTH:0.415384 +G1 X-10.293 Y-9.946 E.01423 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.61 Y-10.297 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.456252 +G1 F18000 +G1 X-10.636 Y-10.252 E.00174 +G1 X-10.688 Y-10.252 E.00174 +G1 X-10.714 Y-10.297 E.00174 +G1 X-10.688 Y-10.342 E.00174 +G1 X-10.636 Y-10.342 E.00174 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-10.688 Y-10.342 +G1 X-10.714 Y-10.297 +G1 X-10.688 Y-10.252 +G1 X-10.636 Y-10.252 +G1 X-10.61 Y-10.297 +;WIPE_END +G1 X-3.372 Y-12.717 Z1.6 F30000 +G1 X-2.238 Y-13.097 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.505568 +G1 F12000 +G1 X-2.235 Y-12.423 E.0254 +;WIDTH:0.504454 +G1 X-2.694 Y-12.415 E.01727 +;WIDTH:0.505568 +G1 X-2.703 Y-13.089 E.02541 +G1 X-2.278 Y-13.096 E.01603 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-2.235 Y-12.423 +G1 X-2.694 Y-12.415 +G1 X-2.703 Y-13.089 +G1 X-2.51 Y-13.092 +;WIPE_END +G1 X1.271 Y-10.178 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.545281 +G1 F12000 +G1 X1.297 Y-10.145 E.00172 +;WIDTH:0.509085 +G1 X1.323 Y-10.112 E.00159 +;WIDTH:0.472889 +G1 X1.349 Y-10.079 E.00147 +;WIDTH:0.449928 +G1 X1.705 Y-9.695 E.01738 +;WIDTH:0.479339 +G1 X1.877 Y-9.257 E.01671 +;WIDTH:0.491169 +G1 X1.916 Y-8.841 E.01526 +G1 X1.823 Y-8.305 E.01988 +;WIDTH:0.487822 +G1 X1.672 Y-8.005 E.01217 +G1 X1.316 Y-7.673 E.01765 +;WIDTH:0.460274 +G1 X.882 Y-7.505 E.01583 +;WIDTH:0.447155 +G1 X.379 Y-7.463 E.01663 +;WIDTH:0.456748 +G1 X-.027 Y-7.53 E.01388 +;WIDTH:0.466184 +G1 X-.427 Y-7.728 E.01541 +;WIDTH:0.47526 +G1 X-.75 Y-8.068 E.01652 +;WIDTH:0.484724 +G1 X-.909 Y-8.452 E.01494 +;WIDTH:0.496789 +G1 X-.961 Y-8.825 E.01394 +G1 X-.904 Y-9.34 E.01916 +;WIDTH:0.476278 +G1 X-.669 Y-9.792 E.01798 +;WIDTH:0.497527 +G1 X-.485 Y-9.987 E.00994 +;WIDTH:0.544084 +G1 X-.302 Y-10.183 E.01096 +G1 X-.506 Y-10.375 E.01145 +;WIDTH:0.495054 +G1 X-.71 Y-10.567 E.01033 +;WIDTH:0.478222 +G1 X-.941 Y-10.917 E.01487 +;WIDTH:0.492365 +G1 X-1.059 Y-11.325 E.01556 +;WIDTH:0.497868 +G1 X-1.062 Y-11.841 E.01913 +;WIDTH:0.501362 +G1 X-.945 Y-12.331 E.01883 +G1 X-.743 Y-12.68 E.01504 +;WIDTH:0.475111 +G1 X-.471 Y-12.943 E.01333 +;WIDTH:0.466174 +G1 X-.069 Y-13.132 E.01534 +;WIDTH:0.458075 +G1 X.496 Y-13.203 E.01926 +;WIDTH:0.455156 +G1 X1.048 Y-13.131 E.0187 +;WIDTH:0.469365 +G1 X1.498 Y-12.906 E.01748 +;WIDTH:0.479415 +G1 X1.758 Y-12.648 E.01303 +;WIDTH:0.492662 +G1 X1.974 Y-12.16 E.01957 +;WIDTH:0.501285 +G1 X2.043 Y-11.599 E.02108 +G1 X1.972 Y-11.075 E.01978 +;WIDTH:0.490625 +G1 X1.831 Y-10.739 E.01328 +;WIDTH:0.465054 +G1 X1.657 Y-10.534 E.00926 +;WIDTH:0.475779 +G1 X1.528 Y-10.415 E.00616 +;WIDTH:0.51053 +G1 X1.4 Y-10.297 E.00666 +;WIDTH:0.545281 +G1 X1.301 Y-10.206 E.00552 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X.92 Y-10.42 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.538475 +G1 F12000 +G1 X.637 Y-10.41 E.01142 +;WIDTH:0.499686 +G1 X.354 Y-10.4 E.01053 +;WIDTH:0.50249 +G1 X.197 Y-10.41 E.00589 +;WIDTH:0.544084 +G1 X.04 Y-10.419 E.00643 +G1 X-.197 Y-10.64 E.01322 +;WIDTH:0.495054 +G1 X-.435 Y-10.86 E.01193 +;WIDTH:0.475263 +G1 X-.594 Y-11.239 E.01448 +;WIDTH:0.497868 +G1 X-.614 Y-11.789 E.02041 +;WIDTH:0.488671 +G1 X-.445 Y-12.347 E.02119 +;WIDTH:0.480957 +G1 X-.228 Y-12.596 E.01178 +;WIDTH:0.466174 +G1 X.007 Y-12.724 E.00922 +;WIDTH:0.458075 +G1 X.533 Y-12.803 E.01801 +;WIDTH:0.447592 +G1 X1.027 Y-12.704 E.01662 +;WIDTH:0.469682 +G1 X1.251 Y-12.565 E.00917 +;WIDTH:0.478827 +G1 X1.41 Y-12.369 E.00897 +;WIDTH:0.488114 +G1 X1.55 Y-11.995 E.01447 +G1 X1.585 Y-11.411 E.02122 +;WIDTH:0.482958 +G1 X1.453 Y-10.913 E.01847 +;WIDTH:0.465054 +G1 X1.062 Y-10.512 E.01928 +;WIDTH:0.49899 +G1 X.991 Y-10.466 E.00315 +;WIDTH:0.538475 +G1 X.953 Y-10.441 E.0018 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X.908 Y-9.96 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.545281 +G1 F12000 +G1 X1.038 Y-9.833 E.00746 +;WIDTH:0.509384 +G1 X1.169 Y-9.706 E.00693 +;WIDTH:0.473487 +G1 X1.299 Y-9.579 E.00639 +;WIDTH:0.480142 +G1 X1.45 Y-9.178 E.01525 +;WIDTH:0.501266 +G1 X1.458 Y-8.756 E.01578 +G1 X1.364 Y-8.345 E.01574 +;WIDTH:0.484876 +G1 X1.091 Y-8.02 E.01528 +;WIDTH:0.460274 +G1 X.697 Y-7.875 E.01429 +;WIDTH:0.444483 +G1 X.305 Y-7.869 E.01281 +;WIDTH:0.455696 +G1 X-.133 Y-8.023 E.01562 +;WIDTH:0.47526 +G1 X-.394 Y-8.303 E.01349 +;WIDTH:0.488222 +G1 X-.504 Y-8.664 E.01369 +G1 X-.494 Y-9.174 E.0185 +;WIDTH:0.473419 +G1 X-.303 Y-9.617 E.01693 +;WIDTH:0.480432 +G1 X-.126 Y-9.782 E.00862 +;WIDTH:0.5284 +G1 X.051 Y-9.946 E.00956 +G1 X.248 Y-9.966 E.00786 +;WIDTH:0.493584 +G1 X.446 Y-9.986 E.0073 +;WIDTH:0.502025 +G1 X.677 Y-9.973 E.00865 +;WIDTH:0.545281 +G1 X.868 Y-9.963 E.00783 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X1.038 Y-9.833 +G1 X1.169 Y-9.706 +G1 X1.299 Y-9.579 +G1 X1.45 Y-9.178 +G1 X1.458 Y-8.756 +G1 X1.364 Y-8.345 +G1 X1.268 Y-8.23 +;WIPE_END +G1 X7.905 Y-12 Z1.6 F30000 +G1 X9.082 Y-12.669 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.519379 +G1 F12000 +G1 X9.008 Y-12.65 E.00295 +;WIDTH:0.556367 +G1 X8.935 Y-12.632 E.00317 +;WIDTH:0.593355 +G1 X8.861 Y-12.613 E.0034 +G1 X8.861 Y-12.558 E.00246 +;WIDTH:0.557062 +G1 X8.861 Y-12.504 E.0023 +;WIDTH:0.520768 +G1 X8.861 Y-12.449 E.00213 +;WIDTH:0.484474 +G1 X8.86 Y-8.324 E.14841 +;WIDTH:0.483999 +G1 X8.876 Y-7.927 E.01428 +;WIDTH:0.451999 +G1 X8.892 Y-7.53 E.01325 +;WIDTH:0.419999 +G1 X8.395 Y-7.532 E.01528 +G1 X8.255 Y-7.644 E.00551 +;WIDTH:0.42972 +G1 X7.828 Y-7.996 E.01744 +;WIDTH:0.435164 +G1 X7.301 Y-8.433 E.0219 +G1 X7.296 Y-8.945 E.01634 +G1 X8.074 Y-8.295 E.03241 +;WIDTH:0.42972 +G1 X8.297 Y-8.282 E.00703 +;WIDTH:0.451999 +G1 X8.358 Y-8.362 E.00336 +;WIDTH:0.483999 +G1 X8.419 Y-8.443 E.00363 +;WIDTH:0.520451 +G1 X8.419 Y-9.833 E.0541 +;WIDTH:0.556903 +G1 X8.42 Y-11.223 E.05823 +;WIDTH:0.593355 +G1 X8.42 Y-12.613 E.06235 +G1 X8.346 Y-12.632 E.0034 +;WIDTH:0.556414 +G1 X8.272 Y-12.65 E.00317 +;WIDTH:0.519473 +G1 X8.199 Y-12.669 E.00295 +;WIDTH:0.482658 +G1 X7.188 Y-12.669 E.03622 +G1 X7.183 Y-13.109 E.01576 +G1 X8.199 Y-13.108 E.0364 +;WIDTH:0.519473 +G1 X8.346 Y-13.09 E.00576 +;WIDTH:0.556414 +G1 X8.493 Y-13.071 E.00621 +;WIDTH:0.593355 +G1 X8.64 Y-13.053 E.00665 +G1 X8.788 Y-13.071 E.00665 +;WIDTH:0.556367 +G1 X8.935 Y-13.09 E.00621 +;WIDTH:0.519379 +G1 X9.082 Y-13.108 E.00576 +;WIDTH:0.48239 +G1 X10.048 Y-13.108 E.0346 +;WIDTH:0.482269 +G1 X10.048 Y-12.669 E.01573 +;WIDTH:0.48239 +G1 X9.122 Y-12.669 E.03315 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X9.008 Y-12.65 +G1 X8.935 Y-12.632 +G1 X8.861 Y-12.613 +G1 X8.861 Y-12.558 +G1 X8.861 Y-12.504 +G1 X8.861 Y-12.449 +G1 X8.861 Y-10.88 +;WIPE_END +G1 X8.552 Y-7.948 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.447808 +G1 F18000 +G1 X8.527 Y-7.905 E.00167 +G1 X8.476 Y-7.905 E.00167 +G1 X8.451 Y-7.948 E.00167 +G1 X8.476 Y-7.992 E.00167 +G1 X8.527 Y-7.992 E.00167 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X8.476 Y-7.992 +G1 X8.451 Y-7.948 +G1 X8.476 Y-7.905 +G1 X8.527 Y-7.905 +G1 X8.552 Y-7.948 +;WIPE_END +G1 X11.237 Y-13.087 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.506358 +G1 F12000 +G1 X11.704 Y-13.094 E.01762 +G1 X11.704 Y-12.421 E.0254 +;WIDTH:0.504948 +G1 X11.245 Y-12.413 E.01728 +;WIDTH:0.506358 +G1 X11.238 Y-13.047 E.02392 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X11.704 Y-13.094 +G1 X11.704 Y-12.421 +G1 X11.245 Y-12.413 +G1 X11.24 Y-12.813 +;WIPE_END +G1 X17.06 Y-10.784 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.460667 +G1 F12000 +G1 X17.083 Y-10.692 E.00322 +;WIDTH:0.506388 +G1 X17.106 Y-10.6 E.00357 +;WIDTH:0.506582 +G1 X19.075 Y-10.6 E.07438 +G1 X19.098 Y-10.692 E.00357 +;WIDTH:0.46094 +G1 X19.12 Y-10.784 E.00322 +;WIDTH:0.415298 +G1 X19.128 Y-12.559 E.05387 +;WIDTH:0.41083 +G1 X19.495 Y-12.542 E.01102 +G1 X19.495 Y-12.366 E.00527 +;WIDTH:0.415298 +G1 X19.493 Y-10.783 E.04806 +;WIDTH:0.46094 +G1 X19.47 Y-10.599 E.00629 +;WIDTH:0.506582 +G1 X19.447 Y-10.416 E.00698 +G1 X19.47 Y-10.232 E.00702 +;WIDTH:0.460909 +G1 X19.493 Y-10.047 E.00633 +;WIDTH:0.415235 +G1 X19.493 Y-8.46 E.04815 +G1 X19.121 Y-8.447 E.0113 +G1 X19.121 Y-10.047 E.04854 +;WIDTH:0.460909 +G1 X19.098 Y-10.139 E.00324 +;WIDTH:0.506582 +G1 X19.075 Y-10.232 E.00359 +G1 X17.106 Y-10.231 E.07438 +;WIDTH:0.506388 +G1 X17.083 Y-10.14 E.00357 +;WIDTH:0.460731 +M73 P70 R2 +G1 X17.06 Y-10.048 E.00321 +M73 P70 R2 +;WIDTH:0.415074 +G1 X17.053 Y-8.444 E.04864 +;WIDTH:0.410881 +G1 X16.686 Y-8.461 E.01102 +G1 X16.686 Y-8.637 E.00527 +;WIDTH:0.415074 +G1 X16.688 Y-10.049 E.04284 +;WIDTH:0.460731 +G1 X16.711 Y-10.233 E.00629 +;WIDTH:0.506388 +G1 X16.734 Y-10.416 E.00697 +G1 X16.711 Y-10.6 E.00699 +;WIDTH:0.460667 +G1 X16.688 Y-10.783 E.0063 +;WIDTH:0.414945 +G1 X16.687 Y-12.56 E.05386 +;WIDTH:0.411587 +G1 X17.055 Y-12.549 E.01106 +G1 X17.056 Y-12.371 E.00536 +;WIDTH:0.414945 +G1 X17.06 Y-10.824 E.04689 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X17.083 Y-10.692 +G1 X17.106 Y-10.6 +G1 X18.878 Y-10.6 +;WIPE_END +G1 X21.019 Y-8.917 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.398061 +G1 F12000 +G1 X21.332 Y-8.766 E.01007 +;WIDTH:0.393735 +G1 X21.839 Y-8.715 E.01458 +;WIDTH:0.400066 +G1 X22.295 Y-8.813 E.01357 +;WIDTH:0.418588 +G1 X22.586 Y-9.069 E.01186 +G1 X22.69 Y-9.311 E.00806 +;WIDTH:0.410796 +G1 X23.058 Y-9.255 E.01118 +;WIDTH:0.411115 +G1 X22.971 Y-9.011 E.00776 +;WIDTH:0.427277 +G1 X22.801 Y-8.733 E.01022 +G1 X22.457 Y-8.486 E.01328 +;WIDTH:0.404731 +G1 X21.958 Y-8.382 E.015 +;WIDTH:0.388005 +G1 X21.494 Y-8.383 E.01307 +;WIDTH:0.393164 +G1 X21.033 Y-8.5 E.01355 +;WIDTH:0.397402 +G1 X20.726 Y-8.703 E.01065 +;WIDTH:0.405257 +G1 X20.502 Y-9.046 E.0121 +;WIDTH:0.412326 +G1 X20.446 Y-9.526 E.01455 +;WIDTH:0.432275 +G1 X20.548 Y-9.91 E.01259 +G1 X20.798 Y-10.241 E.01319 +;WIDTH:0.434649 +G1 X21.245 Y-10.475 E.01609 +G1 X22.179 Y-10.748 E.03107 +;WIDTH:0.434309 +G1 X22.497 Y-10.895 E.01115 +G1 X22.691 Y-11.081 E.00859 +;WIDTH:0.421753 +G1 X22.774 Y-11.276 E.00653 +;WIDTH:0.41295 +G1 X22.758 Y-11.731 E.01374 +;WIDTH:0.411678 +G1 X22.612 Y-11.986 E.00881 +;WIDTH:0.396887 +G1 X22.328 Y-12.181 E.00994 +;WIDTH:0.397461 +G1 X21.882 Y-12.268 E.01312 +;WIDTH:0.408231 +G1 X21.446 Y-12.239 E.01299 +;WIDTH:0.412299 +G1 X21.024 Y-12.107 E.01333 +;WIDTH:0.421048 +G1 X20.781 Y-11.869 E.01048 +G1 X20.652 Y-11.576 E.00985 +;WIDTH:0.419625 +G1 X20.294 Y-11.649 E.01122 +G1 X20.387 Y-11.933 E.00918 +;WIDTH:0.416515 +G1 X20.612 Y-12.254 E.01193 +G1 X20.902 Y-12.456 E.01075 +;WIDTH:0.408231 +G1 X21.397 Y-12.599 E.01534 +G1 X21.911 Y-12.62 E.01534 +;WIDTH:0.397461 +G1 X22.416 Y-12.522 E.01486 +;WIDTH:0.403356 +G1 X22.755 Y-12.345 E.01122 +;WIDTH:0.407645 +G1 X23.022 Y-12.031 E.01224 +;WIDTH:0.416191 +G1 X23.135 Y-11.718 E.01012 +G1 X23.152 Y-11.363 E.01081 +;WIDTH:0.422901 +G1 X23.058 Y-10.942 E.01337 +;WIDTH:0.429723 +G1 X22.771 Y-10.61 E.01384 +;WIDTH:0.432953 +G1 X22.302 Y-10.378 E.01664 +;WIDTH:0.434649 +G1 X21.37 Y-10.103 E.03102 +G1 X21.031 Y-9.932 E.0121 +;WIDTH:0.430308 +G1 X20.824 Y-9.599 E.01239 +;WIDTH:0.404909 +G1 X20.844 Y-9.174 E.01253 +;WIDTH:0.404726 +G1 X20.996 Y-8.95 E.008 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X21.332 Y-8.766 +G1 X21.839 Y-8.715 +G1 X22.295 Y-8.813 +G1 X22.586 Y-9.069 +G1 X22.686 Y-9.302 +;WIPE_END +G1 X25.819 Y-10.92 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.498524 +G1 F12000 +G1 X25.867 Y-10.92 E.00178 +;WIDTH:0.465107 +G1 X25.915 Y-10.92 E.00165 +;WIDTH:0.43169 +G1 X25.963 Y-10.919 E.00152 +;WIDTH:0.408913 +G1 X27.034 Y-10.907 E.03195 +G1 X27.458 Y-10.787 E.01314 +;WIDTH:0.405963 +G1 X27.729 Y-10.577 E.01013 +;WIDTH:0.411626 +G1 X27.889 Y-10.381 E.0076 +;WIDTH:0.414853 +G1 X28.049 Y-9.916 E.01491 +;WIDTH:0.41423 +G1 X28.037 Y-9.399 E.01566 +;WIDTH:0.423885 +G1 X27.923 Y-9.007 E.01268 +;WIDTH:0.424093 +G1 X27.644 Y-8.673 E.01349 +G1 X27.314 Y-8.508 E.01149 +;WIDTH:0.406748 +G1 X26.796 Y-8.436 E.0155 +;WIDTH:0.401192 +G1 X25.962 Y-8.436 E.02435 +;WIDTH:0.435092 +G1 X25.699 Y-8.453 E.0084 +;WIDTH:0.468991 +G1 X25.437 Y-8.47 E.00912 +G1 X25.423 Y-8.722 E.00874 +;WIDTH:0.440505 +G1 X25.408 Y-8.973 E.00816 +;WIDTH:0.413031 +G1 X25.406 Y-10.386 E.04262 +;WIDTH:0.455778 +G1 X25.428 Y-10.564 E.00602 +;WIDTH:0.498524 +G1 X25.449 Y-10.742 E.00664 +G1 X25.427 Y-10.919 E.00665 +;WIDTH:0.456032 +G1 X25.406 Y-11.097 E.00603 +;WIDTH:0.414454 +G1 X25.404 Y-12.558 E.04422 +G1 X25.776 Y-12.559 E.01125 +G1 X25.776 Y-11.097 E.04425 +;WIDTH:0.456032 +G1 X25.798 Y-11.008 E.00308 +;WIDTH:0.498524 +G1 X25.81 Y-10.958 E.00191 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X25.867 Y-10.92 +G1 X25.915 Y-10.92 +G1 X25.963 Y-10.919 +G1 X27.034 Y-10.907 +G1 X27.458 Y-10.787 +G1 X27.713 Y-10.589 +;WIPE_END +G1 X27.519 Y-9.072 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.407909 +G1 F12000 +G1 X27.213 Y-8.858 E.01111 +;WIDTH:0.406748 +G1 X26.796 Y-8.795 E.01251 +;WIDTH:0.401192 +G1 X25.962 Y-8.795 E.02435 +;WIDTH:0.435092 +G1 X25.884 Y-8.812 E.00255 +;WIDTH:0.468991 +G1 X25.806 Y-8.829 E.00277 +G1 X25.791 Y-8.901 E.00257 +;WIDTH:0.440505 +G1 X25.777 Y-8.974 E.0024 +;WIDTH:0.413031 +G1 X25.777 Y-10.386 E.04261 +;WIDTH:0.455778 +G1 X25.798 Y-10.475 E.00307 +;WIDTH:0.498524 +G1 X25.819 Y-10.564 E.00339 +;WIDTH:0.398284 +G1 X26.817 Y-10.563 E.0289 +;WIDTH:0.407595 +G1 X27.283 Y-10.47 E.0141 +;WIDTH:0.411626 +G1 X27.555 Y-10.227 E.01098 +;WIDTH:0.414896 +G1 X27.68 Y-9.869 E.01148 +G1 X27.666 Y-9.401 E.01422 +;WIDTH:0.414205 +G1 X27.535 Y-9.109 E.00967 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X27.213 Y-8.858 +G1 X26.796 Y-8.795 +G1 X25.962 Y-8.795 +G1 X25.884 Y-8.812 +G1 X25.806 Y-8.829 +G1 X25.791 Y-8.901 +G1 X25.777 Y-8.974 +G1 X25.777 Y-9.002 +;WIPE_END +G1 X33.213 Y-10.724 Z1.6 F30000 +G1 X33.73 Y-10.844 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.444624 +G1 F12000 +G1 X33.723 Y-10.749 E.00314 +;WIDTH:0.394811 +G1 X33.274 Y-9.405 E.04063 +;WIDTH:0.419999 +G1 X33.111 Y-9.272 E.00646 +G1 X33.008 Y-9.273 E.00315 +G1 X32.846 Y-9.408 E.00648 +;WIDTH:0.397336 +G1 X32.398 Y-10.748 E.04083 +;WIDTH:0.446916 +G1 X32.391 Y-10.844 E.00315 +;WIDTH:0.496496 +G1 X32.384 Y-10.939 E.00353 +G1 X32.445 Y-10.953 E.0023 +;WIDTH:0.462728 +G1 X32.506 Y-10.966 E.00213 +;WIDTH:0.42896 +G1 X32.567 Y-10.98 E.00196 +;WIDTH:0.395192 +G1 X33.556 Y-10.98 E.0284 +;WIDTH:0.444815 +G1 X33.646 Y-10.96 E.00302 +;WIDTH:0.494437 +G1 X33.736 Y-10.94 E.0034 +G1 X33.732 Y-10.884 E.00206 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X33.852 Y-11.292 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X33.904 Y-11.372 E.00352 +;WIDTH:0.447399 +G1 X33.956 Y-11.452 E.00315 +;WIDTH:0.405912 +G1 X34.32 Y-12.537 E.03386 +G1 X34.714 Y-12.564 E.01168 +G1 X34.295 Y-11.338 E.03834 +;WIDTH:0.447399 +G1 X34.212 Y-11.17 E.00616 +;WIDTH:0.494437 +G1 X34.13 Y-11.003 E.00687 +G1 X34.093 Y-10.819 E.00688 +;WIDTH:0.444624 +G1 X34.056 Y-10.636 E.00612 +;WIDTH:0.394811 +G1 X33.602 Y-9.302 E.0404 +;WIDTH:0.419999 +G1 X33.29 Y-8.446 E.02801 +G1 X32.836 Y-8.447 E.01394 +G1 X32.524 Y-9.292 E.02769 +;WIDTH:0.397336 +G1 X32.063 Y-10.635 E.041 +;WIDTH:0.446916 +G1 X32.025 Y-10.817 E.00613 +;WIDTH:0.496496 +G1 X31.988 Y-11 E.00688 +G1 X31.906 Y-11.165 E.00684 +;WIDTH:0.449691 +G1 X31.823 Y-11.331 E.00613 +;WIDTH:0.408958 +G1 X31.501 Y-12.266 E.0295 +G1 X31.423 Y-12.303 E.00257 +;WIDTH:0.361242 +G1 X31.346 Y-12.34 E.00223 +;WIDTH:0.344115 +G1 X31.229 Y-12.319 E.00291 +;WIDTH:0.374681 +G1 X31.165 Y-12.262 E.00231 +;WIDTH:0.405246 +G1 X31.102 Y-12.205 E.00252 +G1 X31.01 Y-12.201 E.00272 +;WIDTH:0.405578 +G1 X29.449 Y-12.202 E.04612 +;WIDTH:0.439583 +G1 X29.37 Y-12.185 E.0026 +;WIDTH:0.473587 +G1 X29.292 Y-12.168 E.00282 +G1 X29.277 Y-12.094 E.00264 +;WIDTH:0.444413 +G1 X29.263 Y-12.02 E.00246 +;WIDTH:0.415238 +G1 X29.262 Y-8.445 E.10846 +;WIDTH:0.414686 +G1 X28.89 Y-8.443 E.01126 +G1 X28.89 Y-8.63 E.00566 +;WIDTH:0.415238 +G1 X28.89 Y-12.02 E.10285 +;WIDTH:0.444413 +G1 X28.905 Y-12.275 E.00836 +;WIDTH:0.473587 +G1 X28.92 Y-12.53 E.00897 +G1 X29.184 Y-12.547 E.00931 +;WIDTH:0.439583 +G1 X29.449 Y-12.564 E.00857 +;WIDTH:0.405578 +G1 X31.01 Y-12.564 E.04612 +;WIDTH:0.405246 +G1 X31.096 Y-12.573 E.00256 +;WIDTH:0.371635 +G1 X31.182 Y-12.582 E.00232 +;WIDTH:0.338023 +G1 X31.268 Y-12.592 E.00209 +;WIDTH:0.33989 +G1 X31.442 Y-12.579 E.00422 +;WIDTH:0.375369 +G1 X31.616 Y-12.566 E.00472 +;WIDTH:0.410848 +G1 X31.79 Y-12.553 E.00523 +G1 X31.848 Y-12.384 E.00534 +;WIDTH:0.408958 +G1 X32.163 Y-11.451 E.02937 +;WIDTH:0.449697 +G1 X32.215 Y-11.371 E.00318 +;WIDTH:0.496496 +G1 X32.268 Y-11.291 E.00354 +G1 X32.367 Y-11.305 E.00372 +;WIDTH:0.462728 +G1 X32.467 Y-11.319 E.00344 +;WIDTH:0.42896 +G1 X32.567 Y-11.332 E.00316 +;WIDTH:0.395192 +G1 X33.556 Y-11.332 E.0284 +;WIDTH:0.444815 +G1 X33.704 Y-11.312 E.00488 +;WIDTH:0.494437 +G1 X33.812 Y-11.298 E.00401 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X33.904 Y-11.372 +G1 X33.956 Y-11.452 +G1 X34.32 Y-12.537 +G1 X34.714 Y-12.564 +G1 X34.634 Y-12.331 +;WIPE_END +G1 X33.106 Y-8.907 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.395996 +G1 F18000 +G1 X33.084 Y-8.869 E.00127 +G1 X33.018 Y-8.907 E.0022 +G1 X33.084 Y-8.945 E.0022 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X33.018 Y-8.907 +G1 X33.084 Y-8.869 +G1 X33.106 Y-8.907 +;WIPE_END +G1 X25.721 Y-6.98 Z1.6 F30000 +G1 X-35.997 Y9.126 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.523582 +G1 F15603.481 +G1 X-36.096 Y9.468 E.01392 +;WIDTH:0.525957 +G1 F15526.762 +G1 X-36.14 Y9.993 E.02076 +G1 X-36.14 Y10.599 E.02386 +G1 X-36.508 Y10.197 E.02145 +G1 X-36.854 Y10.004 E.0156 +G1 X-36.571 Y9.83 E.01306 +G1 X-36.239 Y9.541 E.01733 +G1 X-36.017 Y9.161 E.01732 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15526.762 +G1 X-36.096 Y9.468 +G1 X-36.14 Y9.993 +G1 X-36.14 Y10.599 +G1 X-36.508 Y10.197 +G1 X-36.512 Y10.195 +;WIPE_END +G1 X-29.229 Y12.479 Z1.6 F30000 +G1 X-26.5 Y13.334 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-26.466 Y13.465 E.00448 +G1 X-26.117 Y14.297 E.02992 +G1 X-25.635 Y14.955 E.02707 +G1 X-25.237 Y15.318 E.01788 +G1 X-24.466 Y15.786 E.02992 +G1 X-23.746 Y16.02 E.02512 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.607 Y16.063 E.0054 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.468 Y16.106 E.00597 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.329 Y16.149 E.00654 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.192 E.00711 +G1 X32.757 Y16.192 E2.73304 +G1 X33.602 Y16.029 E.04204 +;WIDTH:0.655272 +G1 F12247.857 +G1 X34.067 Y15.831 E.02524 +G1 X34.551 Y15.573 E.02734 +;WIDTH:0.643373 +G1 F12490.569 +G1 X35.134 Y15.09 E.03706 +G1 X35.466 Y14.707 E.02479 +;WIDTH:0.655267 +G1 F12247.957 +G1 X35.726 Y14.273 E.02523 +G1 X35.965 Y13.781 E.02734 +;WIDTH:0.645088 +G1 F12454.995 +G1 X36.141 Y13.1 E.03448 +G1 X36.196 Y12.577 E.02582 +G1 X36.357 Y12.748 E.01153 +G1 X36.765 Y13.039 E.0246 +;WIDTH:0.655273 +G1 F12247.837 +G1 X36.653 Y13.586 E.02785 +G1 X36.49 Y14.1 E.0269 +G1 X36.252 Y14.585 E.02694 +M73 P71 R2 +G1 X35.967 Y15.042 E.0269 +M73 P71 R2 +;WIDTH:0.643372 +G1 F12490.59 +G1 X35.621 Y15.456 E.02641 +;WIDTH:0.655273 +G1 F12247.837 +G1 X35.221 Y15.82 E.02694 +G1 X34.787 Y16.14 E.0269 +G1 X34.31 Y16.392 E.02694 +G1 X33.81 Y16.594 E.0269 +G1 X33.285 Y16.72 E.02694 +G1 X32.69 Y16.792 E.02994 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.792 E2.72975 +G1 X-23.305 Y16.816 E.00575 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.42 Y16.84 E.00528 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.535 Y16.864 E.00482 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.65 Y16.888 E.00436 +;WIDTH:0.449999 +G1 F18000 +G1 X-32.776 Y16.888 E.30272 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.372 Y16.478 E.01832 +G1 X-34.856 Y16.212 E.01832 +G1 X-35.303 Y15.888 E.01832 +G1 X-35.705 Y15.51 E.01831 +G1 X-36.041 Y15.104 E.01746 +G1 X-36.307 Y14.691 E.0163 +G1 X-36.54 Y14.221 E.0174 +;WIDTH:0.498866 +G1 F16449.315 +G1 X-36.555 Y14.108 E.00425 +;WIDTH:0.547733 +G1 F14856.986 +G1 X-36.57 Y13.994 E.00471 +;WIDTH:0.5966 +G1 F13545.731 +G1 X-36.584 Y13.881 E.00517 +;WIDTH:0.645467 +G1 F12447.162 +G1 X-36.599 Y13.767 E.00562 +G1 X-36.668 Y13.551 E.01115 +;WIDTH:0.649626 +G1 F12361.835 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.196 Y12.587 E.02645 +G1 X-36.07 Y13.461 E.04363 +;WIDTH:0.642407 +G1 F12510.696 +G1 X-35.996 Y13.582 E.00696 +;WIDTH:0.593293 +G1 F13627.122 +G1 X-35.922 Y13.704 E.00639 +;WIDTH:0.544179 +G1 F14962.326 +G1 X-35.848 Y13.826 E.00582 +;WIDTH:0.495065 +G1 F16587.598 +G1 X-35.688 Y14.169 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-35.395 Y14.603 E.01736 +G1 X-35.073 Y15.016 E.01736 +G1 X-34.489 Y15.499 E.02512 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.75 Y16.096 E.09751 +G1 X-28.819 Y15.899 E.03157 +G1 X-28.011 Y15.499 E.02992 +G1 X-27.328 Y14.91 E.02992 +G1 X-26.812 Y14.169 E.02992 +G1 X-26.514 Y13.372 E.02825 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-26.466 Y13.465 +G1 X-26.117 Y14.297 +G1 X-25.635 Y14.955 +G1 X-25.504 Y15.075 +;WIPE_END +G1 X-18.768 Y11.487 Z1.6 F30000 +G1 X12.682 Y-5.265 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X13.366 Y-5.59 E.02512 +G1 X13.849 Y-5.941 E.01981 +G1 X14.453 Y-6.611 E.02992 +G1 X14.818 Y-7.275 E.02512 +G1 X14.983 Y-7.843 E.01964 +G1 X15.312 Y-6.997 E.03012 +G1 X15.522 Y-6.678 E.01265 +G1 X15.927 Y-6.151 E.02207 +G1 X16.634 Y-5.59 E.02992 +G1 X17.318 Y-5.265 E.02512 +G1 X12.722 Y-5.265 E.15246 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X13.366 Y-5.59 +G1 X13.849 Y-5.941 +G1 X14.306 Y-6.447 +;WIPE_END +G1 X6.941 Y-4.442 Z1.6 F30000 +G1 X-33.249 Y6.5 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X-33.681 Y6.601 E.01472 +G1 X-34.498 Y7.009 E.0303 +G1 X-35.011 Y7.427 E.02195 +G1 X-35.377 Y7.837 E.01822 +G1 X-35.532 Y8.087 E.00976 +;WIDTH:0.490546 +G1 F16755.077 +G1 X-35.7 Y8.352 E.01145 +;WIDTH:0.531092 +G1 F15363.438 +G1 X-35.868 Y8.617 E.01248 +G1 X-35.866 Y8.585 E.00127 +;WIDTH:0.52451 +G1 F15573.412 +G1 X-35.938 Y8.247 E.01355 +;WIDTH:0.487255 +G1 F16879.176 +G1 X-36.011 Y7.91 E.0125 +;WIDTH:0.449999 +G1 F18000 +G1 X-36.318 Y7.419 E.01921 +G1 X-36.725 Y7.105 E.01704 +G1 X-36.898 Y7.027 E.00631 +G1 X-36.898 Y-12.481 E.64712 +G1 X-36.862 Y-13.051 E.01894 +G1 X-36.759 Y-13.593 E.01832 +G1 X-36.588 Y-14.119 E.01832 +G1 X-36.353 Y-14.618 E.01832 +G1 X-36.057 Y-15.084 E.01832 +G1 X-35.705 Y-15.51 E.01832 +G1 X-35.303 Y-15.888 E.01832 +G1 X-34.856 Y-16.212 E.01832 +G1 X-34.372 Y-16.478 E.01832 +G1 X-33.859 Y-16.682 E.01832 +G1 X-33.324 Y-16.819 E.01832 +G1 X-32.776 Y-16.888 E.01832 +G1 X32.776 Y-16.888 E2.17448 +G1 X33.324 Y-16.819 E.01832 +G1 X33.859 Y-16.682 E.01832 +G1 X34.372 Y-16.478 E.01832 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01831 +G1 X36.057 Y-15.084 E.01832 +G1 X36.353 Y-14.618 E.01831 +G1 X36.588 Y-14.119 E.01832 +G1 X36.759 Y-13.593 E.01832 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y-2.097 E.34447 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.874 Y-1.989 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.849 Y-1.882 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.825 Y-1.775 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X36.801 Y-1.667 E.00539 +G1 X36.801 Y6.966 E.42312 +G1 X36.357 Y7.252 E.02586 +G1 X36.199 Y7.42 E.01131 +G1 X36.199 Y-1.667 E.44539 +G1 X36.164 Y-1.761 E.00492 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.129 Y-1.856 E.00452 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.094 Y-1.95 E.00413 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.059 Y-2.044 E.00373 +;WIDTH:0.449999 +G1 F18000 +G1 X35.966 Y-2.632 E.01975 +G1 X35.617 Y-3.464 E.02992 +G1 X35.172 Y-4.076 E.02512 +G1 X34.976 Y-4.262 E.00897 +G1 X34.366 Y-4.743 E.02576 +G1 X33.681 Y-5.066 E.02512 +G1 X33.249 Y-5.167 E.01472 +G1 X33.681 Y-5.268 E.01472 +G1 X34.489 Y-5.668 E.02992 +G1 X35.172 Y-6.257 E.02992 +G1 X35.688 Y-6.997 E.02992 +G1 X36.003 Y-7.842 E.02992 +G1 X36.102 Y-8.666 E.02752 +G1 X36.102 Y-12.469 E.12614 +G1 X36.05 Y-12.948 E.01601 +G1 X35.966 Y-13.465 E.01736 +G1 X35.688 Y-14.169 E.02512 +G1 X35.395 Y-14.603 E.01736 +G1 X35.073 Y-15.016 E.01736 +G1 X34.489 Y-15.499 E.02512 +G1 X34.024 Y-15.738 E.01736 +G1 X33.543 Y-15.944 E.01736 +G1 X32.69 Y-16.096 E.02874 +G1 X18.25 Y-16.096 E.47899 +G1 X17.319 Y-15.899 E.03157 +G1 X16.511 Y-15.499 E.02992 +G1 X15.828 Y-14.91 E.02992 +G1 X15.312 Y-14.169 E.02992 +G1 X15 Y-13.334 E.02957 +G1 X14.966 Y-13.465 E.00448 +G1 X14.617 Y-14.297 E.02992 +G1 X14.073 Y-15.016 E.02992 +G1 X13.366 Y-15.576 E.02992 +G1 X12.542 Y-15.944 E.02992 +G1 X11.69 Y-16.096 E.02874 +G1 X-32.69 Y-16.096 E1.47214 +G1 X-33.171 Y-16.015 E.01617 +G1 X-33.681 Y-15.899 E.01736 +G1 X-34.366 Y-15.576 E.02512 +G1 X-34.781 Y-15.257 E.01736 +G1 X-35.172 Y-14.91 E.01736 +G1 X-35.617 Y-14.297 E.02512 +G1 X-35.827 Y-13.817 E.01736 +G1 X-36.003 Y-13.324 E.01736 +G1 X-36.102 Y-12.469 E.02857 +G1 X-36.102 Y-8.624 E.12752 +G1 X-35.966 Y-7.702 E.03094 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.172 Y-6.257 E.02512 +G1 X-34.976 Y-6.071 E.00897 +G1 X-34.366 Y-5.59 E.02576 +G1 X-33.681 Y-5.268 E.02512 +G1 X-33.249 Y-5.167 E.01472 +G1 X-33.681 Y-5.066 E.01472 +G1 X-34.489 Y-4.665 E.02992 +G1 X-35.172 Y-4.076 E.02992 +G1 X-35.688 Y-3.336 E.02992 +G1 X-36.003 Y-2.491 E.02992 +G1 X-36.102 Y-1.667 E.02752 +G1 X-36.102 Y3.042 E.15623 +G1 X-35.966 Y3.965 E.03094 +G1 X-35.617 Y4.797 E.02992 +G1 X-35.172 Y5.41 E.02512 +G1 X-34.924 Y5.639 E.0112 +G1 X-34.366 Y6.076 E.02353 +G1 X-33.681 Y6.399 E.02512 +G1 X-33.288 Y6.491 E.0134 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-33.681 Y6.601 +G1 X-34.498 Y7.009 +G1 X-35.011 Y7.427 +G1 X-35.022 Y7.44 +;WIPE_END +G1 X-27.392 Y7.649 Z1.6 F30000 +G1 X36.142 Y9.392 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.530591 +G1 F15379.221 +G1 X36.391 Y9.702 E.01577 +G1 X36.842 Y10 E.0215 +G1 X36.391 Y10.298 E.02149 +G1 X36.142 Y10.608 E.01578 +G1 X36.142 Y9.432 E.04671 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.243 Y8.461 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.525697 +G1 F12000 +G1 X36.282 Y8.607 E.00596 +;WIDTH:0.491266 +G1 X36.323 Y8.759 E.00575 +;WIDTH:0.456835 +G1 X36.364 Y8.911 E.00531 +;WIDTH:0.422404 +G1 X36.596 Y9.3 E.01402 +;WIDTH:0.419999 +G1 X36.955 Y9.577 E.01393 +G1 X37.29 Y9.669 E.01067 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01407 +G1 X36.391 Y11.023 E.01391 +;WIDTH:0.455234 +G1 X36.341 Y11.167 E.00511 +;WIDTH:0.490468 +G1 X36.291 Y11.311 E.00555 +;WIDTH:0.525702 +G1 X36.241 Y11.454 E.00599 +G1 X36.282 Y11.607 E.0062 +;WIDTH:0.491239 +G1 X36.323 Y11.759 E.00576 +;WIDTH:0.456776 +G1 X36.364 Y11.911 E.00531 +;WIDTH:0.422312 +G1 X36.588 Y12.287 E.01355 +;WIDTH:0.419999 +G1 X36.955 Y12.577 E.01437 +G1 X37.279 Y12.666 E.01033 +G1 X37.252 Y13.1 E.01337 +M73 P72 R2 +G1 X37.139 Y13.691 E.01848 +M73 P72 R2 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.553 Y16.19 E.01848 +G1 X-35.991 Y15.779 E.01848 +G1 X-36.375 Y15.315 E.01848 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.253 E.0171 +G1 X-37.279 Y12.666 E.01811 +G1 X-37.024 Y12.609 E.00803 +G1 X-36.645 Y12.352 E.01407 +G1 X-36.391 Y11.977 E.01392 +;WIDTH:0.46933 +G1 X-36.323 Y11.832 E.00555 +;WIDTH:0.518661 +G1 X-36.256 Y11.687 E.00619 +;WIDTH:0.525402 +G1 X-36.245 Y11.372 E.01239 +G1 X-36.305 Y11.231 E.00601 +;WIDTH:0.491502 +G1 X-36.364 Y11.09 E.00559 +;WIDTH:0.457602 +G1 X-36.424 Y10.949 E.00517 +;WIDTH:0.423701 +G1 X-36.703 Y10.593 E.01405 +;WIDTH:0.419999 +G1 X-37.07 Y10.376 E.01311 +G1 X-37.29 Y10.321 E.00696 +G1 X-37.29 Y9.669 E.02005 +G1 X-37.024 Y9.609 E.00837 +G1 X-36.645 Y9.352 E.01407 +G1 X-36.391 Y8.977 E.01391 +G1 X-36.293 Y8.536 E.01389 +G1 X-36.365 Y8.089 E.01389 +G1 X-36.596 Y7.7 E.01392 +G1 X-36.955 Y7.423 E.01393 +G1 X-37.29 Y7.331 E.01067 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.397 Y-17.205 E.01848 +G1 X33.98 Y-17.055 E.01848 +G1 X34.539 Y-16.834 E.01848 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.697 Y-14.807 E.01848 +G1 X36.953 Y-14.263 E.01848 +G1 X37.139 Y-13.691 E.01848 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.331 E.60916 +G1 X37.024 Y7.391 E.00837 +G1 X36.645 Y7.648 E.01407 +G1 X36.391 Y8.023 E.01392 +;WIDTH:0.455232 +G1 X36.341 Y8.167 E.00512 +;WIDTH:0.490465 +G1 X36.291 Y8.311 E.00555 +;WIDTH:0.525697 +G1 X36.253 Y8.423 E.00465 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.282 Y8.607 +G1 X36.323 Y8.759 +G1 X36.364 Y8.911 +G1 X36.596 Y9.3 +G1 X36.955 Y9.577 +G1 X37.29 Y9.669 +G1 X37.29 Y9.913 +;WIPE_END +G1 X31.937 Y4.472 Z1.6 F30000 +G1 X15.239 Y-12.501 Z1.6 +M73 P72 R1 +G1 Z1.2 +M73 P72 R1 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.52146 +G1 F12000 +G1 X15.288 Y-12.759 E.01025 +;WIDTH:0.48764 +G1 X15.337 Y-13.017 E.00952 +;WIDTH:0.45382 +G1 X15.386 Y-13.275 E.0088 +;WIDTH:0.419999 +G1 X15.677 Y-14.025 E.02471 +G1 X16.145 Y-14.679 E.02472 +G1 X16.761 Y-15.196 E.02471 +G1 X17.486 Y-15.544 E.02471 +G1 X18.292 Y-15.704 E.02524 +G1 X32.69 Y-15.704 E.44241 +G1 X33.469 Y-15.559 E.02436 +G1 X34.199 Y-15.222 E.02471 +G1 X34.823 Y-14.714 E.02471 +G1 X35.3 Y-14.066 E.02471 +G1 X35.602 Y-13.321 E.02471 +G1 X35.71 Y-12.513 E.02504 +G1 X35.71 Y-8.666 E.11821 +G1 X35.614 Y-7.892 E.02398 +G1 X35.323 Y-7.142 E.02471 +G1 X34.855 Y-6.487 E.02471 +G1 X34.239 Y-5.97 E.02471 +G1 X33.514 Y-5.622 E.02471 +;WIDTH:0.455793 +G1 X33.232 Y-5.551 E.00979 +;WIDTH:0.491587 +G1 X32.95 Y-5.48 E.01064 +;WIDTH:0.527381 +G1 X32.667 Y-5.409 E.01149 +G1 X18.31 Y-5.409 E.56679 +G1 X18.051 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X17.791 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X17.531 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X16.801 Y-5.945 E.02471 +G1 X16.177 Y-6.453 E.02471 +G1 X15.677 Y-7.142 E.02615 +G1 X15.398 Y-7.846 E.02328 +;WIDTH:0.45382 +G1 X15.345 Y-8.112 E.00908 +;WIDTH:0.48764 +G1 X15.292 Y-8.378 E.00982 +;WIDTH:0.52146 +G1 X15.239 Y-8.643 E.01057 +G1 X15.239 Y-12.461 E.14885 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X15.288 Y-12.759 +G1 X15.337 Y-13.017 +G1 X15.386 Y-13.275 +G1 X15.677 Y-14.025 +G1 X15.891 Y-14.324 +;WIPE_END +G1 X9.206 Y-10.642 Z1.6 F30000 +G1 X-26.256 Y8.889 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.495492 +G1 F12000 +G1 X-26.245 Y8.788 E.00375 +;WIDTH:0.457746 +G1 X-26.232 Y8.656 E.00447 +;WIDTH:0.419999 +G1 X-26.407 Y7.974 E.02166 +G1 X-26.747 Y7.356 E.02167 +G1 X-27.047 Y7.025 E.01373 +G1 X-27.506 Y6.646 E.01829 +G1 X-28.138 Y6.348 E.02148 +G1 X-28.831 Y6.216 E.02167 +;WIDTH:0.454327 +G1 X-29.115 Y6.232 E.00953 +;WIDTH:0.488655 +G1 X-29.399 Y6.248 E.01033 +;WIDTH:0.522983 +G1 X-29.683 Y6.265 E.01112 +;WIDTH:0.527381 +G1 X-32.689 Y6.258 E.11868 +G1 X-32.949 Y6.191 E.01059 +;WIDTH:0.491587 +G1 X-33.209 Y6.125 E.00981 +;WIDTH:0.455793 +G1 X-33.469 Y6.059 E.00903 +;WIDTH:0.419999 +G1 X-34.199 Y5.722 E.02471 +G1 X-34.855 Y5.179 E.02615 +G1 X-35.3 Y4.566 E.02327 +G1 X-35.602 Y3.821 E.02471 +G1 X-35.71 Y3.013 E.02504 +G1 X-35.71 Y-1.667 E.14382 +G1 X-35.614 Y-2.442 E.02398 +G1 X-35.323 Y-3.192 E.02471 +G1 X-34.855 Y-3.846 E.02471 +G1 X-34.239 Y-4.363 E.02472 +G1 X-33.514 Y-4.711 E.02471 +;WIDTH:0.455793 +G1 X-33.241 Y-4.782 E.00949 +;WIDTH:0.491587 +G1 X-32.968 Y-4.853 E.01031 +;WIDTH:0.527381 +G1 X-32.695 Y-4.924 E.01113 +G1 X11.69 Y-4.924 E1.75221 +;WIDTH:0.531052 +G1 X11.973 Y-4.907 E.01128 +;WIDTH:0.494035 +G1 X12.256 Y-4.889 E.01042 +;WIDTH:0.457017 +G1 X12.539 Y-4.871 E.00957 +;WIDTH:0.419999 +G1 X17.461 Y-4.871 E.15126 +;WIDTH:0.455793 +G1 X17.704 Y-4.889 E.00819 +;WIDTH:0.491587 +G1 X17.947 Y-4.907 E.0089 +;WIDTH:0.527381 +G1 X18.189 Y-4.924 E.00961 +G1 X32.69 Y-4.924 E.57244 +G1 X32.949 Y-4.858 E.01058 +;WIDTH:0.491587 +G1 X33.209 Y-4.792 E.0098 +;WIDTH:0.455793 +G1 X33.469 Y-4.725 E.00902 +;WIDTH:0.419999 +G1 X34.199 Y-4.388 E.02471 +G1 X34.855 Y-3.846 E.02615 +G1 X35.3 Y-3.233 E.02327 +G1 X35.602 Y-2.487 E.02472 +G1 X35.71 Y-1.68 E.02504 +G1 X35.71 Y7.825 E.29207 +;WIDTH:0.455232 +G1 X35.728 Y8.02 E.00657 +;WIDTH:0.490465 +G1 X35.745 Y8.215 E.00713 +;WIDTH:0.525697 +G1 X35.763 Y8.41 E.00769 +G1 X35.746 Y8.597 E.00741 +;WIDTH:0.491266 +G1 X35.728 Y8.785 E.00688 +;WIDTH:0.456835 +G1 X35.711 Y8.972 E.00635 +;WIDTH:0.422404 +G1 X35.71 Y9.557 E.01809 +;WIDTH:0.419999 +G1 X35.71 Y10.825 E.03895 +;WIDTH:0.455234 +G1 X35.728 Y11.02 E.00657 +;WIDTH:0.490468 +G1 X35.745 Y11.214 E.00713 +;WIDTH:0.525702 +G1 X35.763 Y11.409 E.00769 +G1 X35.746 Y11.597 E.00742 +;WIDTH:0.491239 +G1 X35.728 Y11.785 E.00689 +;WIDTH:0.456776 +G1 X35.711 Y11.972 E.00636 +;WIDTH:0.422312 +G1 X35.71 Y12.501 E.01633 +;WIDTH:0.419999 +G1 X35.667 Y12.978 E.01474 +G1 X35.492 Y13.659 E.02159 +G1 X35.11 Y14.367 E.02471 +G1 X34.823 Y14.714 E.01383 +G1 X34.239 Y15.196 E.02327 +G1 X33.514 Y15.544 E.02471 +G1 X32.708 Y15.704 E.02524 +G1 X-23.19 Y15.704 E1.71758 +G1 X-23.624 Y15.647 E.01347 +G1 X-24.345 Y15.413 E.02327 +G1 X-25.027 Y14.987 E.02471 +G1 X-25.355 Y14.679 E.01383 +G1 X-25.8 Y14.066 E.02327 +G1 X-26.102 Y13.321 E.02471 +;WIDTH:0.45382 +G1 X-26.155 Y13.052 E.00918 +;WIDTH:0.48764 +G1 X-26.208 Y12.783 E.00994 +;WIDTH:0.52146 +G1 X-26.261 Y12.513 E.0107 +M73 P73 R1 +G1 X-26.261 Y9.999 E.09804 +M73 P73 R1 +;WIDTH:0.570985 +G1 X-26.285 Y9.182 E.03517 +G1 X-26.272 Y9.051 E.00569 +;WIDTH:0.533239 +G1 X-26.26 Y8.929 E.00491 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.245 Y8.788 +G1 X-26.232 Y8.656 +G1 X-26.407 Y7.974 +G1 X-26.747 Y7.356 +G1 X-26.96 Y7.122 +;WIPE_END +G1 X-19.699 Y4.769 Z1.6 F30000 +G1 X11.722 Y-5.409 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.527381 +G1 F12000 +G1 X-32.69 Y-5.409 E1.75327 +G1 X-32.949 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X-33.209 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X-33.469 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X-34.199 Y-5.945 E.02471 +G1 X-34.855 Y-6.487 E.02615 +G1 X-35.3 Y-7.1 E.02327 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.614 Y-13.275 E.02496 +G1 X-35.323 Y-14.025 E.02471 +G1 X-34.855 Y-14.679 E.02471 +G1 X-34.239 Y-15.196 E.02471 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02524 +G1 X11.69 Y-15.704 E1.36422 +G1 X12.469 Y-15.559 E.02436 +G1 X13.199 Y-15.222 E.02471 +G1 X13.823 Y-14.714 E.02471 +G1 X14.3 Y-14.066 E.02471 +G1 X14.602 Y-13.321 E.02471 +;WIDTH:0.45382 +G1 X14.655 Y-13.058 E.00899 +;WIDTH:0.48764 +G1 X14.708 Y-12.794 E.00973 +;WIDTH:0.52146 +G1 X14.761 Y-12.531 E.01047 +G1 X14.761 Y-8.666 E.15071 +G1 X14.708 Y-8.424 E.00965 +;WIDTH:0.48764 +G1 X14.656 Y-8.183 E.00896 +;WIDTH:0.45382 +G1 X14.603 Y-7.941 E.00828 +;WIDTH:0.419999 +G1 X14.475 Y-7.464 E.01519 +G1 X14.11 Y-6.8 E.02327 +G1 X13.564 Y-6.21 E.02471 +G1 X13.199 Y-5.945 E.01383 +G1 X12.514 Y-5.622 E.02327 +;WIDTH:0.455793 +G1 X12.25 Y-5.551 E.0092 +;WIDTH:0.491587 +G1 X11.986 Y-5.48 E.00999 +;WIDTH:0.527381 +G1 X11.761 Y-5.419 E.00921 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X9.761 Y-5.419 +;WIPE_END +G1 X2.675 Y-2.583 Z1.6 F30000 +G1 X-26.811 Y9.215 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.570985 +G1 F12000 +G1 X-26.739 Y9.979 E.03301 +;WIDTH:0.52146 +G1 X-26.739 Y12.501 E.09832 +G1 X-26.788 Y12.759 E.01025 +;WIDTH:0.48764 +G1 X-26.837 Y13.017 E.00952 +;WIDTH:0.45382 +G1 X-26.886 Y13.275 E.0088 +;WIDTH:0.419999 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.645 Y14.679 E.02471 +G1 X-28.261 Y15.196 E.02471 +G1 X-28.986 Y15.544 E.02471 +G1 X-29.792 Y15.704 E.02524 +G1 X-32.69 Y15.704 E.08905 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.823 Y14.714 E.02471 +G1 X-35.3 Y14.066 E.02471 +G1 X-35.577 Y13.383 E.02267 +G1 X-35.677 Y12.771 E.01905 +G1 X-35.71 Y12.175 E.01835 +;WIDTH:0.455134 +G1 X-35.728 Y11.992 E.00615 +;WIDTH:0.490268 +G1 X-35.745 Y11.81 E.00667 +;WIDTH:0.525402 +G1 X-35.763 Y11.628 E.0072 +;WIDTH:0.537497 +G1 X-35.769 Y11.219 E.01647 +G1 X-35.749 Y11.019 E.00811 +;WIDTH:0.498331 +G1 X-35.73 Y10.819 E.00747 +;WIDTH:0.459165 +G1 X-35.71 Y10.619 E.00682 +;WIDTH:0.419999 +G1 X-35.71 Y9.997 E.01909 +G1 X-35.68 Y9.574 E.01303 +G1 X-35.492 Y8.841 E.02327 +G1 X-35.085 Y8.098 E.02602 +G1 X-34.718 Y7.689 E.01688 +G1 X-34.254 Y7.315 E.01832 +G1 X-33.514 Y6.956 E.02528 +;WIDTH:0.455793 +G1 X-33.241 Y6.885 E.00949 +;WIDTH:0.491587 +G1 X-32.968 Y6.813 E.01031 +;WIDTH:0.527381 +G1 X-32.695 Y6.742 E.01113 +G1 X-29.81 Y6.744 E.11389 +;WIDTH:0.522983 +G1 X-29.551 Y6.81 E.01048 +;WIDTH:0.488655 +G1 X-29.291 Y6.876 E.00973 +;WIDTH:0.454327 +G1 X-29.031 Y6.941 E.00898 +;WIDTH:0.419999 +G1 X-28.261 Y7.304 E.02615 +G1 X-27.677 Y7.786 E.02327 +G1 X-27.213 Y8.416 E.02404 +;WIDTH:0.464159 +G1 X-27.04 Y8.775 E.01369 +;WIDTH:0.508319 +G1 X-26.868 Y9.135 E.01512 +;WIDTH:0.539652 +G1 X-26.84 Y9.175 E.00199 +;WIDTH:0.570985 +G1 X-26.835 Y9.183 E.00039 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.739 Y9.979 +G1 X-26.739 Y11.177 +;WIPE_END +G1 X-34.191 Y9.526 Z1.6 F30000 +G1 X-35.997 Y9.126 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.531092 +G1 F15363.438 +G1 X-35.868 Y8.617 E.0209 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15363.438 +G1 X-35.997 Y9.126 +;WIPE_END +G1 X-28.868 Y6.595 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.433748 +G1 F18000 +G1 X-28.823 Y6.608 E.0015 +;WIDTH:0.4814 +G1 F17104.567 +G1 X-28.642 Y6.667 E.00679 +;WIDTH:0.529051 +G1 F15427.952 +G1 X-28.461 Y6.726 E.00753 +;WIDTH:0.576702 +G1 F14050.68 +G1 X-28.28 Y6.785 E.00827 +;WIDTH:0.615368 +G1 F13101.627 +G1 X-28.026 Y6.925 E.01353 +;WIDTH:0.654034 +G1 F12272.669 +G1 X-27.773 Y7.066 E.01445 +G1 X-27.362 Y7.406 E.02653 +;WIDTH:0.663656 +G1 F12082.431 +G1 X-27.162 Y7.637 E.01551 +G1 X-26.996 Y7.901 E.01576 +;WIDTH:0.621729 +G1 F12957.642 +G1 X-26.83 Y8.165 E.0147 +;WIDTH:0.579802 +G1 F13969.551 +G1 X-26.734 Y8.443 E.01286 +;WIDTH:0.53276 +G1 F15311.121 +G1 X-26.639 Y8.721 E.01173 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15311.121 +G1 X-26.734 Y8.443 +G1 X-26.83 Y8.165 +G1 X-26.996 Y7.901 +G1 X-27.162 Y7.637 +G1 X-27.362 Y7.406 +G1 X-27.734 Y7.098 +;WIPE_END +G1 X-35.08 Y6.503 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.419999 +G1 F15000 +G1 X-35.708 Y5.956 E.02562 +G1 X-36.129 Y5.376 E.022 +G1 X-36.129 Y6.595 E.03746 +G1 X-35.806 Y6.847 E.01259 +G1 X-35.65 Y6.999 E.00667 +G1 X-35.415 Y6.756 E.0104 +G1 X-35.112 Y6.527 E.01166 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.669 Y6.483 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.408936 +G1 F15000 +G1 X-35.723 Y6.438 E.00211 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.426 Y3.972 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.521764 +G1 F15000 +G1 X-36.453 Y4.279 E.01202 +;WIDTH:0.481058 +G1 X-36.479 Y4.586 E.011 +;WIDTH:0.42083 +G1 X-36.506 Y4.893 E.00949 +G1 X-36.506 Y6.782 E.05818 +G1 X-36.048 Y7.137 E.01783 +G1 X-35.726 Y7.653 E.01874 +G1 X-35.188 Y7.057 E.02473 +G1 X-34.566 Y6.589 E.02396 +G1 X-34.374 Y6.494 E.0066 +G1 X-34.732 Y6.307 E.01243 +G1 X-35.461 Y5.671 E.02978 +G1 X-36.032 Y4.851 E.03079 +;WIDTH:0.437946 +G1 X-36.158 Y4.57 E.0099 +;WIDTH:0.47384 +G1 X-36.284 Y4.289 E.0108 +;WIDTH:0.491787 +G1 X-36.409 Y4.009 E.01125 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.445 Y3.93 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.524743 +G1 F15000 +G1 X-36.462 Y3.657 E.01073 +;WIDTH:0.489995 +G1 X-36.48 Y3.384 E.00996 +;WIDTH:0.436831 +G1 X-36.497 Y3.112 E.00877 +G1 X-36.5 Y-1.681 E.15384 +;WIDTH:0.452526 +G1 X-36.482 Y-1.937 E.00857 +;WIDTH:0.490205 +G1 X-36.463 Y-2.193 E.00936 +;WIDTH:0.527884 +G1 X-36.445 Y-2.449 E.01015 +;WIDTH:0.546723 +G1 F14886.771 +G1 X-36.427 Y-2.706 E.01054 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.075 Y-5.158 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-35.709 Y-5.711 E.02584 +G1 X-36.129 Y-6.291 E.02199 +G1 X-36.129 Y-4.061 E.06852 +G1 X-35.651 Y-4.683 E.02411 +G1 X-35.106 Y-5.133 E.02173 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.691 Y-5.223 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.486666 +G1 F15000 +G1 X-35.746 Y-5.223 E.00201 +G1 X-35.774 Y-5.175 E.00201 +G1 X-35.746 Y-5.127 E.00201 +G1 X-35.683 Y-5.14 E.00234 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.426 Y-7.695 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.521764 +G1 F15000 +G1 X-36.453 Y-7.388 E.01202 +;WIDTH:0.481058 +G1 X-36.479 Y-7.081 E.011 +;WIDTH:0.421892 +G1 X-36.506 Y-6.774 E.00951 +G1 X-36.506 Y-3.769 E.09278 +;WIDTH:0.442466 +G1 X-36.483 Y-3.452 E.01037 +;WIDTH:0.4874 +G1 X-36.461 Y-3.134 E.01153 +;WIDTH:0.532334 +G1 X-36.438 Y-2.817 E.0127 +;WIDTH:0.569602 +G1 F14240.106 +G1 X-36.424 Y-2.745 E.00313 +;WIDTH:0.563852 +G1 F14397.284 +G1 X-36.307 Y-2.973 E.01088 +;WIDTH:0.522751 +G1 F15000 +G1 X-36.19 Y-3.201 E.01002 +;WIDTH:0.48165 +G1 X-36.073 Y-3.429 E.00916 +;WIDTH:0.421086 +G1 X-35.956 Y-3.657 E.0079 +G1 X-35.352 Y-4.454 E.0308 +G1 X-34.754 Y-4.931 E.02357 +G1 X-34.372 Y-5.174 E.01395 +G1 X-34.743 Y-5.369 E.01291 +G1 X-35.461 Y-5.995 E.02936 +G1 X-36.032 Y-6.816 E.0308 +;WIDTH:0.437946 +G1 X-36.158 Y-7.096 E.0099 +;WIDTH:0.47384 +G1 X-36.284 Y-7.377 E.0108 +;WIDTH:0.491787 +G1 X-36.409 Y-7.658 E.01125 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.445 Y-7.737 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.523711 +G1 F15000 +G1 X-36.463 Y-8.043 E.01201 +;WIDTH:0.486901 +G1 X-36.482 Y-8.348 E.01109 +;WIDTH:0.435039 +G1 X-36.5 Y-8.654 E.00979 +G1 X-36.5 Y-12.469 E.12188 +G1 X-36.448 Y-12.999 E.01702 +G1 X-36.366 Y-13.493 E.016 +G1 X-36.206 Y-13.967 E.016 +G1 X-36.004 Y-14.427 E.01603 +G1 X-35.72 Y-14.839 E.01602 +G1 X-35.409 Y-15.232 E.016 +G1 X-35.041 Y-15.571 E.016 +G1 X-34.643 Y-15.876 E.01603 +G1 X-33.665 Y-16.32 E.03429 +G1 X-32.791 Y-16.489 E.02845 +G1 X11.688 Y-16.492 E1.42124 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.006 Y-15.07 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.349 Y-15.517 E.01729 +G1 X16.087 Y-16.119 E.02928 +G1 X13.935 Y-16.119 E.06613 +G1 X14.709 Y-15.456 E.03131 +G1 X14.982 Y-15.102 E.01373 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.032 Y-15.767 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.459962 +G1 F15000 +G1 X14.98 Y-15.767 E.00177 +G1 X14.954 Y-15.722 E.00177 +G1 X14.98 Y-15.677 E.00177 +G1 X15.038 Y-15.687 E.00201 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.137 Y-16.484 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.422094 +G1 F15000 +G1 X11.728 Y-16.49 E.19803 +G1 X12.805 Y-16.268 E.03397 +G1 X13.704 Y-15.824 E.03098 +G1 X14.461 Y-15.171 E.03089 +G1 X14.931 Y-14.533 E.02449 +G1 X15 Y-14.392 E.00485 +G1 X15.068 Y-14.531 E.00478 +G1 X15.648 Y-15.287 E.02944 +G1 X16.431 Y-15.909 E.03089 +M73 P74 R1 +G1 X17.344 Y-16.316 E.03089 +M73 P74 R1 +G1 X18.098 Y-16.476 E.02383 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.14 Y-16.485 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.436279 +G1 F15000 +G1 X32.69 Y-16.492 E.46636 +G1 X33.245 Y-16.407 E.01802 +G1 X33.733 Y-16.296 E.01604 +G1 X34.197 Y-16.106 E.01605 +G1 X34.643 Y-15.877 E.01608 +G1 X35.04 Y-15.57 E.01608 +G1 X35.422 Y-15.228 E.01644 +G1 X35.991 Y-14.447 E.03098 +G1 X36.203 Y-13.966 E.01684 +G1 X36.37 Y-13.494 E.01606 +G1 X36.454 Y-12.999 E.01607 +G1 X36.5 Y-12.49 E.01641 +G1 X36.5 Y-8.652 E.12301 +;WIDTH:0.452528 +G1 X36.481 Y-8.394 E.00864 +;WIDTH:0.490208 +G1 X36.463 Y-8.136 E.00943 +;WIDTH:0.527888 +G1 X36.444 Y-7.878 E.01023 +;WIDTH:0.546728 +G1 F14886.623 +G1 X36.426 Y-7.62 E.01062 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.372 Y-5.16 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X34.743 Y-4.964 E.01287 +G1 X35.461 Y-4.338 E.02927 +G1 X36.032 Y-3.518 E.03071 +;WIDTH:0.443476 +G1 X36.154 Y-3.279 E.00874 +;WIDTH:0.490429 +G1 X36.275 Y-3.041 E.00976 +;WIDTH:0.537383 +G1 X36.396 Y-2.802 E.01079 +;WIDTH:0.573065 +G1 F14147.086 +G1 X36.423 Y-2.751 E.00249 +;WIDTH:0.564611 +G1 F14376.322 +G1 X36.444 Y-2.955 E.00872 +;WIDTH:0.523294 +G1 F15000 +G1 X36.465 Y-3.159 E.00803 +;WIDTH:0.481976 +G1 X36.485 Y-3.363 E.00734 +;WIDTH:0.421322 +G1 X36.506 Y-3.567 E.00632 +G1 X36.506 Y-6.564 E.09241 +;WIDTH:0.443215 +G1 X36.483 Y-6.881 E.01039 +;WIDTH:0.489645 +G1 X36.459 Y-7.199 E.01159 +;WIDTH:0.536076 +G1 X36.436 Y-7.517 E.0128 +;WIDTH:0.572502 +G1 F14162.126 +G1 X36.423 Y-7.58 E.00278 +;WIDTH:0.564998 +G1 F14365.673 +G1 X36.306 Y-7.354 E.01082 +;WIDTH:0.52357 +G1 F15000 +G1 X36.189 Y-7.128 E.00996 +;WIDTH:0.482142 +G1 X36.072 Y-6.902 E.0091 +;WIDTH:0.424333 +G1 X35.956 Y-6.676 E.0079 +G1 X35.352 Y-5.879 E.03107 +G1 X34.754 Y-5.403 E.02377 +G1 X34.406 Y-5.181 E.01282 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.075 Y-5.175 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X35.709 Y-4.622 E.02584 +G1 X36.129 Y-4.043 E.02199 +G1 X36.129 Y-6.273 E.06851 +G1 X35.651 Y-5.65 E.02411 +G1 X35.106 Y-5.2 E.02172 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.746 Y-5.206 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.486658 +G1 F15000 +G1 X35.691 Y-5.206 E.00201 +G1 X35.663 Y-5.158 E.00201 +G1 X35.691 Y-5.11 E.00201 +G1 X35.754 Y-5.124 E.00234 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.473 Y-2.15 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.511313 +G1 F15000 +G1 X36.449 Y-2.431 E.01074 +;WIDTH:0.535965 +G1 X36.426 Y-2.711 E.0113 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X36.449 Y-2.431 +;WIPE_END +G1 X28.895 Y-3.521 Z1.6 F30000 +G1 X14.115 Y-5.655 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.933 Y-5.655 E.05585 +G1 X15.54 Y-5.995 E.01597 +G1 X15.065 Y-6.639 E.02459 +G1 X14.992 Y-6.792 E.0052 +G1 X14.724 Y-6.313 E.01686 +G1 X14.144 Y-5.683 E.02632 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.039 Y-6.089 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.446922 +G1 F15000 +G1 X14.989 Y-6.089 E.00166 +G1 X14.964 Y-6.045 E.00166 +G1 X14.989 Y-6.002 E.00166 +G1 X15.045 Y-6.011 E.00186 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X14.989 Y-6.002 +G1 X14.964 Y-6.045 +G1 X14.989 Y-6.089 +G1 X15.039 Y-6.089 +;WIPE_END +G1 X7.939 Y-3.289 Z1.6 F30000 +G1 X-36.15 Y14.098 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.433789 +G1 F15000 +G1 X-35.966 Y14.489 E.01375 +G1 X-35.463 Y15.181 E.02726 +G1 X-35.039 Y15.569 E.01829 +G1 X-34.641 Y15.874 E.01597 +G1 X-34.196 Y16.103 E.01596 +G1 X-33.724 Y16.306 E.01633 +G1 X-32.791 Y16.492 E.03031 +G1 X-29.81 Y16.492 E.09495 +;WIDTH:0.447726 +G1 X-29.586 Y16.473 E.00743 +;WIDTH:0.487696 +G1 X-29.361 Y16.454 E.00817 +;WIDTH:0.527667 +G1 X-29.137 Y16.435 E.0089 +;WIDTH:0.547652 +G1 F14859.387 +G1 X-28.912 Y16.416 E.00927 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.475 Y15.685 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.446988 +G1 F15000 +G1 X-26.525 Y15.685 E.00166 +G1 X-26.551 Y15.728 E.00166 +G1 X-26.525 Y15.772 E.00166 +G1 X-26.469 Y15.763 E.00186 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.398 Y16.119 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-25.853 Y15.81 E.0169 +G1 X-26.243 Y15.426 E.01683 +G1 X-26.5 Y15.076 E.01334 +G1 X-26.859 Y15.532 E.01784 +G1 X-27.59 Y16.119 E.0288 +G1 X-25.438 Y16.119 E.06612 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.614 Y16.457 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-24.77 Y16.074 E.0374 +G1 X-25.566 Y15.557 E.02917 +G1 X-25.939 Y15.203 E.01582 +G1 X-26.434 Y14.528 E.02571 +G1 X-26.5 Y14.392 E.00464 +G1 X-27.074 Y15.221 E.03098 +G1 X-27.929 Y15.908 E.03371 +;WIDTH:0.443233 +G1 X-28.225 Y16.065 E.01092 +;WIDTH:0.489701 +G1 X-28.52 Y16.223 E.01219 +;WIDTH:0.536169 +G1 X-28.815 Y16.38 E.01346 +;WIDTH:0.573483 +G1 F14135.927 +G1 X-28.872 Y16.412 E.00282 +;WIDTH:0.566618 +G1 F14321.245 +G1 X-28.618 Y16.433 E.01091 +;WIDTH:0.524727 +G1 F15000 +G1 X-28.363 Y16.454 E.01004 +;WIDTH:0.482836 +G1 X-28.108 Y16.475 E.00916 +;WIDTH:0.422399 +G1 X-27.853 Y16.496 E.00791 +G1 X-23.694 Y16.496 E.12862 +G1 X-23.65 Y16.475 E.00149 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-23.694 Y16.496 +G1 X-25.646 Y16.496 +;WIPE_END +G1 X-18.692 Y13.349 Z1.6 F30000 +G1 X13.931 Y-1.416 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.42525 +G1 F12000 +G1 X14.069 Y-1.15 E.00934 +G1 X14.224 Y-.665 E.01588 +;WIDTH:0.429157 +G1 X14.284 Y.047 E.02247 +;WIDTH:0.420185 +G1 X14.242 Y.563 E.01591 +G1 X14.095 Y1.104 E.01726 +;WIDTH:0.423818 +G1 X13.895 Y1.493 E.01358 +G1 X13.584 Y1.813 E.01385 +;WIDTH:0.415382 +G1 X13.22 Y2.023 E.01273 +;WIDTH:0.40905 +G1 X12.778 Y2.141 E.01366 +;WIDTH:0.407595 +G1 X12.267 Y2.125 E.01519 +;WIDTH:0.41267 +G1 X11.811 Y2.004 E.01421 +;WIDTH:0.418611 +G1 X11.431 Y1.766 E.01373 +;WIDTH:0.422719 +G1 X11.144 Y1.433 E.0136 +G1 X10.932 Y.988 E.01524 +G1 X10.82 Y.462 E.01665 +;WIDTH:0.426289 +G1 X10.81 Y-.354 E.0255 +G1 X10.917 Y-.899 E.01734 +;WIDTH:0.424879 +G1 X11.14 Y-1.402 E.01713 +G1 X11.469 Y-1.788 E.01579 +;WIDTH:0.419944 +G1 X11.849 Y-2.022 E.01369 +;WIDTH:0.41276 +G1 X12.288 Y-2.138 E.0137 +;WIDTH:0.40775 +G1 X12.801 Y-2.133 E.01526 +;WIDTH:0.409661 +G1 X13.283 Y-1.996 E.01496 +;WIDTH:0.411184 +G1 X13.648 Y-1.752 E.01318 +;WIDTH:0.414981 +G1 X13.905 Y-1.447 E.0121 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X13.461 Y-1.421 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.415225 +G1 F12000 +G1 X13.662 Y-1.142 E.01042 +;WIDTH:0.417828 +G1 X13.836 Y-.669 E.01541 +;WIDTH:0.429157 +G1 X13.898 Y-.136 E.01688 +G1 X13.881 Y.443 E.01823 +;WIDTH:0.420834 +G1 X13.738 Y.986 E.01731 +;WIDTH:0.422247 +G1 X13.54 Y1.334 E.01236 +G1 X13.301 Y1.562 E.01021 +;WIDTH:0.412765 +G1 X13.021 Y1.71 E.00955 +;WIDTH:0.410201 +G1 X12.69 Y1.783 E.01015 +;WIDTH:0.419689 +G1 X12.198 Y1.74 E.01514 +G1 X11.869 Y1.624 E.01072 +;WIDTH:0.417409 +G1 X11.619 Y1.427 E.00972 +;WIDTH:0.418968 +G1 X11.424 Y1.165 E.01002 +;WIDTH:0.419847 +G1 X11.281 Y.832 E.01111 +;WIDTH:0.420892 +G1 X11.197 Y.443 E.01226 +;WIDTH:0.421609 +G1 X11.177 Y-.191 E.01958 +;WIDTH:0.4259 +G1 X11.271 Y-.756 E.01786 +G1 X11.449 Y-1.18 E.01437 +;WIDTH:0.424879 +G1 X11.782 Y-1.559 E.01568 +;WIDTH:0.418069 +G1 X12.064 Y-1.713 E.00981 +;WIDTH:0.413294 +G1 X12.383 Y-1.782 E.00986 +;WIDTH:0.404429 +G1 X12.751 Y-1.775 E.01085 +;WIDTH:0.406956 +G1 X13.213 Y-1.621 E.01445 +;WIDTH:0.408448 +G1 X13.43 Y-1.446 E.0083 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X13.662 Y-1.142 +G1 X13.836 Y-.669 +G1 X13.898 Y-.136 +G1 X13.881 Y.441 +;WIPE_END +G1 X20.897 Y-.255 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.398471 +G1 F12000 +G1 X20.437 Y1.122 E.04206 +;WIDTH:0.419999 +G1 X20.272 Y1.254 E.00649 +G1 X20.169 Y1.254 E.00315 +G1 X20.005 Y1.119 E.00651 +;WIDTH:0.401646 +G1 X19.547 Y-.255 E.04234 +;WIDTH:0.451588 +G1 X19.54 Y-.351 E.00321 +;WIDTH:0.50153 +G1 X19.533 Y-.447 E.00361 +G1 X19.594 Y-.461 E.00235 +;WIDTH:0.467287 +G1 X19.656 Y-.475 E.00218 +;WIDTH:0.433044 +G1 X19.717 Y-.489 E.002 +;WIDTH:0.3988 +G1 X20.728 Y-.489 E.02932 +;WIDTH:0.432237 +G1 X20.789 Y-.475 E.00197 +;WIDTH:0.465674 +G1 X20.849 Y-.462 E.00214 +;WIDTH:0.499111 +G1 X20.91 Y-.448 E.00231 +G1 X20.906 Y-.384 E.0024 +;WIDTH:0.465565 +G1 X20.901 Y-.319 E.00222 +;WIDTH:0.432018 +G1 X20.9 Y-.295 E.00078 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X20.437 Y1.122 +G1 X20.272 Y1.254 +G1 X20.169 Y1.254 +G1 X20.018 Y1.13 +;WIPE_END +G1 X15.523 Y.044 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.504532 +G1 F12000 +G1 X15.882 Y.044 E.01351 +;WIDTH:0.47084 +G1 X16.241 Y.045 E.01252 +;WIDTH:0.437148 +G1 X16.6 Y.046 E.01154 +;WIDTH:0.44412 +G1 X16.747 Y.03 E.00483 +;WIDTH:0.484784 +G1 X16.894 Y.015 E.00532 +G1 X17.042 Y.078 E.00579 +;WIDTH:0.445743 +G1 X17.19 Y.141 E.00528 +;WIDTH:0.414995 +G1 X17.466 Y.38 E.0111 +G1 X17.595 Y.706 E.01062 +;WIDTH:0.427554 +G1 X17.589 Y1.117 E.01287 +G1 X17.461 Y1.429 E.01056 +;WIDTH:0.418846 +G1 X17.176 Y1.65 E.01107 +;WIDTH:0.416342 +G1 X16.712 Y1.734 E.01433 +;WIDTH:0.404898 +G1 X15.668 Y1.734 E.03081 +;WIDTH:0.440215 +G1 X15.588 Y1.716 E.00265 +;WIDTH:0.475531 +G1 X15.508 Y1.699 E.00289 +G1 X15.494 Y1.626 E.00262 +;WIDTH:0.447309 +G1 X15.48 Y1.553 E.00244 +;WIDTH:0.419086 +G1 X15.48 Y.223 E.04076 +;WIDTH:0.461718 +G1 X15.501 Y.133 E.00315 +;WIDTH:0.504532 +G1 X15.513 Y.082 E.00196 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.523 Y-.315 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X15.571 Y-.315 E.00182 +;WIDTH:0.470296 +G1 X15.619 Y-.315 E.00169 +;WIDTH:0.43606 +G1 X15.668 Y-.315 E.00155 +;WIDTH:0.403455 +G1 X16.6 Y-.314 E.0274 +;WIDTH:0.447213 +G1 X16.686 Y-.333 E.00289 +;WIDTH:0.490971 +G1 X16.772 Y-.351 E.0032 +G1 X16.828 Y-.498 E.00574 +;WIDTH:0.446197 +G1 X16.885 Y-.644 E.00517 +;WIDTH:0.414134 +G1 X17.673 Y-2.094 E.04993 +G1 X18.1 Y-2.093 E.0129 +G1 X17.199 Y-.472 E.05612 +;WIDTH:0.446197 +G1 X17.174 Y-.384 E.00301 +;WIDTH:0.490971 +G1 X17.15 Y-.295 E.00334 +G1 X17.334 Y-.191 E.00773 +;WIDTH:0.454217 +G1 X17.518 Y-.087 E.0071 +;WIDTH:0.417462 +G1 X17.754 Y.148 E.01018 +;WIDTH:0.421278 +G1 X17.94 Y.522 E.01285 +G1 X17.985 Y1.005 E.01496 +;WIDTH:0.428096 +G1 X17.897 Y1.427 E.01355 +;WIDTH:0.429295 +G1 X17.663 Y1.78 E.01332 +G1 X17.262 Y2.013 E.01461 +;WIDTH:0.416342 +G1 X16.712 Y2.096 E.01692 +;WIDTH:0.404898 +G1 X15.668 Y2.096 E.03081 +;WIDTH:0.440215 +G1 X15.4 Y2.078 E.0087 +;WIDTH:0.475531 +G1 X15.132 Y2.061 E.00947 +G1 X15.117 Y1.807 E.00896 +;WIDTH:0.447309 +G1 X15.103 Y1.553 E.00838 +;WIDTH:0.419086 +G1 X15.104 Y.223 E.04076 +;WIDTH:0.461718 +G1 X15.125 Y.044 E.00617 +;WIDTH:0.504532 +G1 X15.147 Y-.136 E.0068 +G1 X15.125 Y-.315 E.00677 +;WIDTH:0.461693 +G1 X15.104 Y-.493 E.00614 +;WIDTH:0.418853 +G1 X15.102 Y-2.093 E.04902 +;WIDTH:0.415573 +G1 X15.474 Y-2.093 E.0113 +G1 X15.475 Y-1.908 E.00562 +;WIDTH:0.418853 +G1 X15.48 Y-.494 E.04331 +;WIDTH:0.461693 +G1 X15.501 Y-.405 E.00313 +;WIDTH:0.504532 +G1 X15.513 Y-.354 E.00195 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X15.571 Y-.315 +G1 X15.619 Y-.315 +G1 X15.668 Y-.315 +G1 X16.6 Y-.314 +G1 X16.686 Y-.333 +G1 X16.772 Y-.351 +G1 X16.828 Y-.498 +G1 X16.885 Y-.644 +G1 X17.081 Y-1.005 +;WIPE_END +G1 X21.027 Y-.804 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.499111 +G1 F12000 +G1 X21.079 Y-.884 E.00356 +;WIDTH:0.451619 +G1 X21.132 Y-.964 E.00319 +;WIDTH:0.40538 +G1 X21.52 Y-2.099 E.03541 +G1 X21.903 Y-2.098 E.01132 +G1 X21.474 Y-.847 E.03905 +;WIDTH:0.451619 +G1 X21.391 Y-.679 E.00623 +;WIDTH:0.499111 +G1 X21.308 Y-.512 E.00696 +G1 X21.283 Y-.388 E.00468 +;WIDTH:0.465565 +G1 X21.258 Y-.265 E.00433 +;WIDTH:0.432018 +G1 X21.234 Y-.141 E.00399 +;WIDTH:0.398471 +G1 X20.771 Y1.218 E.0416 +;WIDTH:0.419999 +G1 X20.456 Y2.087 E.02843 +G1 X20.013 Y2.088 E.01359 +G1 X19.678 Y1.236 E.02815 +;WIDTH:0.401646 +G1 X19.207 Y-.14 E.04251 +;WIDTH:0.451588 +G1 X19.169 Y-.324 E.00626 +;WIDTH:0.50153 +G1 X19.132 Y-.509 E.00703 +G1 X19.048 Y-.678 E.00707 +;WIDTH:0.454248 +G1 X18.964 Y-.848 E.00634 +;WIDTH:0.413024 +G1 X18.536 Y-2.092 E.03968 +G1 X18.931 Y-2.086 E.0119 +G1 X19.309 Y-.965 E.03569 +;WIDTH:0.454248 +G1 X19.362 Y-.884 E.00324 +;WIDTH:0.50153 +G1 X19.415 Y-.803 E.00362 +G1 X19.515 Y-.817 E.0038 +;WIDTH:0.467287 +G1 X19.616 Y-.831 E.00352 +;WIDTH:0.433044 +G1 X19.717 Y-.845 E.00323 +;WIDTH:0.3988 +G1 X20.728 Y-.845 E.02932 +;WIDTH:0.432237 +G1 X20.828 Y-.831 E.00319 +;WIDTH:0.465674 +G1 X20.927 Y-.818 E.00346 +;WIDTH:0.499111 +G1 X20.987 Y-.81 E.00225 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X21.079 Y-.884 +M73 P75 R1 +G1 X21.132 Y-.964 +M73 P75 R1 +G1 X21.52 Y-2.099 +G1 X21.903 Y-2.098 +G1 X21.837 Y-1.905 +;WIPE_END +G1 X20.268 Y1.625 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.408412 +G1 F18000 +G1 X20.245 Y1.665 E.00136 +G1 X20.176 Y1.625 E.00236 +G1 X20.245 Y1.585 E.00236 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X20.176 Y1.625 +G1 X20.245 Y1.665 +G1 X20.268 Y1.625 +;WIPE_END +G1 X22.947 Y.905 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X23.054 Y.932 E.00338 +G1 X23.254 Y.831 E.00686 +;WIDTH:0.39392 +G1 X24.246 Y-1.019 E.06006 +;WIDTH:0.419999 +G1 X24.835 Y-2.091 E.03757 +G1 X25.331 Y-2.087 E.01523 +G1 X25.344 Y-.743 E.04131 +;WIDTH:0.394337 +G1 X25.344 Y2.085 E.08098 +;WIDTH:0.393306 +G1 X24.999 Y2.098 E.00985 +G1 X24.994 Y1.916 E.00518 +;WIDTH:0.394337 +G1 X24.992 Y-.743 E.07614 +;WIDTH:0.419999 +G1 X24.863 Y-.925 E.00687 +G1 X24.757 Y-.952 E.00338 +G1 X24.555 Y-.853 E.0069 +;WIDTH:0.39392 +G1 X23.561 Y.996 E.06005 +;WIDTH:0.419999 +G1 X22.959 Y2.088 E.03832 +G1 X22.484 Y2.088 E.01459 +G1 X22.47 Y.723 E.04196 +;WIDTH:0.39176 +G1 X22.469 Y-2.097 E.08014 +;WIDTH:0.388043 +G1 X22.813 Y-2.105 E.0097 +G1 X22.814 Y-1.929 E.00496 +;WIDTH:0.39176 +G1 X22.819 Y.722 E.07535 +;WIDTH:0.419999 +G1 X22.924 Y.872 E.00563 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X23.054 Y.932 +G1 X23.254 Y.831 +G1 X24.026 Y-.609 +;WIPE_END +G1 X24.969 Y-1.346 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.504434 +G1 F16250.861 +G1 X24.941 Y-1.296 E.00217 +G1 X24.883 Y-1.296 E.00217 +G1 X24.854 Y-1.346 E.00217 +G1 X24.883 Y-1.396 E.00217 +G1 X24.941 Y-1.396 E.00217 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F16250.861 +G1 X24.883 Y-1.396 +G1 X24.854 Y-1.346 +G1 X24.883 Y-1.296 +G1 X24.941 Y-1.296 +G1 X24.969 Y-1.346 +;WIPE_END +G1 X22.956 Y1.321 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.496724 +G1 F16526.958 +G1 X22.928 Y1.37 E.0021 +G1 X22.871 Y1.37 E.0021 +G1 X22.843 Y1.321 E.0021 +G1 X22.871 Y1.272 E.0021 +G1 X22.928 Y1.272 E.0021 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F16526.958 +G1 X22.871 Y1.272 +G1 X22.843 Y1.321 +G1 X22.871 Y1.37 +G1 X22.928 Y1.37 +G1 X22.956 Y1.321 +;WIPE_END +G1 X29.115 Y-.457 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.468232 +G1 F12000 +G1 X29.133 Y-.537 E.00285 +;WIDTH:0.432489 +G1 X29.151 Y-.617 E.00261 +;WIDTH:0.417567 +G1 X29.144 Y-1.297 E.02077 +G1 X28.814 Y-1.562 E.01291 +;WIDTH:0.404844 +G1 X28.454 Y-1.718 E.01157 +;WIDTH:0.40578 +G1 X28.057 Y-1.788 E.01191 +;WIDTH:0.413333 +G1 X27.67 Y-1.765 E.0117 +;WIDTH:0.415086 +G1 X27.3 Y-1.647 E.01178 +;WIDTH:0.420236 +G1 X27.033 Y-1.452 E.01015 +;WIDTH:0.425227 +G1 X26.823 Y-1.188 E.01053 +G1 X26.662 Y-.837 E.01203 +;WIDTH:0.418558 +G1 X26.568 Y-.443 E.01239 +;WIDTH:0.422689 +G1 X26.533 Y.018 E.01431 +;WIDTH:0.428438 +G1 X26.579 Y.525 E.01598 +G1 X26.67 Y.909 E.01241 +;WIDTH:0.426496 +G1 X26.821 Y1.225 E.01092 +;WIDTH:0.425737 +G1 X27.015 Y1.464 E.0096 +;WIDTH:0.423091 +G1 X27.366 Y1.693 E.013 +;WIDTH:0.408614 +G1 X27.695 Y1.774 E.01009 +;WIDTH:0.404656 +G1 X28.203 Y1.76 E.01498 +;WIDTH:0.423052 +G1 X28.579 Y1.636 E.01225 +;WIDTH:0.431947 +G1 X28.905 Y1.33 E.01417 +G1 X29.031 Y1.118 E.00783 +;WIDTH:0.415068 +G1 X29.386 Y1.229 E.01127 +G1 X29.295 Y1.417 E.00634 +;WIDTH:0.439079 +G1 X29.057 Y1.748 E.01317 +G1 X28.731 Y1.984 E.01297 +;WIDTH:0.423052 +G1 X28.247 Y2.118 E.01558 +;WIDTH:0.404656 +G1 X27.701 Y2.132 E.0161 +;WIDTH:0.405869 +G1 X27.277 Y2.048 E.01278 +;WIDTH:0.410077 +G1 X26.908 Y1.856 E.01243 +;WIDTH:0.415202 +G1 X26.601 Y1.564 E.01285 +;WIDTH:0.419405 +G1 X26.354 Y1.142 E.015 +;WIDTH:0.428438 +G1 X26.196 Y.573 E.01855 +G1 X26.157 Y.068 E.01591 +;WIDTH:0.425629 +G1 X26.171 Y-.331 E.01248 +G1 X26.259 Y-.82 E.01549 +;WIDTH:0.425144 +G1 X26.452 Y-1.288 E.01577 +;WIDTH:0.419806 +G1 X26.702 Y-1.649 E.01349 +G1 X27.046 Y-1.927 E.01357 +;WIDTH:0.414158 +G1 X27.451 Y-2.095 E.01328 +;WIDTH:0.409375 +G1 X27.991 Y-2.153 E.0162 +;WIDTH:0.409755 +G1 X28.438 Y-2.099 E.01348 +;WIDTH:0.411998 +G1 X28.807 Y-1.975 E.01168 +;WIDTH:0.413868 +G1 X29.229 Y-1.72 E.01493 +;WIDTH:0.417567 +G1 X29.5 Y-1.45 E.01167 +G1 X29.508 Y-1.215 E.00718 +;WIDTH:0.398799 +G1 X29.505 Y-.613 E.01746 +;WIDTH:0.432487 +G1 X29.485 Y-.349 E.00842 +;WIDTH:0.468232 +G1 X29.466 Y-.084 E.00919 +G1 X29.222 Y-.071 E.00845 +;WIDTH:0.441916 +G1 X28.979 Y-.058 E.00792 +;WIDTH:0.415599 +G1 X28.077 Y-.056 E.02741 +;WIDTH:0.411114 +G1 X28.068 Y-.421 E.01098 +G1 X28.254 Y-.424 E.00558 +;WIDTH:0.439673 +G1 X28.684 Y-.44 E.01392 +;WIDTH:0.468232 +G1 X29.075 Y-.455 E.01353 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X29.133 Y-.537 +G1 X29.151 Y-.617 +G1 X29.144 Y-1.297 +G1 X28.814 Y-1.562 +G1 X28.454 Y-1.718 +G1 X28.137 Y-1.774 +;WIPE_END +G1 X30.828 Y1.544 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.44866 +G1 F12000 +G1 X30.843 Y1.618 E.00251 +;WIDTH:0.478153 +G1 X30.858 Y1.693 E.00269 +G1 X30.937 Y1.71 E.00287 +;WIDTH:0.443832 +G1 X31.016 Y1.727 E.00264 +;WIDTH:0.409511 +G1 X33.105 Y1.73 E.06242 +;WIDTH:0.407442 +G1 X33.091 Y2.095 E.01083 +G1 X32.916 Y2.095 E.0052 +;WIDTH:0.409511 +G1 X31.016 Y2.094 E.05676 +;WIDTH:0.443832 +G1 X30.748 Y2.076 E.00874 +;WIDTH:0.478153 +G1 X30.481 Y2.059 E.00949 +G1 X30.467 Y1.801 E.00916 +;WIDTH:0.44866 +G1 X30.452 Y1.544 E.00854 +;WIDTH:0.419167 +G1 X30.452 Y.422 E.0344 +;WIDTH:0.463078 +G1 X30.474 Y.24 E.00627 +;WIDTH:0.506988 +G1 X30.496 Y.058 E.00692 +G1 X30.474 Y-.124 E.00692 +;WIDTH:0.463078 +G1 X30.452 Y-.306 E.00627 +;WIDTH:0.419167 +G1 X30.452 Y-1.547 E.03805 +;WIDTH:0.448648 +G1 X30.467 Y-1.804 E.00854 +;WIDTH:0.478129 +G1 X30.481 Y-2.062 E.00916 +G1 X30.748 Y-2.079 E.00948 +;WIDTH:0.443773 +G1 X31.015 Y-2.096 E.00874 +;WIDTH:0.409417 +G1 X33.218 Y-2.098 E.0658 +;WIDTH:0.405818 +G1 X33.218 Y-1.735 E.01072 +G1 X33.037 Y-1.735 E.00534 +;WIDTH:0.409417 +G1 X31.016 Y-1.73 E.06037 +;WIDTH:0.443773 +G1 X30.937 Y-1.713 E.00264 +;WIDTH:0.478129 +G1 X30.858 Y-1.696 E.00287 +G1 X30.843 Y-1.621 E.00269 +;WIDTH:0.448648 +G1 X30.828 Y-1.547 E.00251 +;WIDTH:0.419167 +G1 X30.828 Y-.306 E.03805 +;WIDTH:0.463078 +G1 X30.85 Y-.215 E.0032 +;WIDTH:0.506988 +G1 X30.872 Y-.124 E.00354 +G1 X30.92 Y-.124 E.00182 +;WIDTH:0.473482 +G1 X30.968 Y-.124 E.00169 +;WIDTH:0.439976 +G1 X31.016 Y-.124 E.00155 +;WIDTH:0.40647 +G1 X32.956 Y-.125 E.05746 +;WIDTH:0.402764 +G1 X32.939 Y.235 E.01055 +G1 X32.768 Y.235 E.00502 +;WIDTH:0.40647 +G1 X30.872 Y.24 E.05617 +;WIDTH:0.419167 +G1 X30.828 Y.422 E.00572 +G1 X30.828 Y1.504 E.03318 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X30.843 Y1.618 +G1 X30.858 Y1.693 +G1 X30.937 Y1.71 +G1 X31.016 Y1.727 +G1 X32.663 Y1.73 +;WIPE_END +G1 X32.748 Y9.362 Z1.6 F30000 +G1 X32.786 Y12.792 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.522354 +G1 F12000 +G1 X32.307 Y12.791 E.01872 +G1 X32.307 Y12.552 E.00935 +;WIDTH:0.524424 +G1 X32.308 Y8.985 E.13995 +;WIDTH:0.5288 +G1 X32.222 Y8.339 E.02577 +G1 X32.021 Y7.859 E.02061 +;WIDTH:0.526951 +G1 X31.716 Y7.51 E.01828 +;WIDTH:0.525056 +G1 X31.238 Y7.26 E.02119 +;WIDTH:0.514918 +G1 X30.659 Y7.186 E.02248 +;WIDTH:0.516261 +G1 X30.082 Y7.286 E.02257 +;WIDTH:0.522732 +G1 X29.649 Y7.55 E.01984 +;WIDTH:0.528284 +G1 X29.354 Y7.957 E.01988 +;WIDTH:0.536088 +G1 X29.206 Y8.446 E.02055 +G1 X29.151 Y9.021 E.0232 +;WIDTH:0.529288 +G1 X29.144 Y12.789 E.14934 +;WIDTH:0.524571 +G1 X28.663 Y12.783 E.01889 +G1 X28.663 Y12.545 E.00932 +;WIDTH:0.529288 +G1 X28.665 Y8.966 E.14184 +;WIDTH:0.536088 +G1 X28.721 Y8.362 E.02437 +G1 X28.886 Y7.821 E.02275 +;WIDTH:0.536592 +G1 X29.086 Y7.472 E.01616 +G1 X29.437 Y7.108 E.02037 +;WIDTH:0.522195 +G1 X29.88 Y6.86 E.01984 +;WIDTH:0.522801 +G1 X30.357 Y6.738 E.01922 +G1 X30.867 Y6.722 E.01996 +;WIDTH:0.518092 +G1 X31.525 Y6.859 E.02603 +;WIDTH:0.525056 +G1 X31.992 Y7.117 E.02096 +;WIDTH:0.527234 +G1 X32.35 Y7.481 E.02014 +;WIDTH:0.537093 +G1 X32.539 Y7.804 E.01506 +G1 X32.716 Y8.324 E.02212 +;WIDTH:0.535834 +G1 X32.79 Y9.08 E.03051 +;WIDTH:0.524424 +G1 X32.786 Y12.752 E.14408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X32.307 Y12.791 +G1 X32.307 Y12.552 +G1 X32.307 Y11.272 +;WIPE_END +G1 X25.616 Y7.6 Z1.6 F30000 +G1 X25.061 Y7.295 Z1.6 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.604639 +G1 F12000 +G1 X24.959 Y7.317 E.0048 +G1 X24.94 Y7.413 E.00446 +;WIDTH:0.566951 +G1 X24.921 Y7.509 E.00417 +;WIDTH:0.529262 +G1 X24.92 Y12.787 E.20917 +;WIDTH:0.528709 +G1 X24.435 Y12.788 E.01923 +;WIDTH:0.529262 +G1 X24.435 Y7.509 E.20924 +;WIDTH:0.566951 +G1 X24.454 Y7.177 E.01419 +;WIDTH:0.604639 +G1 X24.472 Y6.845 E.01522 +G1 X24.818 Y6.823 E.01584 +;WIDTH:0.559913 +G1 X25.163 Y6.8 E.01458 +;WIDTH:0.515186 +G1 X27.597 Y6.799 E.09368 +;WIDTH:0.511717 +G1 X27.597 Y7.267 E.01789 +G1 X27.363 Y7.268 E.00891 +;WIDTH:0.515186 +G1 X25.164 Y7.273 E.08465 +;WIDTH:0.559913 +G1 X25.1 Y7.286 E.00273 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X24.959 Y7.317 +G1 X24.94 Y7.413 +G1 X24.921 Y7.509 +G1 X24.921 Y9.169 +;WIPE_END +G1 X22.622 Y8.062 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.431922 +G1 F18000 +G1 X22.447 Y7.835 E.00907 +G1 X22.377 Y7.816 E.00231 +G1 X22.043 Y7.935 E.01125 +G1 X22.469 Y7.137 E.02868 +G1 X22.622 Y7.138 E.00484 +G1 X22.622 Y8.022 E.02801 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X22.447 Y7.835 +G1 X22.377 Y7.816 +G1 X22.043 Y7.935 +G1 X22.469 Y7.137 +G1 X22.622 Y7.138 +G1 X22.622 Y7.397 +;WIPE_END +G1 X19.77 Y11.672 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.4257 +G1 F18000 +G1 X19.346 Y12.463 E.02799 +G1 X19.219 Y12.463 E.00397 +G1 X19.219 Y11.549 E.02852 +G1 X19.39 Y11.735 E.0079 +G1 X19.459 Y11.753 E.00222 +G1 X19.731 Y11.682 E.00878 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X19.556 Y11.386 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.457553 +G1 F12000 +G1 X19.688 Y11.37 E.0045 +;WIDTH:0.495107 +G1 X19.82 Y11.354 E.00491 +G1 X19.84 Y11.32 E.00145 +;WIDTH:0.494731 +G1 X21.595 Y8.038 E.137 +G1 X21.757 Y7.697 E.01389 +;WIDTH:0.457365 +G1 X21.918 Y7.356 E.01275 +;WIDTH:0.419999 +G1 X22.244 Y6.753 E.02106 +G1 X23.005 Y6.757 E.02338 +G1 X23.005 Y7.701 E.02901 +;WIDTH:0.454735 +G1 X22.987 Y8.044 E.01154 +;WIDTH:0.489471 +G1 X22.97 Y8.388 E.01251 +;WIDTH:0.492122 +G1 X22.969 Y12.791 E.16116 +M73 P76 R1 +G1 X22.519 Y12.803 E.01646 +M73 P76 R1 +G1 X22.523 Y8.387 E.16162 +;WIDTH:0.489471 +G1 X22.436 Y8.296 E.0046 +;WIDTH:0.454735 +G1 X22.349 Y8.205 E.00424 +;WIDTH:0.419999 +G1 X22.278 Y8.186 E.00223 +;WIDTH:0.457365 +G1 X22.136 Y8.219 E.00493 +;WIDTH:0.494731 +G1 X21.994 Y8.252 E.00538 +G1 X20.184 Y11.628 E.14105 +;WIDTH:0.491706 +G1 X20.049 Y11.918 E.0117 +;WIDTH:0.455853 +G1 X19.913 Y12.208 E.01077 +;WIDTH:0.419999 +G1 X19.569 Y12.843 E.02218 +G1 X18.839 Y12.843 E.02244 +G1 X18.839 Y11.867 E.02999 +;WIDTH:0.452658 +G1 X18.855 Y11.526 E.0114 +;WIDTH:0.485317 +G1 X18.872 Y11.185 E.0123 +;WIDTH:0.485629 +G1 X18.873 Y6.785 E.15873 +G1 X19.315 Y6.786 E.01597 +G1 X19.314 Y11.223 E.16006 +;WIDTH:0.485317 +G1 X19.435 Y11.304 E.00526 +;WIDTH:0.452658 +G1 X19.523 Y11.364 E.00354 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X19.688 Y11.37 +G1 X19.82 Y11.354 +G1 X19.84 Y11.32 +G1 X20.624 Y9.854 +;WIPE_END +G1 X17.435 Y12.792 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.522354 +G1 F12000 +G1 X16.956 Y12.791 E.01872 +G1 X16.956 Y12.552 E.00935 +;WIDTH:0.524424 +G1 X16.957 Y8.985 E.13995 +;WIDTH:0.5288 +G1 X16.871 Y8.339 E.02578 +G1 X16.67 Y7.859 E.02061 +;WIDTH:0.526951 +G1 X16.365 Y7.51 E.01828 +;WIDTH:0.525055 +G1 X15.888 Y7.26 E.02119 +;WIDTH:0.514917 +G1 X15.308 Y7.186 E.02248 +;WIDTH:0.516261 +G1 X14.731 Y7.286 E.02258 +;WIDTH:0.522731 +G1 X14.298 Y7.55 E.01984 +;WIDTH:0.528284 +G1 X14.003 Y7.957 E.01988 +;WIDTH:0.536086 +G1 X13.855 Y8.446 E.02055 +G1 X13.8 Y9.021 E.0232 +;WIDTH:0.529288 +G1 X13.793 Y12.789 E.14933 +;WIDTH:0.524572 +G1 X13.312 Y12.783 E.01889 +G1 X13.312 Y12.545 E.00932 +;WIDTH:0.529288 +G1 X13.314 Y8.966 E.14184 +;WIDTH:0.536086 +G1 X13.37 Y8.362 E.02437 +G1 X13.535 Y7.821 E.02275 +;WIDTH:0.536593 +G1 X13.735 Y7.472 E.01616 +G1 X14.086 Y7.108 E.02037 +;WIDTH:0.522196 +G1 X14.529 Y6.86 E.01984 +;WIDTH:0.522801 +G1 X15.006 Y6.738 E.01922 +G1 X15.516 Y6.722 E.01996 +;WIDTH:0.51809 +G1 X16.174 Y6.859 E.02603 +;WIDTH:0.525055 +G1 X16.641 Y7.117 E.02096 +;WIDTH:0.527232 +G1 X16.999 Y7.481 E.02014 +;WIDTH:0.537093 +G1 X17.188 Y7.804 E.01506 +G1 X17.365 Y8.324 E.02212 +;WIDTH:0.535834 +G1 X17.439 Y9.08 E.03051 +;WIDTH:0.524424 +G1 X17.435 Y12.752 E.14408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X16.956 Y12.791 +G1 X16.956 Y12.552 +G1 X16.956 Y11.272 +;WIPE_END +G1 X11.974 Y11.65 Z1.6 F30000 +G1 Z1.2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.528132 +G1 F12000 +G1 X11.825 Y12.057 E.01713 +;WIDTH:0.528286 +G1 X11.538 Y12.45 E.01924 +G1 X11.129 Y12.713 E.01923 +;WIDTH:0.523935 +G1 X10.722 Y12.843 E.01673 +;WIDTH:0.499617 +G1 X10.219 Y12.895 E.01884 +;WIDTH:0.487401 +G1 X9.663 Y12.869 E.02017 +;WIDTH:0.490514 +G1 X9.158 Y12.75 E.0189 +;WIDTH:0.505007 +G1 X8.743 Y12.531 E.01768 +;WIDTH:0.519498 +G1 X8.447 Y12.23 E.01638 +G1 X8.264 Y11.817 E.01754 +;WIDTH:0.516067 +G1 X8.203 Y11.313 E.01957 +;WIDTH:0.542871 +G1 X8.275 Y10.831 E.01985 +;WIDTH:0.544257 +G1 X8.444 Y10.494 E.01541 +;WIDTH:0.550634 +G1 X8.71 Y10.21 E.01609 +;WIDTH:0.558112 +G1 X9.167 Y9.943 E.02224 +G1 X9.704 Y9.756 E.02385 +;WIDTH:0.557829 +G1 X10.84 Y9.429 E.04961 +G1 X11.302 Y9.176 E.0221 +;WIDTH:0.548864 +G1 X11.533 Y8.92 E.01422 +;WIDTH:0.544322 +G1 X11.654 Y8.467 E.01915 +;WIDTH:0.521713 +G1 X11.594 Y7.962 E.01986 +;WIDTH:0.520253 +G1 X11.381 Y7.592 E.01659 +;WIDTH:0.518708 +G1 X10.978 Y7.316 E.01896 +;WIDTH:0.500912 +G1 X10.46 Y7.185 E.01992 +;WIDTH:0.497673 +G1 X9.886 Y7.17 E.0213 +;WIDTH:0.515236 +G1 X9.285 Y7.287 E.02355 +;WIDTH:0.523419 +G1 X8.884 Y7.502 E.01782 +;WIDTH:0.538618 +G1 X8.646 Y7.773 E.01457 +G1 X8.458 Y8.191 E.01852 +;WIDTH:0.536405 +G1 X7.99 Y8.092 E.01925 +G1 X8.153 Y7.641 E.01929 +;WIDTH:0.531533 +G1 X8.46 Y7.228 E.02048 +G1 X8.866 Y6.953 E.01953 +;WIDTH:0.516689 +G1 X9.339 Y6.789 E.01933 +;WIDTH:0.504538 +G1 X9.922 Y6.713 E.02212 +;WIDTH:0.497658 +G1 X10.5 Y6.733 E.02144 +;WIDTH:0.504616 +G1 X11.049 Y6.851 E.02113 +;WIDTH:0.517091 +G1 X11.431 Y7.035 E.0164 +G1 X11.688 Y7.236 E.01259 +;WIDTH:0.52355 +G1 X11.952 Y7.585 E.01714 +;WIDTH:0.53117 +G1 X12.098 Y8.01 E.0179 +G1 X12.138 Y8.382 E.01487 +;WIDTH:0.54576 +G1 X12.075 Y8.894 E.02116 +;WIDTH:0.547443 +G1 X11.919 Y9.248 E.0159 +;WIDTH:0.553635 +G1 X11.663 Y9.544 E.01627 +;WIDTH:0.557875 +G1 X11.22 Y9.816 E.02181 +G1 X10.888 Y9.951 E.01503 +G1 X9.832 Y10.252 E.04607 +;WIDTH:0.558112 +G1 X9.346 Y10.426 E.0217 +G1 X9.038 Y10.598 E.01479 +;WIDTH:0.546934 +G1 X8.835 Y10.804 E.01188 +;WIDTH:0.544257 +G1 X8.682 Y11.234 E.01866 +;WIDTH:0.526516 +G1 X8.729 Y11.737 E.01989 +;WIDTH:0.515559 +G1 X8.921 Y12.072 E.01489 +;WIDTH:0.507544 +G1 X9.201 Y12.275 E.0131 +;WIDTH:0.504306 +G1 X9.706 Y12.428 E.01983 +;WIDTH:0.487401 +G1 X10.227 Y12.45 E.01888 +;WIDTH:0.499617 +G1 X10.659 Y12.39 E.01623 +;WIDTH:0.518173 +G1 X11.1 Y12.183 E.01886 +;WIDTH:0.526936 +G1 X11.366 Y11.883 E.01582 +G1 X11.5 Y11.552 E.0141 +;WIDTH:0.524122 +G1 X11.935 Y11.642 E.01741 +;LAYER_CHANGE +;Z:1.4 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;1.4 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X11.825 Y12.057 +G1 X11.538 Y12.45 +G1 X11.129 Y12.713 +G1 X10.722 Y12.843 +G1 X10.552 Y12.86 +;WIPE_END +;AFTER_LAYER_CHANGE +;1.4 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z1.8 I.789 J.927 P1 F30000 +G1 X11.974 Y11.65 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.52813 +G1 F12000 +G1 X11.825 Y12.057 E.01713 +;WIDTH:0.528286 +G1 X11.538 Y12.45 E.01924 +G1 X11.129 Y12.714 E.01925 +;WIDTH:0.52395 +G1 X10.723 Y12.842 E.01669 +;WIDTH:0.499236 +G1 X10.219 Y12.895 E.01883 +;WIDTH:0.487483 +G1 X9.665 Y12.87 E.02008 +;WIDTH:0.490687 +G1 X9.163 Y12.752 E.01883 +;WIDTH:0.504745 +G1 X8.744 Y12.531 E.01781 +;WIDTH:0.519498 +G1 X8.447 Y12.23 E.01642 +G1 X8.264 Y11.817 E.01754 +;WIDTH:0.516068 +G1 X8.203 Y11.313 E.01957 +;WIDTH:0.542871 +G1 X8.275 Y10.831 E.01985 +;WIDTH:0.544257 +G1 X8.444 Y10.494 E.01541 +;WIDTH:0.550633 +G1 X8.71 Y10.21 E.01609 +;WIDTH:0.558115 +G1 X9.167 Y9.943 E.02224 +G1 X9.704 Y9.757 E.02385 +;WIDTH:0.557824 +G1 X10.84 Y9.429 E.04961 +G1 X11.302 Y9.176 E.0221 +;WIDTH:0.54887 +G1 X11.533 Y8.92 E.01422 +;WIDTH:0.544321 +G1 X11.654 Y8.442 E.02016 +;WIDTH:0.520888 +G1 X11.591 Y7.943 E.0196 +G1 X11.352 Y7.56 E.01756 +;WIDTH:0.509656 +G1 X10.954 Y7.307 E.01794 +;WIDTH:0.498865 +G1 X10.46 Y7.187 E.01888 +;WIDTH:0.494485 +G1 X9.886 Y7.172 E.02114 +;WIDTH:0.515237 +G1 X9.285 Y7.287 E.02355 +;WIDTH:0.52342 +G1 X8.884 Y7.502 E.01782 +;WIDTH:0.538616 +G1 X8.646 Y7.773 E.01457 +G1 X8.458 Y8.191 E.01852 +;WIDTH:0.536406 +G1 X7.99 Y8.092 E.01925 +G1 X8.153 Y7.641 E.01929 +;WIDTH:0.531533 +G1 X8.46 Y7.228 E.02048 +G1 X8.866 Y6.953 E.01953 +;WIDTH:0.516689 +G1 X9.339 Y6.789 E.01933 +;WIDTH:0.504537 +G1 X9.927 Y6.719 E.02227 +;WIDTH:0.493539 +G1 X10.594 Y6.754 E.02455 +;WIDTH:0.498865 +G1 X11.106 Y6.877 E.01956 +;WIDTH:0.512146 +G1 X11.562 Y7.127 E.01988 +;WIDTH:0.526479 +G1 X11.851 Y7.426 E.01638 +G1 X12.027 Y7.747 E.01442 +;WIDTH:0.533233 +G1 X12.126 Y8.216 E.01916 +G1 X12.129 Y8.528 E.01248 +;WIDTH:0.535015 +G1 X12.031 Y8.987 E.0188 +;WIDTH:0.547442 +G1 X11.919 Y9.248 E.01169 +;WIDTH:0.553635 +G1 X11.663 Y9.544 E.01627 +;WIDTH:0.55788 +G1 X11.22 Y9.816 E.02181 +G1 X10.888 Y9.951 E.01503 +G1 X9.832 Y10.252 E.04607 +;WIDTH:0.558115 +G1 X9.346 Y10.426 E.0217 +G1 X9.038 Y10.598 E.01479 +;WIDTH:0.546934 +G1 X8.835 Y10.804 E.01188 +;WIDTH:0.544257 +G1 X8.682 Y11.234 E.01866 +;WIDTH:0.526515 +G1 X8.729 Y11.737 E.01989 +;WIDTH:0.515559 +G1 X8.921 Y12.072 E.01489 +;WIDTH:0.507544 +G1 X9.214 Y12.283 E.01364 +;WIDTH:0.503367 +G1 X9.706 Y12.428 E.01926 +;WIDTH:0.487483 +G1 X10.227 Y12.45 E.01888 +;WIDTH:0.499236 +G1 X10.659 Y12.39 E.01622 +;WIDTH:0.518172 +G1 X11.1 Y12.183 E.01886 +;WIDTH:0.526936 +G1 X11.366 Y11.883 E.01582 +G1 X11.5 Y11.552 E.0141 +;WIDTH:0.524122 +G1 X11.935 Y11.642 E.01741 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X11.825 Y12.057 +G1 X11.538 Y12.45 +G1 X11.129 Y12.714 +G1 X10.723 Y12.842 +G1 X10.552 Y12.86 +;WIPE_END +G1 X17.435 Y12.792 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.522355 +G1 F12000 +G1 X16.956 Y12.791 E.01872 +G1 X16.956 Y12.552 E.00935 +;WIDTH:0.524425 +G1 X16.957 Y8.985 E.13995 +;WIDTH:0.5288 +G1 X16.871 Y8.339 E.02578 +G1 X16.67 Y7.859 E.02061 +;WIDTH:0.526993 +G1 X16.365 Y7.51 E.01829 +;WIDTH:0.525055 +G1 X15.887 Y7.26 E.02118 +;WIDTH:0.514269 +G1 X15.308 Y7.185 E.02245 +;WIDTH:0.516262 +G1 X14.731 Y7.286 E.02258 +;WIDTH:0.522732 +G1 X14.298 Y7.55 E.01984 +;WIDTH:0.528283 +G1 X14.003 Y7.957 E.01988 +;WIDTH:0.536086 +G1 X13.855 Y8.446 E.02055 +G1 X13.8 Y9.021 E.0232 +;WIDTH:0.529288 +G1 X13.793 Y12.789 E.14933 +;WIDTH:0.524571 +G1 X13.312 Y12.783 E.01889 +G1 X13.312 Y12.545 E.00932 +;WIDTH:0.529288 +G1 X13.314 Y8.967 E.1418 +;WIDTH:0.536086 +G1 X13.364 Y8.401 E.02283 +G1 X13.445 Y8.057 E.0142 +;WIDTH:0.534619 +G1 X13.617 Y7.659 E.01739 +;WIDTH:0.528537 +G1 X13.963 Y7.209 E.02245 +;WIDTH:0.525818 +G1 X14.392 Y6.919 E.02039 +G1 X14.733 Y6.79 E.01432 +;WIDTH:0.516096 +G1 X15.433 Y6.715 E.02713 +;WIDTH:0.514269 +G1 X15.957 Y6.794 E.02038 +;WIDTH:0.518091 +G1 X16.174 Y6.859 E.00875 +;WIDTH:0.525055 +G1 X16.641 Y7.117 E.02096 +;WIDTH:0.527163 +G1 X16.999 Y7.481 E.02014 +;WIDTH:0.537095 +G1 X17.188 Y7.804 E.01505 +G1 X17.365 Y8.324 E.02213 +;WIDTH:0.535833 +G1 X17.439 Y9.08 E.03051 +;WIDTH:0.524425 +G1 X17.435 Y12.752 E.14408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X16.956 Y12.791 +G1 X16.956 Y12.552 +G1 X16.956 Y11.272 +;WIPE_END +G1 X22.622 Y8.062 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.431922 +G1 F18000 +G1 X22.447 Y7.835 E.00907 +G1 X22.377 Y7.816 E.00231 +G1 X22.043 Y7.935 E.01125 +G1 X22.469 Y7.137 E.02868 +G1 X22.622 Y7.138 E.00484 +G1 X22.622 Y8.022 E.02801 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X22.447 Y7.835 +G1 X22.377 Y7.816 +G1 X22.043 Y7.935 +G1 X22.469 Y7.137 +G1 X22.622 Y7.138 +G1 X22.622 Y7.397 +;WIPE_END +G1 X19.77 Y11.672 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.425698 +G1 F18000 +G1 X19.346 Y12.463 E.02799 +G1 X19.219 Y12.463 E.00397 +G1 X19.219 Y11.549 E.02852 +G1 X19.39 Y11.735 E.0079 +G1 X19.459 Y11.753 E.00222 +G1 X19.731 Y11.682 E.00878 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X19.556 Y11.386 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.457552 +G1 F12000 +G1 X19.688 Y11.37 E.0045 +;WIDTH:0.495105 +G1 X19.82 Y11.354 E.00491 +G1 X19.84 Y11.32 E.00145 +;WIDTH:0.494732 +G1 X21.595 Y8.038 E.137 +G1 X21.757 Y7.697 E.01389 +;WIDTH:0.457366 +G1 X21.918 Y7.356 E.01275 +;WIDTH:0.419999 +G1 X22.244 Y6.753 E.02106 +G1 X23.005 Y6.757 E.02338 +G1 X23.005 Y7.701 E.02901 +;WIDTH:0.454735 +G1 X22.987 Y8.044 E.01154 +;WIDTH:0.489471 +G1 X22.97 Y8.388 E.01251 +;WIDTH:0.492122 +G1 X22.969 Y12.791 E.16116 +G1 X22.519 Y12.803 E.01646 +G1 X22.523 Y8.387 E.16162 +;WIDTH:0.489471 +G1 X22.436 Y8.296 E.0046 +;WIDTH:0.454735 +G1 X22.349 Y8.205 E.00424 +;WIDTH:0.419999 +G1 X22.278 Y8.186 E.00223 +;WIDTH:0.457366 +G1 X22.136 Y8.219 E.00493 +;WIDTH:0.494732 +M73 P77 R1 +G1 X21.994 Y8.252 E.00538 +M73 P77 R1 +G1 X20.184 Y11.628 E.14105 +;WIDTH:0.491705 +G1 X20.049 Y11.918 E.0117 +;WIDTH:0.455852 +G1 X19.913 Y12.208 E.01077 +;WIDTH:0.419999 +G1 X19.569 Y12.843 E.02218 +G1 X18.839 Y12.843 E.02243 +G1 X18.839 Y11.867 E.02999 +;WIDTH:0.452654 +G1 X18.855 Y11.526 E.0114 +;WIDTH:0.485309 +G1 X18.872 Y11.185 E.01231 +;WIDTH:0.487794 +G1 X18.874 Y6.786 E.15945 +G1 X19.319 Y6.787 E.01613 +G1 X19.314 Y11.223 E.16081 +;WIDTH:0.485309 +G1 X19.435 Y11.304 E.00526 +;WIDTH:0.452654 +G1 X19.523 Y11.364 E.00354 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X19.688 Y11.37 +G1 X19.82 Y11.354 +G1 X19.84 Y11.32 +G1 X20.624 Y9.854 +;WIPE_END +G1 X25.061 Y7.295 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.604653 +G1 F12000 +G1 X24.959 Y7.317 E.0048 +G1 X24.94 Y7.413 E.00447 +;WIDTH:0.566958 +G1 X24.921 Y7.509 E.00417 +;WIDTH:0.529262 +G1 X24.92 Y12.787 E.20917 +;WIDTH:0.52871 +G1 X24.435 Y12.788 E.01923 +;WIDTH:0.529262 +G1 X24.435 Y7.509 E.20924 +;WIDTH:0.566958 +G1 X24.454 Y7.177 E.0142 +;WIDTH:0.604653 +G1 X24.472 Y6.845 E.01522 +G1 X24.818 Y6.823 E.01584 +;WIDTH:0.559944 +G1 X25.163 Y6.8 E.01458 +;WIDTH:0.515235 +G1 X27.587 Y6.799 E.0933 +;WIDTH:0.511903 +G1 X27.594 Y7.267 E.0179 +G1 X27.357 Y7.268 E.00907 +;WIDTH:0.515235 +G1 X25.164 Y7.273 E.08441 +;WIDTH:0.559944 +G1 X25.1 Y7.286 E.00273 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X24.959 Y7.317 +G1 X24.94 Y7.413 +G1 X24.921 Y7.509 +G1 X24.921 Y9.169 +;WIPE_END +G1 X31.853 Y12.362 Z1.8 F30000 +G1 X32.786 Y12.792 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.522354 +G1 F12000 +G1 X32.307 Y12.791 E.01872 +G1 X32.307 Y12.552 E.00935 +;WIDTH:0.524424 +G1 X32.308 Y8.985 E.13995 +;WIDTH:0.528799 +G1 X32.222 Y8.339 E.02578 +G1 X32.021 Y7.859 E.02061 +;WIDTH:0.526993 +G1 X31.716 Y7.51 E.01829 +;WIDTH:0.525055 +G1 X31.238 Y7.26 E.02118 +;WIDTH:0.514268 +G1 X30.659 Y7.185 E.02245 +;WIDTH:0.516262 +G1 X30.082 Y7.286 E.02258 +;WIDTH:0.522733 +G1 X29.649 Y7.55 E.01984 +;WIDTH:0.528284 +G1 X29.354 Y7.957 E.01988 +;WIDTH:0.536087 +G1 X29.206 Y8.446 E.02055 +G1 X29.151 Y9.021 E.0232 +;WIDTH:0.529288 +G1 X29.144 Y12.789 E.14933 +;WIDTH:0.524572 +G1 X28.663 Y12.783 E.01889 +G1 X28.663 Y12.545 E.00932 +;WIDTH:0.529288 +G1 X28.665 Y8.967 E.14181 +;WIDTH:0.536087 +G1 X28.714 Y8.401 E.02283 +G1 X28.796 Y8.057 E.0142 +;WIDTH:0.534621 +G1 X28.968 Y7.659 E.01739 +;WIDTH:0.528537 +G1 X29.314 Y7.209 E.02245 +;WIDTH:0.525819 +G1 X29.743 Y6.919 E.02039 +G1 X30.084 Y6.79 E.01433 +;WIDTH:0.516097 +G1 X30.784 Y6.715 E.02713 +;WIDTH:0.514268 +G1 X31.308 Y6.794 E.02038 +;WIDTH:0.518092 +G1 X31.525 Y6.859 E.00875 +;WIDTH:0.525055 +G1 X31.992 Y7.117 E.02096 +;WIDTH:0.527164 +G1 X32.35 Y7.481 E.02014 +;WIDTH:0.537093 +G1 X32.538 Y7.804 E.01505 +G1 X32.716 Y8.324 E.02213 +;WIDTH:0.535834 +G1 X32.79 Y9.08 E.03051 +;WIDTH:0.524424 +G1 X32.786 Y12.752 E.14408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X32.307 Y12.791 +G1 X32.307 Y12.552 +G1 X32.307 Y11.272 +;WIPE_END +G1 X31.16 Y3.726 Z1.8 F30000 +G1 X30.828 Y1.544 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.44866 +G1 F12000 +G1 X30.843 Y1.618 E.00251 +;WIDTH:0.478153 +G1 X30.858 Y1.693 E.00269 +G1 X30.937 Y1.71 E.00287 +;WIDTH:0.443833 +G1 X31.016 Y1.727 E.00264 +;WIDTH:0.409512 +G1 X33.105 Y1.73 E.06242 +;WIDTH:0.407441 +G1 X33.091 Y2.095 E.01083 +G1 X32.916 Y2.095 E.0052 +;WIDTH:0.409512 +G1 X31.016 Y2.094 E.05676 +;WIDTH:0.443833 +G1 X30.748 Y2.076 E.00874 +;WIDTH:0.478153 +G1 X30.481 Y2.059 E.00949 +G1 X30.467 Y1.801 E.00916 +;WIDTH:0.44866 +G1 X30.452 Y1.544 E.00854 +;WIDTH:0.419167 +G1 X30.452 Y.422 E.0344 +;WIDTH:0.463078 +G1 X30.474 Y.24 E.00627 +;WIDTH:0.506988 +G1 X30.496 Y.058 E.00692 +G1 X30.474 Y-.124 E.00692 +;WIDTH:0.463078 +G1 X30.452 Y-.306 E.00627 +;WIDTH:0.419167 +G1 X30.452 Y-1.547 E.03805 +;WIDTH:0.448655 +G1 X30.467 Y-1.804 E.00854 +;WIDTH:0.478142 +G1 X30.481 Y-2.062 E.00916 +G1 X30.748 Y-2.079 E.00948 +;WIDTH:0.443801 +G1 X31.015 Y-2.097 E.00874 +;WIDTH:0.40946 +G1 X33.208 Y-2.098 E.06549 +;WIDTH:0.406009 +G1 X33.216 Y-1.735 E.01073 +G1 X33.031 Y-1.735 E.00547 +;WIDTH:0.40946 +G1 X31.016 Y-1.73 E.06019 +;WIDTH:0.443801 +G1 X30.937 Y-1.713 E.00264 +;WIDTH:0.478142 +G1 X30.858 Y-1.696 E.00287 +G1 X30.843 Y-1.621 E.00269 +;WIDTH:0.448655 +G1 X30.828 Y-1.547 E.00251 +;WIDTH:0.419167 +G1 X30.828 Y-.306 E.03805 +;WIDTH:0.463078 +G1 X30.85 Y-.215 E.0032 +;WIDTH:0.506988 +G1 X30.872 Y-.124 E.00354 +G1 X30.92 Y-.124 E.00182 +;WIDTH:0.473482 +G1 X30.968 Y-.124 E.00169 +;WIDTH:0.439976 +G1 X31.016 Y-.124 E.00155 +;WIDTH:0.40647 +G1 X32.956 Y-.125 E.05746 +;WIDTH:0.402763 +G1 X32.939 Y.235 E.01055 +G1 X32.768 Y.235 E.00502 +;WIDTH:0.40647 +G1 X30.872 Y.24 E.05617 +;WIDTH:0.419167 +G1 X30.828 Y.422 E.00572 +G1 X30.828 Y1.504 E.03318 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X30.843 Y1.618 +G1 X30.858 Y1.693 +G1 X30.937 Y1.71 +G1 X31.016 Y1.727 +G1 X32.663 Y1.73 +;WIPE_END +G1 X29.115 Y-.457 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.468232 +G1 F12000 +G1 X29.133 Y-.537 E.00285 +;WIDTH:0.432489 +G1 X29.151 Y-.617 E.00261 +;WIDTH:0.417657 +G1 X29.143 Y-1.297 E.02078 +G1 X28.815 Y-1.563 E.01291 +;WIDTH:0.406051 +G1 X28.454 Y-1.717 E.01162 +;WIDTH:0.406794 +G1 X28.057 Y-1.788 E.01194 +;WIDTH:0.413286 +G1 X27.669 Y-1.765 E.01172 +;WIDTH:0.415085 +G1 X27.3 Y-1.647 E.01176 +;WIDTH:0.420236 +G1 X27.033 Y-1.452 E.01015 +;WIDTH:0.425228 +G1 X26.823 Y-1.188 E.01052 +G1 X26.662 Y-.837 E.01203 +;WIDTH:0.418558 +G1 X26.568 Y-.443 E.01239 +;WIDTH:0.422689 +G1 X26.533 Y.018 E.01431 +;WIDTH:0.428438 +G1 X26.579 Y.525 E.01598 +G1 X26.67 Y.909 E.01241 +;WIDTH:0.426495 +G1 X26.821 Y1.225 E.01092 +;WIDTH:0.425736 +G1 X27.015 Y1.464 E.0096 +;WIDTH:0.423091 +G1 X27.366 Y1.693 E.013 +;WIDTH:0.408614 +G1 X27.695 Y1.774 E.01009 +;WIDTH:0.404656 +G1 X28.203 Y1.76 E.01498 +;WIDTH:0.423055 +G1 X28.578 Y1.636 E.01224 +;WIDTH:0.426215 +G1 X28.88 Y1.375 E.01245 +G1 X29.029 Y1.122 E.00919 +;WIDTH:0.419788 +G1 X29.388 Y1.232 E.01152 +G1 X29.291 Y1.423 E.00658 +;WIDTH:0.43502 +G1 X29.058 Y1.75 E.01282 +G1 X28.731 Y1.984 E.01286 +;WIDTH:0.423055 +G1 X28.247 Y2.118 E.01557 +;WIDTH:0.404656 +G1 X27.701 Y2.132 E.0161 +;WIDTH:0.405869 +G1 X27.277 Y2.048 E.01278 +;WIDTH:0.410076 +G1 X26.908 Y1.856 E.01243 +;WIDTH:0.415202 +G1 X26.601 Y1.564 E.01285 +;WIDTH:0.419405 +G1 X26.354 Y1.142 E.015 +;WIDTH:0.428438 +G1 X26.196 Y.573 E.01855 +G1 X26.157 Y.068 E.01591 +;WIDTH:0.425628 +G1 X26.171 Y-.332 E.01248 +G1 X26.259 Y-.82 E.01549 +;WIDTH:0.425144 +G1 X26.452 Y-1.288 E.01577 +;WIDTH:0.419806 +G1 X26.702 Y-1.649 E.01349 +G1 X27.046 Y-1.927 E.01357 +;WIDTH:0.414159 +G1 X27.451 Y-2.095 E.01328 +;WIDTH:0.409374 +G1 X27.991 Y-2.152 E.01621 +;WIDTH:0.409492 +G1 X28.442 Y-2.098 E.01356 +G1 X28.91 Y-1.927 E.01487 +;WIDTH:0.41407 +G1 X29.238 Y-1.715 E.01181 +;WIDTH:0.417657 +G1 X29.5 Y-1.45 E.01139 +G1 X29.508 Y-1.215 E.00718 +;WIDTH:0.398799 +G1 X29.505 Y-.613 E.01746 +;WIDTH:0.432487 +G1 X29.485 Y-.349 E.00842 +;WIDTH:0.468232 +G1 X29.466 Y-.084 E.00919 +G1 X29.222 Y-.071 E.00845 +;WIDTH:0.441916 +G1 X28.979 Y-.058 E.00792 +;WIDTH:0.415599 +G1 X28.077 Y-.056 E.02741 +;WIDTH:0.411114 +G1 X28.068 Y-.421 E.01098 +G1 X28.254 Y-.424 E.00558 +;WIDTH:0.439673 +G1 X28.684 Y-.44 E.01392 +;WIDTH:0.468232 +G1 X29.075 Y-.455 E.01353 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X29.133 Y-.537 +G1 X29.151 Y-.617 +G1 X29.143 Y-1.297 +G1 X28.815 Y-1.563 +G1 X28.454 Y-1.717 +G1 X28.137 Y-1.774 +;WIPE_END +G1 X22.947 Y.905 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.419999 +G1 F12000 +G1 X23.054 Y.932 E.00338 +G1 X23.254 Y.831 E.00686 +;WIDTH:0.39392 +G1 X24.246 Y-1.019 E.06006 +;WIDTH:0.419999 +G1 X24.835 Y-2.091 E.03757 +G1 X25.331 Y-2.087 E.01523 +G1 X25.344 Y-.743 E.04131 +;WIDTH:0.394336 +G1 X25.344 Y2.085 E.08098 +;WIDTH:0.393306 +G1 X24.999 Y2.098 E.00985 +G1 X24.994 Y1.916 E.00518 +;WIDTH:0.394336 +G1 X24.992 Y-.743 E.07614 +;WIDTH:0.419999 +G1 X24.863 Y-.925 E.00687 +G1 X24.757 Y-.952 E.00339 +G1 X24.555 Y-.853 E.0069 +;WIDTH:0.39392 +G1 X23.561 Y.996 E.06005 +;WIDTH:0.419999 +G1 X22.959 Y2.088 E.03832 +G1 X22.484 Y2.088 E.01459 +G1 X22.47 Y.722 E.04198 +;WIDTH:0.391746 +G1 X22.47 Y-2.106 E.0804 +;WIDTH:0.390159 +G1 X22.817 Y-2.106 E.00982 +G1 X22.817 Y-1.933 E.0049 +;WIDTH:0.391746 +G1 X22.819 Y.722 E.07546 +;WIDTH:0.419999 +G1 X22.924 Y.872 E.00564 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X23.054 Y.932 +G1 X23.254 Y.831 +G1 X24.026 Y-.609 +;WIPE_END +G1 X24.969 Y-1.346 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.504434 +G1 F16250.861 +G1 X24.941 Y-1.296 E.00217 +G1 X24.883 Y-1.296 E.00217 +G1 X24.854 Y-1.346 E.00217 +G1 X24.883 Y-1.396 E.00217 +G1 X24.941 Y-1.396 E.00217 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F16250.861 +G1 X24.883 Y-1.396 +G1 X24.854 Y-1.346 +G1 X24.883 Y-1.296 +G1 X24.941 Y-1.296 +G1 X24.969 Y-1.346 +;WIPE_END +G1 X22.956 Y1.321 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.496724 +G1 F16526.958 +G1 X22.928 Y1.37 E.0021 +G1 X22.871 Y1.37 E.0021 +G1 X22.843 Y1.321 E.0021 +G1 X22.871 Y1.272 E.0021 +G1 X22.928 Y1.272 E.0021 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F16526.958 +G1 X22.871 Y1.272 +G1 X22.843 Y1.321 +G1 X22.871 Y1.37 +G1 X22.928 Y1.37 +G1 X22.956 Y1.321 +;WIPE_END +G1 X20.897 Y-.255 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.398471 +G1 F12000 +G1 X20.437 Y1.122 E.04206 +;WIDTH:0.419999 +G1 X20.272 Y1.254 E.00649 +G1 X20.015 Y1.149 E.00852 +;WIDTH:0.401646 +G1 X19.547 Y-.255 E.04326 +;WIDTH:0.451588 +G1 X19.54 Y-.351 E.00321 +;WIDTH:0.50153 +G1 X19.533 Y-.447 E.00361 +G1 X19.594 Y-.461 E.00235 +;WIDTH:0.467287 +G1 X19.656 Y-.475 E.00218 +;WIDTH:0.433044 +G1 X19.717 Y-.489 E.002 +;WIDTH:0.3988 +G1 X20.728 Y-.489 E.02932 +;WIDTH:0.432237 +G1 X20.789 Y-.475 E.00197 +;WIDTH:0.465674 +G1 X20.849 Y-.462 E.00214 +;WIDTH:0.499111 +G1 X20.91 Y-.448 E.00231 +G1 X20.906 Y-.384 E.0024 +;WIDTH:0.465565 +G1 X20.901 Y-.319 E.00222 +;WIDTH:0.432018 +G1 X20.9 Y-.295 E.00078 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X20.437 Y1.122 +G1 X20.272 Y1.254 +G1 X20.015 Y1.149 +G1 X20.008 Y1.129 +;WIPE_END +G1 X15.523 Y.044 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.504514 +G1 F12000 +G1 X15.882 Y.044 E.01351 +;WIDTH:0.470828 +G1 X16.241 Y.045 E.01252 +;WIDTH:0.437142 +G1 X16.6 Y.046 E.01154 +;WIDTH:0.444121 +G1 X16.747 Y.03 E.00483 +;WIDTH:0.484786 +G1 X16.894 Y.015 E.00532 +G1 X17.096 Y.113 E.0081 +;WIDTH:0.451124 +G1 X17.299 Y.211 E.00749 +;WIDTH:0.417461 +G1 X17.51 Y.45 E.00974 +;WIDTH:0.416009 +G1 X17.597 Y.72 E.00863 +;WIDTH:0.427553 +G1 X17.589 Y1.117 E.01243 +G1 X17.461 Y1.429 E.01056 +;WIDTH:0.429295 +G1 X17.176 Y1.65 E.01138 +;WIDTH:0.416342 +G1 X16.712 Y1.734 E.01433 +;WIDTH:0.404898 +G1 X15.668 Y1.734 E.03081 +;WIDTH:0.440215 +G1 X15.588 Y1.716 E.00265 +;WIDTH:0.475531 +G1 X15.508 Y1.699 E.00289 +G1 X15.494 Y1.626 E.00262 +;WIDTH:0.447309 +G1 X15.48 Y1.553 E.00244 +;WIDTH:0.419086 +G1 X15.48 Y.223 E.04076 +;WIDTH:0.461709 +G1 X15.501 Y.133 E.00315 +;WIDTH:0.504514 +G1 X15.513 Y.083 E.00196 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.566 Y-.315 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.47074 +G1 F12000 +G1 X15.609 Y-.315 E.0015 +;WIDTH:0.436966 +G1 X15.652 Y-.315 E.00139 +;WIDTH:0.403455 +G1 X16.6 Y-.314 E.02787 +;WIDTH:0.447213 +M73 P78 R1 +G1 X16.686 Y-.333 E.00289 +M73 P78 R1 +;WIDTH:0.490971 +G1 X16.772 Y-.351 E.0032 +G1 X16.828 Y-.498 E.00574 +;WIDTH:0.446198 +G1 X16.885 Y-.644 E.00517 +;WIDTH:0.414136 +G1 X17.673 Y-2.094 E.04993 +G1 X18.1 Y-2.093 E.0129 +G1 X17.199 Y-.472 E.05612 +;WIDTH:0.446198 +G1 X17.174 Y-.384 E.00301 +;WIDTH:0.490971 +G1 X17.15 Y-.295 E.00334 +G1 X17.344 Y-.186 E.00815 +;WIDTH:0.454216 +G1 X17.539 Y-.077 E.00748 +;WIDTH:0.417461 +G1 X17.798 Y.212 E.01185 +;WIDTH:0.421278 +G1 X17.94 Y.522 E.01049 +G1 X17.985 Y1.005 E.01496 +;WIDTH:0.428094 +G1 X17.897 Y1.427 E.01355 +;WIDTH:0.429295 +G1 X17.663 Y1.78 E.01332 +G1 X17.262 Y2.013 E.01461 +;WIDTH:0.416342 +G1 X16.712 Y2.096 E.01691 +;WIDTH:0.404898 +G1 X15.668 Y2.096 E.03081 +;WIDTH:0.440215 +G1 X15.4 Y2.078 E.0087 +;WIDTH:0.475531 +G1 X15.132 Y2.061 E.00947 +G1 X15.117 Y1.807 E.00896 +;WIDTH:0.447309 +G1 X15.103 Y1.553 E.00838 +;WIDTH:0.419086 +G1 X15.104 Y.223 E.04076 +;WIDTH:0.461709 +G1 X15.125 Y.044 E.00617 +;WIDTH:0.504514 +G1 X15.147 Y-.136 E.0068 +G1 X15.125 Y-.315 E.0068 +;WIDTH:0.461659 +G1 X15.104 Y-.495 E.00617 +;WIDTH:0.418804 +G1 X15.104 Y-2.092 E.04892 +;WIDTH:0.418611 +G1 X15.48 Y-2.092 E.0115 +;WIDTH:0.418804 +G1 X15.48 Y-.495 E.04891 +;WIDTH:0.461659 +G1 X15.501 Y-.405 E.00315 +;WIDTH:0.504514 +G1 X15.523 Y-.315 E.00347 +G1 X15.526 Y-.315 E.00012 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X15.609 Y-.315 +G1 X15.652 Y-.315 +G1 X16.6 Y-.314 +G1 X16.686 Y-.333 +G1 X16.772 Y-.351 +G1 X16.828 Y-.498 +G1 X16.885 Y-.644 +G1 X17.093 Y-1.027 +;WIPE_END +G1 X21.027 Y-.804 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.499111 +G1 F12000 +G1 X21.079 Y-.884 E.00356 +;WIDTH:0.451619 +G1 X21.132 Y-.964 E.00319 +;WIDTH:0.40538 +G1 X21.52 Y-2.099 E.03541 +G1 X21.903 Y-2.098 E.01132 +G1 X21.474 Y-.847 E.03905 +;WIDTH:0.451619 +G1 X21.391 Y-.679 E.00623 +;WIDTH:0.499111 +G1 X21.308 Y-.512 E.00696 +G1 X21.283 Y-.388 E.00468 +;WIDTH:0.465565 +G1 X21.258 Y-.265 E.00433 +;WIDTH:0.432018 +G1 X21.234 Y-.141 E.00399 +;WIDTH:0.398471 +G1 X20.771 Y1.218 E.0416 +;WIDTH:0.419999 +G1 X20.456 Y2.087 E.02843 +G1 X20.013 Y2.088 E.01359 +G1 X19.678 Y1.236 E.02815 +;WIDTH:0.401646 +G1 X19.207 Y-.14 E.04251 +;WIDTH:0.451588 +G1 X19.169 Y-.324 E.00626 +;WIDTH:0.50153 +G1 X19.132 Y-.509 E.00703 +G1 X19.048 Y-.678 E.00707 +;WIDTH:0.454248 +G1 X18.964 Y-.848 E.00634 +;WIDTH:0.413031 +G1 X18.536 Y-2.092 E.03969 +G1 X18.93 Y-2.088 E.01188 +G1 X19.309 Y-.965 E.03575 +;WIDTH:0.454248 +G1 X19.362 Y-.884 E.00324 +;WIDTH:0.50153 +G1 X19.415 Y-.803 E.00362 +G1 X19.515 Y-.817 E.0038 +;WIDTH:0.467287 +G1 X19.616 Y-.831 E.00352 +;WIDTH:0.433044 +G1 X19.717 Y-.845 E.00323 +;WIDTH:0.3988 +G1 X20.728 Y-.845 E.02932 +;WIDTH:0.432237 +G1 X20.828 Y-.831 E.00319 +;WIDTH:0.465674 +G1 X20.927 Y-.818 E.00346 +;WIDTH:0.499111 +G1 X20.987 Y-.81 E.00225 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X21.079 Y-.884 +G1 X21.132 Y-.964 +G1 X21.52 Y-2.099 +G1 X21.903 Y-2.098 +G1 X21.837 Y-1.905 +;WIPE_END +G1 X20.268 Y1.625 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.408414 +G1 F18000 +G1 X20.245 Y1.665 E.00136 +G1 X20.176 Y1.625 E.00236 +G1 X20.245 Y1.585 E.00236 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X20.176 Y1.625 +G1 X20.245 Y1.665 +G1 X20.268 Y1.625 +;WIPE_END +G1 X13.483 Y-1.87 Z1.8 F30000 +G1 X13.127 Y-2.053 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.404919 +G1 F12000 +G1 X13.498 Y-1.865 E.01228 +;WIDTH:0.407981 +G1 X13.811 Y-1.572 E.01275 +;WIDTH:0.416735 +G1 X14.073 Y-1.152 E.01508 +;WIDTH:0.421643 +G1 X14.225 Y-.663 E.01581 +;WIDTH:0.429148 +G1 X14.283 Y.06 E.02282 +;WIDTH:0.420308 +G1 X14.22 Y.686 E.01935 +;WIDTH:0.419796 +G1 X14.065 Y1.158 E.01525 +;WIDTH:0.416749 +G1 X13.836 Y1.559 E.01408 +G1 X13.526 Y1.855 E.01305 +;WIDTH:0.411966 +G1 X13.162 Y2.046 E.01235 +;WIDTH:0.415851 +G1 X12.677 Y2.147 E.01507 +;WIDTH:0.418504 +G1 X12.13 Y2.108 E.01676 +G1 X11.919 Y2.046 E.00675 +;WIDTH:0.411768 +G1 X11.548 Y1.853 E.01258 +;WIDTH:0.415324 +G1 X11.243 Y1.56 E.01283 +;WIDTH:0.418832 +G1 X10.995 Y1.141 E.01491 +;WIDTH:0.428876 +G1 X10.834 Y.571 E.01865 +G1 X10.793 Y.069 E.01583 +;WIDTH:0.430921 +G1 X10.812 Y-.354 E.01339 +G1 X10.905 Y-.858 E.0162 +;WIDTH:0.427421 +G1 X11.126 Y-1.377 E.01768 +;WIDTH:0.431645 +G1 X11.375 Y-1.697 E.01286 +G1 X11.709 Y-1.959 E.01345 +;WIDTH:0.415476 +G1 X12.192 Y-2.123 E.01548 +;WIDTH:0.409751 +G1 X12.736 Y-2.131 E.01626 +;WIDTH:0.402683 +G1 X13.087 Y-2.061 E.01051 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X13.068 Y-1.692 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.40627 +G1 F12000 +G1 X13.344 Y-1.53 E.00947 +;WIDTH:0.4189 +G1 X13.625 Y-1.197 E.01335 +G1 X13.735 Y-.992 E.00714 +;WIDTH:0.421643 +G1 X13.848 Y-.621 E.01196 +G1 X13.907 Y0 E.01926 +;WIDTH:0.42109 +G1 X13.847 Y.633 E.0196 +;WIDTH:0.419796 +G1 X13.717 Y1.03 E.01281 +;WIDTH:0.417338 +G1 X13.538 Y1.332 E.01071 +G1 X13.301 Y1.562 E.01007 +;WIDTH:0.412765 +G1 X13.009 Y1.713 E.0099 +;WIDTH:0.409148 +G1 X12.513 Y1.784 E.01496 +;WIDTH:0.408746 +G1 X12.015 Y1.693 E.0151 +;WIDTH:0.414432 +G1 X11.738 Y1.534 E.00967 +;WIDTH:0.425836 +G1 X11.458 Y1.214 E.01326 +;WIDTH:0.426374 +G1 X11.306 Y.898 E.01097 +;WIDTH:0.428876 +G1 X11.213 Y.506 E.01265 +G1 X11.17 Y-.001 E.01599 +;WIDTH:0.422975 +G1 X11.233 Y-.604 E.01879 +;WIDTH:0.418883 +G1 X11.364 Y-1.007 E.01298 +;WIDTH:0.420306 +G1 X11.552 Y-1.327 E.01141 +G1 X11.894 Y-1.634 E.01414 +;WIDTH:0.415325 +G1 X12.229 Y-1.759 E.01086 +;WIDTH:0.409751 +G1 X12.74 Y-1.775 E.01527 +;WIDTH:0.403314 +G1 X13.029 Y-1.702 E.00877 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X13.344 Y-1.53 +G1 X13.625 Y-1.197 +G1 X13.735 Y-.992 +G1 X13.848 Y-.621 +G1 X13.904 Y-.038 +;WIPE_END +G1 X7.97 Y-4.839 Z1.8 F30000 +G1 X-2.238 Y-13.097 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.50557 +G1 F12000 +G1 X-2.235 Y-12.423 E.02541 +;WIDTH:0.504455 +G1 X-2.694 Y-12.415 E.01727 +;WIDTH:0.50557 +G1 X-2.703 Y-13.089 E.02542 +G1 X-2.278 Y-13.096 E.01603 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-2.235 Y-12.423 +G1 X-2.694 Y-12.415 +G1 X-2.703 Y-13.089 +G1 X-2.511 Y-13.092 +;WIPE_END +G1 X1.275 Y-10.176 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.542718 +G1 F12000 +G1 X1.366 Y-9.978 E.00885 +G1 X1.382 Y-9.998 E.00102 +;WIDTH:0.492854 +G1 X1.398 Y-10.017 E.00091 +;WIDTH:0.442989 +G1 X1.573 Y-9.874 E.00736 +;WIDTH:0.45571 +G1 X1.7 Y-9.71 E.00698 +;WIDTH:0.483115 +G1 X1.888 Y-9.243 E.01807 +;WIDTH:0.50097 +G1 X1.912 Y-8.724 E.01939 +G1 X1.826 Y-8.308 E.01585 +;WIDTH:0.487189 +G1 X1.661 Y-7.991 E.01294 +G1 X1.4 Y-7.736 E.01323 +;WIDTH:0.45838 +G1 X1.006 Y-7.534 E.01496 +G1 X.493 Y-7.462 E.01755 +;WIDTH:0.455853 +G1 X-.01 Y-7.528 E.01706 +;WIDTH:0.467481 +G1 X-.44 Y-7.739 E.01659 +;WIDTH:0.475513 +G1 X-.752 Y-8.07 E.016 +;WIDTH:0.485758 +G1 X-.91 Y-8.45 E.01489 +;WIDTH:0.494454 +G1 X-.958 Y-8.944 E.01823 +G1 X-.869 Y-9.424 E.01799 +;WIDTH:0.473703 +G1 X-.659 Y-9.804 E.01522 +;WIDTH:0.479337 +G1 X-.54 Y-9.928 E.00613 +;WIDTH:0.514069 +G1 X-.421 Y-10.053 E.00662 +;WIDTH:0.548801 +G1 X-.301 Y-10.177 E.00711 +G1 X-.424 Y-10.289 E.00682 +;WIDTH:0.512496 +G1 X-.546 Y-10.4 E.00633 +;WIDTH:0.47619 +G1 X-.668 Y-10.512 E.00584 +;WIDTH:0.470522 +G1 X-.885 Y-10.804 E.01268 +;WIDTH:0.474241 +G1 X-.991 Y-11.057 E.00965 +;WIDTH:0.490062 +G1 X-1.074 Y-11.553 E.01831 +;WIDTH:0.494341 +G1 X-1.026 Y-12.067 E.019 +G1 X-.837 Y-12.559 E.01938 +;WIDTH:0.490203 +G1 X-.566 Y-12.869 E.01503 +;WIDTH:0.470659 +G1 X-.196 Y-13.088 E.01497 +;WIDTH:0.456907 +G1 X.29 Y-13.193 E.01678 +G1 X.874 Y-13.172 E.01969 +;WIDTH:0.464113 +G1 X1.363 Y-12.997 E.01782 +;WIDTH:0.479949 +G1 X1.634 Y-12.787 E.01225 +;WIDTH:0.488568 +G1 X1.846 Y-12.49 E.01323 +;WIDTH:0.499748 +G1 X2.015 Y-11.966 E.0205 +G1 X2.023 Y-11.325 E.02389 +;WIDTH:0.483746 +G1 X1.894 Y-10.85 E.01765 +;WIDTH:0.467978 +G1 X1.647 Y-10.511 E.01455 +;WIDTH:0.496063 +G1 X1.461 Y-10.343 E.00924 +;WIDTH:0.542718 +G1 X1.305 Y-10.202 E.00856 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X.907 Y-10.406 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.532007 +G1 F12000 +G1 X.638 Y-10.398 E.01071 +;WIDTH:0.491779 +G1 X.369 Y-10.39 E.00983 +;WIDTH:0.490411 +G1 X.181 Y-10.413 E.00692 +;WIDTH:0.529272 +G1 X-.008 Y-10.435 E.00752 +;WIDTH:0.445066 +G1 X-.417 Y-10.828 E.0186 +;WIDTH:0.474241 +G1 X-.576 Y-11.162 E.01298 +;WIDTH:0.49403 +G1 X-.628 Y-11.607 E.01649 +;WIDTH:0.494341 +G1 X-.6 Y-11.918 E.01149 +G1 X-.41 Y-12.409 E.01937 +;WIDTH:0.478297 +G1 X-.185 Y-12.626 E.01107 +;WIDTH:0.468505 +G1 X.006 Y-12.724 E.00745 +;WIDTH:0.457315 +G1 X.504 Y-12.801 E.01703 +;WIDTH:0.448466 +G1 X.809 Y-12.774 E.01009 +;WIDTH:0.465434 +G1 X1.167 Y-12.628 E.01332 +;WIDTH:0.488568 +G1 X1.428 Y-12.336 E.01423 +;WIDTH:0.499748 +G1 X1.56 Y-11.933 E.01578 +G1 X1.587 Y-11.41 E.01948 +;WIDTH:0.487051 +G1 X1.494 Y-10.992 E.01551 +;WIDTH:0.467978 +G1 X1.232 Y-10.657 E.0147 +;WIDTH:0.457516 +G1 X1.124 Y-10.574 E.00463 +;WIDTH:0.494762 +G1 X1.015 Y-10.49 E.00505 +;WIDTH:0.532007 +G1 X.938 Y-10.431 E.00387 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.188 Y-9.797 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.542718 +G1 F12000 +G1 X1.171 Y-9.779 E.00102 +;WIDTH:0.492854 +G1 X1.154 Y-9.761 E.00092 +;WIDTH:0.456259 +G1 X1.344 Y-9.512 E.01055 +;WIDTH:0.483115 +G1 X1.451 Y-9.178 E.0126 +;WIDTH:0.50097 +G1 X1.457 Y-8.745 E.01617 +G1 X1.364 Y-8.345 E.01532 +;WIDTH:0.483787 +G1 X1.099 Y-8.024 E.01495 +;WIDTH:0.459997 +G1 X.871 Y-7.927 E.00841 +;WIDTH:0.45838 +G1 X.459 Y-7.864 E.01411 +;WIDTH:0.455853 +G1 X.077 Y-7.931 E.01306 +G1 X-.133 Y-8.023 E.0077 +;WIDTH:0.475772 +G1 X-.403 Y-8.328 E.01437 +;WIDTH:0.484956 +G1 X-.503 Y-8.664 E.01263 +G1 X-.489 Y-9.191 E.01897 +;WIDTH:0.468531 +G1 X-.341 Y-9.56 E.0138 +;WIDTH:0.479337 +G1 X-.219 Y-9.684 E.00618 +;WIDTH:0.514069 +G1 X-.098 Y-9.808 E.00668 +;WIDTH:0.548801 +G1 X.024 Y-9.932 E.00717 +G1 X.216 Y-9.957 E.00795 +;WIDTH:0.501387 +G1 X.407 Y-9.982 E.00721 +;WIDTH:0.498345 +G1 X.66 Y-9.969 E.00941 +;WIDTH:0.542718 +G1 X.913 Y-9.956 E.01033 +G1 X1.154 Y-9.817 E.01132 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X1.171 Y-9.779 +G1 X1.154 Y-9.761 +G1 X1.344 Y-9.512 +G1 X1.451 Y-9.178 +G1 X1.457 Y-8.745 +G1 X1.364 Y-8.345 +G1 X1.099 Y-8.024 +G1 X1.09 Y-8.021 +;WIPE_END +G1 X7.558 Y-12.073 Z1.8 F30000 +G1 X8.42 Y-12.613 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.484474 +G1 F12000 +G1 X8.199 Y-12.669 E.00819 +;WIDTH:0.482679 +G1 X7.188 Y-12.669 E.03622 +G1 X7.183 Y-13.109 E.01576 +G1 X8.199 Y-13.108 E.0364 +;WIDTH:0.519545 +G1 X8.346 Y-13.09 E.00576 +;WIDTH:0.556487 +G1 X8.493 Y-13.071 E.00621 +;WIDTH:0.593429 +G1 X8.64 Y-13.053 E.00665 +G1 X8.788 Y-13.071 E.00665 +;WIDTH:0.556459 +G1 X8.935 Y-13.09 E.00621 +;WIDTH:0.519488 +G1 X9.082 Y-13.108 E.00576 +;WIDTH:0.482517 +G1 X10.046 Y-13.108 E.03453 +;WIDTH:0.482445 +G1 X10.047 Y-12.669 E.01574 +G1 X9.082 Y-12.669 E.03456 +;WIDTH:0.519488 +G1 X9.008 Y-12.65 E.00295 +;WIDTH:0.556459 +G1 X8.935 Y-12.632 E.00318 +;WIDTH:0.593429 +G1 X8.861 Y-12.613 E.0034 +G1 X8.861 Y-12.558 E.00246 +;WIDTH:0.557111 +G1 X8.861 Y-12.504 E.0023 +;WIDTH:0.520793 +G1 X8.861 Y-12.449 E.00213 +;WIDTH:0.484474 +G1 X8.86 Y-8.324 E.14841 +;WIDTH:0.483999 +G1 X8.876 Y-7.927 E.01428 +;WIDTH:0.451999 +G1 X8.892 Y-7.53 E.01325 +;WIDTH:0.419999 +G1 X8.395 Y-7.532 E.01528 +G1 X8.255 Y-7.644 E.00551 +;WIDTH:0.429721 +G1 X7.828 Y-7.996 E.01744 +;WIDTH:0.435165 +G1 X7.301 Y-8.433 E.0219 +G1 X7.296 Y-8.945 E.01634 +G1 X8.074 Y-8.295 E.03241 +;WIDTH:0.429721 +G1 X8.297 Y-8.282 E.00703 +;WIDTH:0.451999 +G1 X8.358 Y-8.362 E.00337 +;WIDTH:0.483999 +G1 X8.419 Y-8.443 E.00363 +;WIDTH:0.484474 +G1 X8.42 Y-12.573 E.14862 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X8.199 Y-12.669 +G1 X7.188 Y-12.669 +G1 X7.183 Y-13.109 +G1 X7.492 Y-13.108 +;WIPE_END +G1 X8.552 Y-7.948 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.447808 +G1 F18000 +G1 X8.527 Y-7.905 E.00167 +G1 X8.476 Y-7.905 E.00167 +G1 X8.451 Y-7.948 E.00167 +G1 X8.476 Y-7.992 E.00167 +G1 X8.527 Y-7.992 E.00167 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X8.476 Y-7.992 +G1 X8.451 Y-7.948 +G1 X8.476 Y-7.905 +G1 X8.527 Y-7.905 +G1 X8.552 Y-7.948 +;WIPE_END +G1 X11.237 Y-13.087 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.505567 +G1 F12000 +G1 X11.702 Y-13.095 E.01753 +G1 X11.704 Y-12.421 E.0254 +;WIDTH:0.504453 +G1 X11.245 Y-12.413 E.01727 +;WIDTH:0.505567 +G1 X11.237 Y-13.047 E.02391 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P79 R1 +G1 E-.8 F2100 +M73 P79 R1 +;WIPE_START +G1 F12000 +G1 X11.702 Y-13.095 +G1 X11.704 Y-12.421 +G1 X11.245 Y-12.413 +G1 X11.24 Y-12.813 +;WIPE_END +G1 X17.083 Y-10.692 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.506372 +G1 F12000 +G1 X17.106 Y-10.6 E.00359 +;WIDTH:0.506582 +G1 X19.075 Y-10.6 E.07439 +G1 X19.098 Y-10.692 E.00357 +;WIDTH:0.46094 +G1 X19.12 Y-10.784 E.00322 +;WIDTH:0.415298 +G1 X19.128 Y-12.559 E.05387 +;WIDTH:0.410829 +G1 X19.495 Y-12.542 E.01102 +G1 X19.495 Y-12.366 E.00527 +;WIDTH:0.415298 +G1 X19.493 Y-10.783 E.04806 +;WIDTH:0.46094 +G1 X19.47 Y-10.599 E.00629 +;WIDTH:0.506582 +G1 X19.447 Y-10.416 E.00698 +G1 X19.47 Y-10.232 E.00702 +;WIDTH:0.460909 +G1 X19.493 Y-10.047 E.00633 +;WIDTH:0.415235 +G1 X19.493 Y-8.46 E.04815 +G1 X19.121 Y-8.447 E.0113 +G1 X19.121 Y-10.047 E.04854 +;WIDTH:0.460909 +G1 X19.098 Y-10.139 E.00324 +;WIDTH:0.506582 +G1 X19.075 Y-10.232 E.00359 +G1 X17.106 Y-10.231 E.07439 +;WIDTH:0.506372 +G1 X17.083 Y-10.14 E.00357 +;WIDTH:0.460724 +G1 X17.06 Y-10.048 E.00321 +;WIDTH:0.415075 +G1 X17.053 Y-8.444 E.04866 +;WIDTH:0.410875 +G1 X16.686 Y-8.457 E.01101 +G1 X16.686 Y-8.635 E.00532 +;WIDTH:0.415075 +G1 X16.688 Y-10.049 E.0429 +;WIDTH:0.460724 +G1 X16.711 Y-10.233 E.00629 +;WIDTH:0.506372 +G1 X16.734 Y-10.416 E.00697 +G1 X16.711 Y-10.6 E.00702 +;WIDTH:0.460637 +G1 X16.688 Y-10.785 E.00633 +;WIDTH:0.414902 +G1 X16.689 Y-12.544 E.05331 +;WIDTH:0.414683 +G1 X17.06 Y-12.557 E.01127 +;WIDTH:0.414902 +G1 X17.06 Y-10.785 E.05372 +;WIDTH:0.460637 +G1 X17.074 Y-10.731 E.00187 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X17.106 Y-10.6 +G1 X18.971 Y-10.6 +;WIPE_END +G1 X20.824 Y-9.599 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.406923 +G1 F12000 +G1 X20.839 Y-9.202 E.01177 +G1 X20.964 Y-8.97 E.00783 +;WIDTH:0.400331 +G1 X21.186 Y-8.812 E.00794 +;WIDTH:0.393166 +G1 X21.716 Y-8.714 E.01537 +;WIDTH:0.391872 +G1 X22.232 Y-8.789 E.01482 +;WIDTH:0.412924 +G1 X22.53 Y-8.998 E.01098 +;WIDTH:0.421748 +G1 X22.69 Y-9.306 E.01072 +G1 X23.059 Y-9.249 E.01152 +G1 X22.926 Y-8.912 E.01119 +;WIDTH:0.423605 +G1 X22.658 Y-8.61 E.01252 +G1 X22.429 Y-8.483 E.00813 +;WIDTH:0.409153 +G1 X21.921 Y-8.378 E.01547 +;WIDTH:0.395413 +G1 X21.554 Y-8.379 E.01056 +G1 X21.033 Y-8.5 E.01534 +;WIDTH:0.397405 +G1 X20.726 Y-8.703 E.01065 +;WIDTH:0.405258 +G1 X20.502 Y-9.046 E.0121 +;WIDTH:0.412327 +G1 X20.446 Y-9.526 E.01455 +;WIDTH:0.432276 +G1 X20.548 Y-9.91 E.01259 +G1 X20.798 Y-10.241 E.01319 +;WIDTH:0.434647 +G1 X21.245 Y-10.475 E.01608 +G1 X22.179 Y-10.748 E.03107 +;WIDTH:0.434311 +G1 X22.496 Y-10.895 E.01115 +G1 X22.691 Y-11.081 E.00859 +;WIDTH:0.42175 +G1 X22.774 Y-11.276 E.00653 +;WIDTH:0.412949 +G1 X22.758 Y-11.731 E.01374 +;WIDTH:0.411678 +G1 X22.612 Y-11.986 E.00881 +;WIDTH:0.401579 +G1 X22.381 Y-12.156 E.00839 +G1 X22.187 Y-12.221 E.00598 +;WIDTH:0.400145 +G1 X21.717 Y-12.273 E.01375 +;WIDTH:0.408168 +G1 X21.452 Y-12.241 E.00794 +;WIDTH:0.412297 +G1 X21.024 Y-12.107 E.01351 +;WIDTH:0.421048 +G1 X20.781 Y-11.869 E.01048 +G1 X20.652 Y-11.576 E.00985 +;WIDTH:0.419625 +G1 X20.294 Y-11.649 E.01122 +G1 X20.387 Y-11.933 E.00918 +;WIDTH:0.4163 +G1 X20.638 Y-12.283 E.01309 +G1 X21.015 Y-12.505 E.01332 +;WIDTH:0.406474 +G1 X21.542 Y-12.616 E.01595 +;WIDTH:0.395522 +G1 X22.02 Y-12.602 E.01375 +;WIDTH:0.394966 +G1 X22.416 Y-12.522 E.01159 +;WIDTH:0.403355 +G1 X22.755 Y-12.345 E.01122 +;WIDTH:0.407645 +G1 X23.022 Y-12.031 E.01224 +;WIDTH:0.416191 +G1 X23.135 Y-11.718 E.01012 +G1 X23.152 Y-11.363 E.01081 +;WIDTH:0.422899 +G1 X23.058 Y-10.942 E.01337 +;WIDTH:0.429722 +G1 X22.771 Y-10.61 E.01384 +;WIDTH:0.432952 +G1 X22.302 Y-10.378 E.01664 +;WIDTH:0.434647 +G1 X21.37 Y-10.103 E.03102 +G1 X21.031 Y-9.932 E.0121 +;WIDTH:0.430308 +G1 X20.845 Y-9.633 E.01112 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X20.839 Y-9.202 +G1 X20.964 Y-8.97 +G1 X21.186 Y-8.812 +G1 X21.716 Y-8.714 +G1 X22.205 Y-8.785 +;WIPE_END +G1 X25.819 Y-10.92 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.498525 +G1 F12000 +G1 X25.867 Y-10.92 E.00178 +;WIDTH:0.465108 +G1 X25.915 Y-10.92 E.00165 +;WIDTH:0.43169 +G1 X25.963 Y-10.919 E.00152 +;WIDTH:0.408914 +G1 X27.034 Y-10.907 E.03195 +G1 X27.457 Y-10.788 E.0131 +;WIDTH:0.406098 +G1 X27.728 Y-10.577 E.01016 +;WIDTH:0.411626 +G1 X27.889 Y-10.381 E.00761 +;WIDTH:0.414854 +G1 X28.049 Y-9.916 E.01491 +;WIDTH:0.41423 +G1 X28.037 Y-9.399 E.01566 +;WIDTH:0.423885 +G1 X27.923 Y-9.007 E.01268 +;WIDTH:0.424092 +G1 X27.644 Y-8.673 E.01349 +G1 X27.314 Y-8.508 E.01148 +;WIDTH:0.406748 +G1 X26.796 Y-8.436 E.0155 +;WIDTH:0.401192 +G1 X25.962 Y-8.436 E.02435 +;WIDTH:0.435092 +G1 X25.699 Y-8.453 E.0084 +;WIDTH:0.468991 +G1 X25.437 Y-8.47 E.00912 +G1 X25.423 Y-8.722 E.00874 +;WIDTH:0.440505 +G1 X25.408 Y-8.973 E.00816 +;WIDTH:0.413031 +G1 X25.406 Y-10.386 E.04262 +;WIDTH:0.455778 +G1 X25.428 Y-10.564 E.00602 +;WIDTH:0.498525 +G1 X25.449 Y-10.742 E.00664 +G1 X25.427 Y-10.919 E.00665 +;WIDTH:0.456033 +G1 X25.406 Y-11.097 E.00603 +;WIDTH:0.414454 +G1 X25.404 Y-12.558 E.04422 +G1 X25.776 Y-12.559 E.01125 +G1 X25.776 Y-11.097 E.04426 +;WIDTH:0.456033 +G1 X25.798 Y-11.008 E.00308 +;WIDTH:0.498525 +G1 X25.81 Y-10.958 E.00191 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X25.867 Y-10.92 +G1 X25.915 Y-10.92 +G1 X25.963 Y-10.919 +G1 X27.034 Y-10.907 +G1 X27.457 Y-10.788 +G1 X27.713 Y-10.589 +;WIPE_END +G1 X27.519 Y-9.072 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.407908 +G1 F12000 +G1 X27.213 Y-8.858 E.01111 +;WIDTH:0.406748 +G1 X26.796 Y-8.795 E.01251 +;WIDTH:0.401192 +G1 X25.962 Y-8.795 E.02435 +;WIDTH:0.435092 +G1 X25.884 Y-8.812 E.00255 +;WIDTH:0.468991 +G1 X25.806 Y-8.829 E.00277 +G1 X25.791 Y-8.901 E.00257 +;WIDTH:0.440505 +G1 X25.777 Y-8.974 E.0024 +;WIDTH:0.413031 +G1 X25.777 Y-10.386 E.04261 +;WIDTH:0.455778 +G1 X25.798 Y-10.475 E.00307 +;WIDTH:0.498525 +G1 X25.819 Y-10.564 E.00339 +G1 X26.151 Y-10.564 E.01234 +;WIDTH:0.464127 +G1 X26.484 Y-10.563 E.0114 +;WIDTH:0.429728 +G1 X26.816 Y-10.563 E.01047 +;WIDTH:0.407835 +G1 X27.283 Y-10.47 E.01415 +;WIDTH:0.411626 +G1 X27.555 Y-10.227 E.01098 +;WIDTH:0.414896 +G1 X27.68 Y-9.869 E.01148 +G1 X27.666 Y-9.401 E.01422 +;WIDTH:0.414203 +G1 X27.535 Y-9.109 E.00967 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X27.213 Y-8.858 +G1 X26.796 Y-8.795 +G1 X25.962 Y-8.795 +G1 X25.884 Y-8.812 +G1 X25.806 Y-8.829 +G1 X25.791 Y-8.901 +G1 X25.777 Y-8.974 +G1 X25.777 Y-9.002 +;WIPE_END +G1 X33.232 Y-10.641 Z1.8 F30000 +G1 X33.723 Y-10.749 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.394811 +G1 F12000 +G1 X33.274 Y-9.405 E.04063 +;WIDTH:0.419999 +G1 X33.111 Y-9.272 E.00646 +G1 X33.008 Y-9.273 E.00315 +G1 X32.846 Y-9.408 E.00648 +;WIDTH:0.397336 +G1 X32.398 Y-10.748 E.04083 +;WIDTH:0.446916 +G1 X32.391 Y-10.844 E.00315 +;WIDTH:0.496496 +G1 X32.384 Y-10.939 E.00353 +G1 X32.445 Y-10.953 E.0023 +;WIDTH:0.462728 +G1 X32.506 Y-10.966 E.00213 +;WIDTH:0.42896 +G1 X32.567 Y-10.98 E.00196 +;WIDTH:0.395192 +G1 X33.556 Y-10.98 E.0284 +;WIDTH:0.444815 +G1 X33.646 Y-10.96 E.00302 +;WIDTH:0.494437 +G1 X33.736 Y-10.94 E.0034 +G1 X33.73 Y-10.844 E.00353 +;WIDTH:0.444624 +G1 X33.726 Y-10.789 E.00183 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X33.852 Y-11.292 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.494437 +G1 F12000 +G1 X33.904 Y-11.372 E.00352 +;WIDTH:0.4474 +G1 X33.956 Y-11.452 E.00315 +;WIDTH:0.405913 +G1 X34.32 Y-12.537 E.03386 +G1 X34.714 Y-12.564 E.01168 +G1 X34.295 Y-11.338 E.03834 +;WIDTH:0.4474 +G1 X34.212 Y-11.17 E.00616 +;WIDTH:0.494437 +G1 X34.13 Y-11.003 E.00687 +G1 X34.093 Y-10.819 E.00688 +;WIDTH:0.444624 +G1 X34.056 Y-10.636 E.00612 +;WIDTH:0.394811 +G1 X33.602 Y-9.302 E.0404 +;WIDTH:0.419999 +G1 X33.29 Y-8.446 E.02801 +G1 X32.836 Y-8.447 E.01394 +G1 X32.524 Y-9.292 E.02769 +;WIDTH:0.397336 +G1 X32.063 Y-10.635 E.041 +;WIDTH:0.446916 +G1 X32.025 Y-10.817 E.00613 +;WIDTH:0.496496 +G1 X31.988 Y-11 E.00688 +G1 X31.906 Y-11.165 E.00684 +;WIDTH:0.449691 +G1 X31.823 Y-11.331 E.00613 +;WIDTH:0.408958 +G1 X31.501 Y-12.266 E.0295 +G1 X31.423 Y-12.303 E.00257 +;WIDTH:0.361242 +G1 X31.346 Y-12.34 E.00223 +;WIDTH:0.340693 +G1 X31.232 Y-12.32 E.00279 +;WIDTH:0.373062 +G1 X31.167 Y-12.263 E.00234 +;WIDTH:0.405431 +G1 X31.102 Y-12.205 E.00257 +G1 X31.009 Y-12.201 E.00273 +;WIDTH:0.405631 +G1 X29.449 Y-12.202 E.04613 +;WIDTH:0.439616 +G1 X29.37 Y-12.185 E.0026 +;WIDTH:0.473601 +G1 X29.292 Y-12.168 E.00282 +G1 X29.277 Y-12.094 E.00264 +;WIDTH:0.44442 +G1 X29.263 Y-12.02 E.00246 +;WIDTH:0.415238 +G1 X29.262 Y-8.445 E.10846 +;WIDTH:0.414687 +G1 X28.89 Y-8.443 E.01126 +G1 X28.89 Y-8.63 E.00566 +;WIDTH:0.415238 +G1 X28.89 Y-12.02 E.10285 +;WIDTH:0.44442 +G1 X28.905 Y-12.275 E.00836 +;WIDTH:0.473601 +G1 X28.92 Y-12.53 E.00897 +G1 X29.184 Y-12.547 E.00931 +;WIDTH:0.439616 +G1 X29.449 Y-12.564 E.00857 +;WIDTH:0.405631 +G1 X31.009 Y-12.564 E.04613 +;WIDTH:0.405431 +G1 X31.096 Y-12.573 E.00257 +;WIDTH:0.371767 +G1 X31.182 Y-12.582 E.00233 +;WIDTH:0.338102 +G1 X31.269 Y-12.592 E.00209 +;WIDTH:0.339907 +G1 X31.443 Y-12.579 E.00422 +;WIDTH:0.375377 +G1 X31.616 Y-12.566 E.00472 +;WIDTH:0.410847 +G1 X31.79 Y-12.553 E.00523 +G1 X31.848 Y-12.384 E.00534 +;WIDTH:0.408958 +G1 X32.163 Y-11.451 E.02937 +;WIDTH:0.449697 +G1 X32.215 Y-11.371 E.00318 +;WIDTH:0.496496 +G1 X32.268 Y-11.291 E.00354 +G1 X32.367 Y-11.305 E.00372 +;WIDTH:0.462728 +G1 X32.467 Y-11.319 E.00344 +;WIDTH:0.42896 +G1 X32.567 Y-11.332 E.00316 +;WIDTH:0.395192 +G1 X33.556 Y-11.332 E.0284 +;WIDTH:0.444815 +G1 X33.704 Y-11.312 E.00488 +;WIDTH:0.494437 +G1 X33.812 Y-11.298 E.00401 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X33.904 Y-11.372 +G1 X33.956 Y-11.452 +G1 X34.32 Y-12.537 +G1 X34.714 Y-12.564 +G1 X34.634 Y-12.331 +;WIPE_END +G1 X33.106 Y-8.907 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.395996 +G1 F18000 +G1 X33.084 Y-8.869 E.00127 +G1 X33.018 Y-8.907 E.0022 +G1 X33.084 Y-8.945 E.0022 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X33.018 Y-8.907 +G1 X33.084 Y-8.869 +G1 X33.106 Y-8.907 +;WIPE_END +G1 X25.721 Y-6.982 Z1.8 F30000 +G1 X-36.005 Y9.111 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.488707 +G1 F16824.178 +G1 X-36.026 Y9.203 E.00342 +;WIDTH:0.525959 +G1 F15526.696 +G1 X-36.14 Y9.993 E.03143 +G1 X-36.14 Y10.599 E.02386 +G1 X-36.508 Y10.197 E.02145 +G1 X-36.854 Y10.004 E.0156 +G1 X-36.571 Y9.83 E.01306 +G1 X-36.233 Y9.531 E.01779 +G1 X-36.024 Y9.146 E.01722 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15526.696 +G1 X-36.026 Y9.203 +G1 X-36.14 Y9.993 +G1 X-36.14 Y10.599 +G1 X-36.503 Y10.202 +;WIPE_END +G1 X-29.22 Y12.483 Z1.8 F30000 +G1 X-26.5 Y13.334 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-26.466 Y13.465 E.00448 +G1 X-26.117 Y14.297 E.02992 +G1 X-25.573 Y15.016 E.02992 +G1 X-24.866 Y15.577 E.02992 +G1 X-24.043 Y15.944 E.02992 +G1 X-23.54 Y16.04 E.01696 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.453 Y16.078 E.00355 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.365 Y16.116 E.00392 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.277 Y16.154 E.00429 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.192 E.00467 +G1 X32.757 Y16.192 E2.73304 +G1 X33.602 Y16.029 E.04204 +;WIDTH:0.655273 +G1 F12247.837 +G1 X34.067 Y15.831 E.02524 +M73 P80 R1 +G1 X34.551 Y15.573 E.02734 +M73 P80 R1 +;WIDTH:0.643373 +G1 F12490.569 +G1 X35.134 Y15.09 E.03706 +G1 X35.466 Y14.707 E.0248 +;WIDTH:0.655257 +G1 F12248.158 +G1 X35.726 Y14.273 E.02522 +G1 X35.965 Y13.781 E.02734 +;WIDTH:0.645088 +G1 F12454.995 +G1 X36.141 Y13.1 E.03448 +G1 X36.196 Y12.577 E.02582 +G1 X36.357 Y12.748 E.01153 +G1 X36.765 Y13.039 E.0246 +;WIDTH:0.655273 +G1 F12247.837 +G1 X36.653 Y13.586 E.02785 +G1 X36.49 Y14.1 E.0269 +G1 X36.252 Y14.585 E.02694 +G1 X35.967 Y15.042 E.0269 +;WIDTH:0.643372 +G1 F12490.59 +G1 X35.621 Y15.456 E.02641 +;WIDTH:0.655273 +G1 F12247.837 +G1 X35.221 Y15.82 E.02694 +G1 X34.787 Y16.14 E.0269 +G1 X34.31 Y16.392 E.02694 +G1 X33.81 Y16.594 E.0269 +G1 X33.285 Y16.72 E.02694 +G1 X32.69 Y16.792 E.02994 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.792 E2.72975 +G1 X-23.297 Y16.816 E.00539 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.405 Y16.84 E.00496 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.513 Y16.864 E.00453 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.62 Y16.888 E.00409 +;WIDTH:0.449999 +G1 F18000 +G1 X-32.776 Y16.888 E.30371 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.372 Y16.478 E.01832 +G1 X-34.856 Y16.212 E.01832 +G1 X-35.303 Y15.888 E.01832 +G1 X-35.705 Y15.51 E.01832 +G1 X-36.041 Y15.104 E.01746 +G1 X-36.307 Y14.691 E.0163 +G1 X-36.54 Y14.221 E.0174 +;WIDTH:0.498867 +G1 F16449.307 +G1 X-36.555 Y14.108 E.00425 +;WIDTH:0.547734 +G1 F14856.972 +G1 X-36.57 Y13.994 E.00471 +;WIDTH:0.596601 +G1 F13545.712 +G1 X-36.584 Y13.881 E.00517 +;WIDTH:0.645468 +G1 F12447.141 +G1 X-36.599 Y13.767 E.00562 +G1 X-36.668 Y13.551 E.01115 +;WIDTH:0.649626 +G1 F12361.835 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.196 Y12.588 E.02644 +G1 X-36.07 Y13.461 E.0436 +;WIDTH:0.642407 +G1 F12510.696 +G1 X-35.996 Y13.582 E.00696 +;WIDTH:0.593296 +G1 F13627.048 +G1 X-35.922 Y13.704 E.00639 +;WIDTH:0.544185 +G1 F14962.147 +G1 X-35.848 Y13.826 E.00582 +;WIDTH:0.495074 +G1 F16587.268 +G1 X-35.688 Y14.169 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-35.395 Y14.603 E.01736 +G1 X-35.073 Y15.016 E.01736 +G1 X-34.489 Y15.499 E.02512 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.75 Y16.096 E.09751 +G1 X-28.819 Y15.899 E.03157 +G1 X-28.011 Y15.499 E.02992 +G1 X-27.328 Y14.91 E.02992 +G1 X-26.812 Y14.169 E.02992 +G1 X-26.514 Y13.372 E.02825 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-26.466 Y13.465 +G1 X-26.117 Y14.297 +G1 X-25.573 Y15.016 +G1 X-25.501 Y15.073 +;WIPE_END +G1 X-18.765 Y11.484 Z1.8 F30000 +G1 X12.682 Y-5.265 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X13.489 Y-5.668 E.02993 +G1 X14.073 Y-6.151 E.02511 +G1 X14.361 Y-6.517 E.01545 +G1 X14.688 Y-6.997 E.01927 +G1 X15 Y-7.832 E.02957 +G1 X15.034 Y-7.702 E.00448 +G1 X15.383 Y-6.87 E.02992 +G1 X15.927 Y-6.151 E.02992 +G1 X16.511 Y-5.668 E.02512 +G1 X16.934 Y-5.424 E.01621 +G1 X17.396 Y-5.263 E.01623 +G1 X13.44 Y-5.263 E.13125 +G1 X12.722 Y-5.265 E.02381 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X13.489 Y-5.668 +G1 X14.073 Y-6.151 +G1 X14.306 Y-6.446 +;WIPE_END +G1 X6.941 Y-4.441 Z1.8 F30000 +G1 X-33.249 Y6.5 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X-33.681 Y6.601 E.01472 +G1 X-34.489 Y7.001 E.02992 +G1 X-35.172 Y7.59 E.02992 +G1 X-35.551 Y8.112 E.02138 +G1 X-35.719 Y8.409 E.01133 +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.02241 +G1 X-35.907 Y8.609 E.01366 +;WIDTH:0.451257 +G1 F18000 +G1 X-35.924 Y8.308 E.01001 +;WIDTH:0.449999 +G1 X-36.06 Y7.814 E.01701 +G1 X-36.331 Y7.404 E.01631 +G1 X-36.724 Y7.106 E.01636 +G1 X-36.898 Y7.027 E.00633 +G1 X-36.898 Y-12.481 E.64712 +G1 X-36.862 Y-13.051 E.01894 +G1 X-36.759 Y-13.593 E.01832 +G1 X-36.588 Y-14.119 E.01832 +G1 X-36.353 Y-14.618 E.01832 +G1 X-36.057 Y-15.084 E.01832 +G1 X-35.705 Y-15.51 E.01832 +G1 X-35.303 Y-15.888 E.01831 +G1 X-34.856 Y-16.212 E.01832 +G1 X-34.372 Y-16.478 E.01832 +G1 X-33.859 Y-16.682 E.01832 +G1 X-33.324 Y-16.819 E.01832 +G1 X-32.776 Y-16.888 E.01832 +G1 X32.776 Y-16.888 E2.17448 +G1 X33.324 Y-16.819 E.01832 +G1 X33.859 Y-16.682 E.01832 +G1 X34.372 Y-16.478 E.01832 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01832 +G1 X36.057 Y-15.084 E.01832 +G1 X36.353 Y-14.618 E.01831 +G1 X36.588 Y-14.119 E.01832 +G1 X36.759 Y-13.593 E.01832 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y-2.097 E.34447 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.874 Y-1.989 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.849 Y-1.882 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.825 Y-1.775 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X36.801 Y-1.667 E.00539 +G1 X36.801 Y6.966 E.42312 +G1 X36.357 Y7.252 E.02587 +G1 X36.199 Y7.42 E.01131 +G1 X36.199 Y-1.667 E.44539 +G1 X36.164 Y-1.761 E.00492 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.129 Y-1.856 E.00452 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.094 Y-1.95 E.00413 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.059 Y-2.044 E.00373 +;WIDTH:0.449999 +G1 F18000 +G1 X35.966 Y-2.632 E.01975 +G1 X35.617 Y-3.464 E.02992 +G1 X35.172 Y-4.077 E.02513 +G1 X34.976 Y-4.262 E.00896 +G1 X34.366 Y-4.743 E.02576 +G1 X33.681 Y-5.066 E.02512 +G1 X33.249 Y-5.167 E.01472 +G1 X33.681 Y-5.268 E.01472 +G1 X34.489 Y-5.668 E.02992 +G1 X35.172 Y-6.257 E.02992 +G1 X35.688 Y-6.997 E.02992 +G1 X36.003 Y-7.842 E.02992 +G1 X36.102 Y-8.666 E.02752 +G1 X36.102 Y-12.469 E.12614 +G1 X36.05 Y-12.948 E.01601 +G1 X35.966 Y-13.465 E.01736 +G1 X35.688 Y-14.169 E.02512 +G1 X35.395 Y-14.603 E.01736 +G1 X35.073 Y-15.016 E.01736 +G1 X34.43 Y-15.541 E.02754 +G1 X33.966 Y-15.786 E.01742 +G1 X33.211 Y-16.03 E.02632 +G1 X32.69 Y-16.096 E.01741 +G1 X18.25 Y-16.096 E.47899 +G1 X17.319 Y-15.899 E.03157 +G1 X16.511 Y-15.499 E.02992 +G1 X15.828 Y-14.91 E.02992 +G1 X15.312 Y-14.169 E.02993 +G1 X15 Y-13.334 E.02956 +G1 X14.966 Y-13.465 E.00448 +G1 X14.617 Y-14.297 E.02992 +G1 X14.073 Y-15.016 E.02992 +G1 X13.366 Y-15.576 E.02992 +G1 X12.542 Y-15.944 E.02992 +G1 X11.69 Y-16.096 E.02874 +G1 X-32.69 Y-16.096 E1.47214 +G1 X-33.171 Y-16.015 E.01617 +G1 X-33.681 Y-15.899 E.01736 +G1 X-34.366 Y-15.576 E.02512 +G1 X-34.781 Y-15.257 E.01736 +G1 X-35.172 Y-14.91 E.01736 +G1 X-35.617 Y-14.297 E.02512 +G1 X-35.827 Y-13.817 E.01736 +G1 X-36.003 Y-13.324 E.01736 +G1 X-36.102 Y-12.469 E.02857 +G1 X-36.102 Y-8.624 E.12752 +G1 X-35.966 Y-7.702 E.03094 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.172 Y-6.257 E.02512 +G1 X-34.976 Y-6.071 E.00897 +G1 X-34.366 Y-5.59 E.02576 +G1 X-33.681 Y-5.268 E.02512 +G1 X-33.249 Y-5.167 E.01472 +G1 X-33.681 Y-5.066 E.01472 +G1 X-34.489 Y-4.665 E.02992 +G1 X-35.073 Y-4.182 E.02512 +G1 X-35.453 Y-3.722 E.01982 +G1 X-35.872 Y-2.923 E.02991 +G1 X-36.06 Y-2.19 E.02512 +G1 X-36.102 Y-1.667 E.01739 +G1 X-36.102 Y3.042 E.15623 +G1 X-35.966 Y3.965 E.03094 +G1 X-35.617 Y4.797 E.02992 +G1 X-35.172 Y5.41 E.02512 +G1 X-34.932 Y5.632 E.01087 +G1 X-34.366 Y6.076 E.02386 +G1 X-33.681 Y6.399 E.02512 +G1 X-33.288 Y6.491 E.0134 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +M73 P81 R1 +G1 F18000 +M73 P81 R1 +G1 X-33.681 Y6.601 +G1 X-34.489 Y7.001 +G1 X-35.011 Y7.452 +;WIPE_END +G1 X-27.382 Y7.66 Z1.8 F30000 +G1 X36.142 Y9.392 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.530593 +G1 F15379.158 +G1 X36.391 Y9.702 E.01577 +G1 X36.842 Y10 E.0215 +G1 X36.391 Y10.298 E.0215 +G1 X36.142 Y10.608 E.01577 +G1 X36.142 Y9.432 E.04672 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.242 Y8.459 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.525695 +G1 F12000 +G1 X36.282 Y8.607 E.00604 +;WIDTH:0.491265 +G1 X36.323 Y8.759 E.00575 +;WIDTH:0.456835 +G1 X36.364 Y8.911 E.00531 +;WIDTH:0.422404 +G1 X36.596 Y9.3 E.01402 +;WIDTH:0.419999 +G1 X36.955 Y9.577 E.01393 +G1 X37.29 Y9.669 E.01067 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01408 +G1 X36.391 Y11.024 E.01393 +;WIDTH:0.455231 +G1 X36.341 Y11.167 E.00511 +;WIDTH:0.490463 +G1 X36.291 Y11.311 E.00555 +;WIDTH:0.525694 +G1 X36.241 Y11.455 E.00598 +G1 X36.282 Y11.607 E.00619 +;WIDTH:0.491264 +G1 X36.323 Y11.759 E.00575 +;WIDTH:0.456834 +G1 X36.364 Y11.911 E.00531 +;WIDTH:0.422404 +G1 X36.588 Y12.287 E.01355 +;WIDTH:0.419999 +G1 X36.955 Y12.577 E.01437 +G1 X37.279 Y12.666 E.01033 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.553 Y16.19 E.01848 +G1 X-35.991 Y15.779 E.01848 +G1 X-36.375 Y15.315 E.01848 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.253 E.0171 +G1 X-37.279 Y12.666 E.01811 +G1 X-37.024 Y12.609 E.00803 +G1 X-36.645 Y12.352 E.01408 +G1 X-36.544 Y12.23 E.00486 +;WIDTH:0.465083 +G1 X-36.404 Y11.965 E.01031 +;WIDTH:0.510166 +G1 X-36.264 Y11.7 E.01141 +;WIDTH:0.525402 +G1 X-36.245 Y11.372 E.01291 +G1 X-36.305 Y11.231 E.00601 +;WIDTH:0.4915 +G1 X-36.364 Y11.09 E.00559 +;WIDTH:0.457598 +G1 X-36.424 Y10.949 E.00517 +;WIDTH:0.423695 +G1 X-36.703 Y10.593 E.01404 +;WIDTH:0.419999 +G1 X-37.07 Y10.376 E.01311 +G1 X-37.29 Y10.321 E.00696 +G1 X-37.29 Y9.669 E.02005 +G1 X-37.024 Y9.609 E.00837 +G1 X-36.645 Y9.352 E.01408 +G1 X-36.391 Y8.976 E.01393 +G1 X-36.293 Y8.535 E.01388 +G1 X-36.365 Y8.089 E.01388 +G1 X-36.596 Y7.7 E.01392 +G1 X-36.955 Y7.423 E.01392 +G1 X-37.29 Y7.332 E.01068 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.397 Y-17.205 E.01848 +G1 X33.98 Y-17.055 E.01848 +G1 X34.539 Y-16.834 E.01848 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.697 Y-14.807 E.01848 +G1 X36.953 Y-14.263 E.01848 +G1 X37.139 Y-13.691 E.01848 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.331 E.60916 +G1 X37.024 Y7.391 E.00837 +G1 X36.645 Y7.648 E.01407 +G1 X36.391 Y8.023 E.01392 +;WIDTH:0.455231 +G1 X36.341 Y8.167 E.00512 +;WIDTH:0.490463 +G1 X36.291 Y8.311 E.00555 +;WIDTH:0.525695 +G1 X36.253 Y8.421 E.00457 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.282 Y8.607 +G1 X36.323 Y8.759 +G1 X36.364 Y8.911 +G1 X36.596 Y9.3 +G1 X36.955 Y9.577 +G1 X37.29 Y9.669 +G1 X37.29 Y9.911 +;WIPE_END +G1 X29.659 Y9.778 Z1.8 F30000 +G1 X-26.247 Y8.801 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.495492 +G1 F12000 +G1 X-26.245 Y8.788 E.00049 +;WIDTH:0.457746 +G1 X-26.232 Y8.656 E.00447 +;WIDTH:0.419999 +G1 X-26.407 Y7.974 E.02166 +G1 X-26.747 Y7.355 E.02167 +G1 X-27.047 Y7.025 E.01372 +G1 X-27.506 Y6.646 E.01829 +G1 X-28.138 Y6.348 E.02148 +G1 X-28.831 Y6.216 E.02167 +;WIDTH:0.454327 +G1 X-29.115 Y6.232 E.00953 +;WIDTH:0.488655 +G1 X-29.399 Y6.248 E.01033 +;WIDTH:0.522983 +G1 X-29.683 Y6.265 E.01112 +;WIDTH:0.527381 +G1 X-32.689 Y6.258 E.11868 +G1 X-32.949 Y6.191 E.01059 +;WIDTH:0.491587 +G1 X-33.209 Y6.125 E.00981 +;WIDTH:0.455793 +G1 X-33.469 Y6.059 E.00903 +;WIDTH:0.419999 +G1 X-34.199 Y5.722 E.02471 +G1 X-34.855 Y5.179 E.02615 +G1 X-35.3 Y4.566 E.02327 +G1 X-35.602 Y3.821 E.02471 +G1 X-35.71 Y3.013 E.02504 +G1 X-35.71 Y-1.667 E.14382 +G1 X-35.68 Y-2.092 E.01309 +G1 X-35.492 Y-2.826 E.02327 +G1 X-35.11 Y-3.533 E.0247 +G1 X-34.726 Y-3.96 E.01764 +G1 X-34.239 Y-4.363 E.01942 +G1 X-33.514 Y-4.711 E.02471 +;WIDTH:0.455793 +G1 X-33.232 Y-4.782 E.00979 +;WIDTH:0.491587 +G1 X-32.95 Y-4.853 E.01064 +;WIDTH:0.527381 +G1 X-32.667 Y-4.924 E.01149 +G1 X11.69 Y-4.924 E1.75111 +G1 X11.973 Y-4.907 E.01119 +;WIDTH:0.491587 +G1 X12.256 Y-4.889 E.01037 +;WIDTH:0.455793 +G1 X12.539 Y-4.871 E.00954 +;WIDTH:0.419999 +G1 X17.016 Y-4.871 E.13757 +;WIDTH:0.453559 +G1 X17.268 Y-4.888 E.00846 +;WIDTH:0.487118 +G1 X17.521 Y-4.904 E.00915 +;WIDTH:0.520677 +G1 X17.773 Y-4.921 E.00985 +;WIDTH:0.527381 +G1 X32.69 Y-4.924 E.58888 +G1 X32.949 Y-4.858 E.01058 +;WIDTH:0.491587 +G1 X33.209 Y-4.792 E.0098 +;WIDTH:0.455793 +G1 X33.469 Y-4.725 E.00902 +;WIDTH:0.419999 +G1 X34.199 Y-4.388 E.02471 +G1 X34.855 Y-3.846 E.02614 +G1 X35.3 Y-3.233 E.02328 +G1 X35.602 Y-2.487 E.02471 +G1 X35.71 Y-1.68 E.02504 +G1 X35.71 Y7.825 E.29207 +;WIDTH:0.455231 +G1 X35.728 Y8.02 E.00657 +;WIDTH:0.490463 +G1 X35.745 Y8.215 E.00713 +;WIDTH:0.525695 +G1 X35.763 Y8.41 E.00769 +G1 X35.746 Y8.597 E.00741 +;WIDTH:0.491265 +G1 X35.728 Y8.785 E.00688 +;WIDTH:0.456835 +G1 X35.711 Y8.972 E.00635 +;WIDTH:0.422404 +G1 X35.71 Y9.557 E.01809 +;WIDTH:0.419999 +G1 X35.71 Y10.826 E.03898 +;WIDTH:0.455231 +G1 X35.728 Y11.021 E.00656 +;WIDTH:0.490463 +G1 X35.745 Y11.215 E.00712 +;WIDTH:0.525694 +G1 X35.763 Y11.41 E.00769 +G1 X35.746 Y11.597 E.00741 +;WIDTH:0.491264 +G1 X35.728 Y11.785 E.00688 +;WIDTH:0.456834 +G1 X35.711 Y11.972 E.00635 +;WIDTH:0.422404 +G1 X35.71 Y12.501 E.01634 +;WIDTH:0.419999 +G1 X35.667 Y12.978 E.01474 +G1 X35.492 Y13.659 E.02159 +G1 X35.11 Y14.367 E.0247 +G1 X34.823 Y14.714 E.01384 +G1 X34.239 Y15.196 E.02327 +G1 X33.514 Y15.544 E.02471 +G1 X32.708 Y15.704 E.02524 +G1 X-23.19 Y15.704 E1.71758 +G1 X-23.969 Y15.559 E.02436 +G1 X-24.699 Y15.222 E.02471 +G1 X-25.323 Y14.714 E.02471 +G1 X-25.8 Y14.066 E.02471 +G1 X-26.102 Y13.321 E.02471 +;WIDTH:0.45382 +G1 X-26.155 Y13.052 E.00918 +;WIDTH:0.48764 +G1 X-26.208 Y12.783 E.00994 +;WIDTH:0.52146 +G1 X-26.261 Y12.513 E.0107 +G1 X-26.261 Y9.999 E.09804 +;WIDTH:0.570985 +G1 X-26.285 Y9.182 E.03517 +G1 X-26.272 Y9.051 E.00569 +;WIDTH:0.533239 +G1 X-26.259 Y8.919 E.00528 +;WIDTH:0.495492 +G1 X-26.251 Y8.841 E.00291 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.245 Y8.788 +G1 X-26.232 Y8.656 +G1 X-26.407 Y7.974 +G1 X-26.747 Y7.355 +G1 X-27.019 Y7.056 +;WIPE_END +G1 X-30.709 Y.375 Z1.8 F30000 +G1 X-34.199 Y-5.945 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.419999 +G1 F12000 +M73 P82 R1 +G1 X-34.855 Y-6.487 E.02615 +M73 P82 R1 +G1 X-35.3 Y-7.1 E.02327 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.614 Y-13.275 E.02496 +G1 X-35.323 Y-14.025 E.02471 +G1 X-34.855 Y-14.679 E.02471 +G1 X-34.239 Y-15.196 E.02471 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02524 +G1 X11.69 Y-15.704 E1.36422 +G1 X12.469 Y-15.559 E.02436 +G1 X13.199 Y-15.222 E.02471 +G1 X13.823 Y-14.714 E.02471 +G1 X14.3 Y-14.066 E.02471 +G1 X14.602 Y-13.321 E.02471 +;WIDTH:0.45382 +G1 X14.655 Y-13.058 E.00899 +;WIDTH:0.48764 +G1 X14.708 Y-12.794 E.00973 +;WIDTH:0.52146 +G1 X14.761 Y-12.531 E.01047 +G1 X14.761 Y-8.666 E.15071 +G1 X14.712 Y-8.408 E.01025 +;WIDTH:0.48764 +G1 X14.663 Y-8.15 E.00952 +;WIDTH:0.45382 +G1 X14.614 Y-7.892 E.0088 +;WIDTH:0.419999 +G1 X14.323 Y-7.142 E.02471 +G1 X13.855 Y-6.487 E.02471 +G1 X13.239 Y-5.97 E.0247 +G1 X12.514 Y-5.622 E.02472 +;WIDTH:0.455793 +G1 X12.25 Y-5.551 E.0092 +;WIDTH:0.491587 +G1 X11.986 Y-5.48 E.00999 +;WIDTH:0.527381 +G1 X11.722 Y-5.409 E.01079 +G1 X-32.69 Y-5.409 E1.75327 +G1 X-32.949 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X-33.209 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X-33.469 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X-34.163 Y-5.928 E.02348 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-34.855 Y-6.487 +G1 X-35.3 Y-7.1 +G1 X-35.433 Y-7.427 +;WIPE_END +G1 X-27.833 Y-8.14 Z1.8 F30000 +G1 X35.3 Y-14.066 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X35.602 Y-13.321 E.02471 +G1 X35.71 Y-12.513 E.02504 +G1 X35.71 Y-8.666 E.11821 +G1 X35.614 Y-7.892 E.02398 +G1 X35.323 Y-7.142 E.02471 +G1 X34.855 Y-6.487 E.02471 +G1 X34.239 Y-5.97 E.02471 +G1 X33.514 Y-5.622 E.02471 +;WIDTH:0.455793 +G1 X33.241 Y-5.551 E.00949 +;WIDTH:0.491587 +G1 X32.968 Y-5.48 E.01031 +;WIDTH:0.527381 +G1 X32.695 Y-5.409 E.01113 +G1 X18.31 Y-5.409 E.56788 +G1 X17.86 Y-5.471 E.01795 +;WIDTH:0.520677 +G1 X17.625 Y-5.565 E.00985 +;WIDTH:0.487118 +G1 X17.39 Y-5.659 E.00915 +;WIDTH:0.453559 +G1 X17.156 Y-5.753 E.00846 +;WIDTH:0.419999 +G1 X16.761 Y-5.97 E.01384 +G1 X16.177 Y-6.453 E.02327 +G1 X15.7 Y-7.1 E.02471 +G1 X15.398 Y-7.846 E.02471 +;WIDTH:0.45382 +G1 X15.345 Y-8.115 E.00918 +;WIDTH:0.48764 +G1 X15.292 Y-8.384 E.00994 +;WIDTH:0.52146 +G1 X15.239 Y-8.653 E.0107 +G1 X15.239 Y-12.501 E.15003 +G1 X15.288 Y-12.759 E.01025 +;WIDTH:0.48764 +G1 X15.337 Y-13.017 E.00952 +;WIDTH:0.45382 +G1 X15.386 Y-13.275 E.0088 +;WIDTH:0.419999 +G1 X15.677 Y-14.025 E.0247 +G1 X16.145 Y-14.679 E.02472 +G1 X16.761 Y-15.196 E.02471 +G1 X17.486 Y-15.544 E.02471 +G1 X18.293 Y-15.704 E.02527 +G1 X32.69 Y-15.704 E.44238 +G1 X33.124 Y-15.647 E.01347 +G1 X33.844 Y-15.413 E.02326 +G1 X34.239 Y-15.196 E.01384 +G1 X34.823 Y-14.714 E.02327 +G1 X35.277 Y-14.099 E.02348 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X35.602 Y-13.321 +G1 X35.71 Y-12.513 +G1 X35.71 Y-12.171 +;WIPE_END +G1 X28.488 Y-9.701 Z1.8 F30000 +G1 X-26.811 Y9.215 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.570985 +G1 F12000 +G1 X-26.739 Y9.979 E.03301 +;WIDTH:0.52146 +G1 X-26.739 Y12.501 E.09832 +G1 X-26.788 Y12.759 E.01025 +;WIDTH:0.48764 +G1 X-26.837 Y13.017 E.00952 +;WIDTH:0.45382 +G1 X-26.886 Y13.275 E.0088 +;WIDTH:0.419999 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.645 Y14.679 E.02471 +G1 X-28.261 Y15.196 E.02471 +G1 X-28.986 Y15.544 E.02471 +G1 X-29.792 Y15.704 E.02524 +G1 X-32.69 Y15.704 E.08905 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.823 Y14.714 E.02471 +G1 X-35.3 Y14.066 E.02471 +G1 X-35.577 Y13.383 E.02266 +G1 X-35.677 Y12.771 E.01906 +G1 X-35.71 Y12.164 E.01868 +;WIDTH:0.455134 +G1 X-35.728 Y11.985 E.00603 +;WIDTH:0.490268 +G1 X-35.745 Y11.806 E.00655 +;WIDTH:0.525402 +G1 X-35.763 Y11.628 E.00706 +;WIDTH:0.537504 +G1 X-35.769 Y11.219 E.01646 +G1 X-35.749 Y11.019 E.00811 +;WIDTH:0.498336 +G1 X-35.73 Y10.819 E.00747 +;WIDTH:0.459168 +G1 X-35.71 Y10.619 E.00682 +;WIDTH:0.419999 +G1 X-35.71 Y9.997 E.01908 +G1 X-35.617 Y9.255 E.023 +G1 X-35.444 Y8.782 E.01547 +G1 X-35.234 Y8.342 E.01497 +G1 X-34.855 Y7.821 E.01981 +G1 X-34.239 Y7.304 E.02471 +G1 X-33.514 Y6.956 E.02471 +;WIDTH:0.455793 +G1 X-33.241 Y6.885 E.00949 +;WIDTH:0.491587 +G1 X-32.968 Y6.813 E.01031 +;WIDTH:0.527381 +G1 X-32.695 Y6.742 E.01113 +G1 X-29.81 Y6.744 E.11389 +;WIDTH:0.522983 +G1 X-29.551 Y6.81 E.01048 +;WIDTH:0.488655 +G1 X-29.291 Y6.876 E.00973 +;WIDTH:0.454327 +G1 X-29.031 Y6.941 E.00898 +;WIDTH:0.419999 +G1 X-28.261 Y7.304 E.02615 +G1 X-27.677 Y7.786 E.02327 +G1 X-27.213 Y8.416 E.02404 +;WIDTH:0.464159 +G1 X-27.04 Y8.775 E.01369 +;WIDTH:0.508318 +G1 X-26.868 Y9.135 E.01512 +;WIDTH:0.539652 +G1 X-26.84 Y9.175 E.00199 +;WIDTH:0.570985 +G1 X-26.835 Y9.183 E.00039 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.739 Y9.979 +G1 X-26.739 Y11.177 +;WIPE_END +G1 X-34.189 Y9.516 Z1.8 F30000 +G1 X-36.005 Y9.111 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.00467 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F17111.567 +G1 X-36.005 Y9.111 +;WIPE_END +G1 X-28.868 Y6.595 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.433748 +G1 F18000 +G1 X-28.823 Y6.608 E.0015 +;WIDTH:0.4814 +G1 F17104.567 +G1 X-28.642 Y6.667 E.00679 +;WIDTH:0.529051 +G1 F15427.952 +G1 X-28.461 Y6.726 E.00753 +;WIDTH:0.576702 +G1 F14050.68 +G1 X-28.28 Y6.785 E.00827 +;WIDTH:0.615368 +G1 F13101.627 +G1 X-28.026 Y6.925 E.01353 +;WIDTH:0.654034 +G1 F12272.669 +G1 X-27.773 Y7.066 E.01445 +G1 X-27.362 Y7.406 E.02653 +;WIDTH:0.663646 +G1 F12082.626 +G1 X-27.162 Y7.637 E.0155 +G1 X-26.996 Y7.901 E.01576 +;WIDTH:0.621724 +G1 F12957.754 +G1 X-26.83 Y8.165 E.0147 +;WIDTH:0.579802 +G1 F13969.551 +G1 X-26.734 Y8.443 E.01286 +;WIDTH:0.532759 +G1 F15311.153 +G1 X-26.639 Y8.721 E.01173 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15311.153 +G1 X-26.734 Y8.443 +G1 X-26.83 Y8.165 +G1 X-26.996 Y7.901 +G1 X-27.162 Y7.637 +G1 X-27.362 Y7.406 +G1 X-27.734 Y7.098 +;WIPE_END +G1 X-35.075 Y6.508 Z1.8 F30000 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.419999 +G1 F15000 +G1 X-35.709 Y5.956 E.02584 +G1 X-36.129 Y5.376 E.02199 +G1 X-36.129 Y6.593 E.0374 +G1 X-35.713 Y6.95 E.01683 +G1 X-35.672 Y7.011 E.00224 +G1 X-35.105 Y6.534 E.02275 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.653 Y6.493 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.403528 +G1 F15000 +G1 X-35.725 Y6.435 E.00272 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.426 Y3.972 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.521762 +G1 F15000 +G1 X-36.453 Y4.279 E.01202 +;WIDTH:0.481057 +G1 X-36.479 Y4.586 E.011 +;WIDTH:0.420828 +G1 X-36.506 Y4.893 E.00949 +G1 X-36.506 Y6.782 E.05817 +G1 X-36.107 Y7.085 E.01542 +G1 X-35.731 Y7.6 E.01964 +G1 X-35.7 Y7.666 E.00224 +G1 X-35.352 Y7.213 E.0176 +G1 X-34.754 Y6.736 E.02354 +G1 X-34.372 Y6.493 E.01394 +G1 X-34.743 Y6.297 E.0129 +G1 X-35.461 Y5.671 E.02934 +G1 X-36.032 Y4.851 E.03078 +;WIDTH:0.437946 +G1 X-36.158 Y4.57 E.0099 +;WIDTH:0.473839 +G1 X-36.284 Y4.289 E.0108 +;WIDTH:0.491785 +G1 X-36.409 Y4.009 E.01125 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.445 Y3.93 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.524741 +G1 F15000 +G1 X-36.462 Y3.657 E.01073 +;WIDTH:0.489993 +G1 X-36.48 Y3.384 E.00996 +;WIDTH:0.43915 +G1 X-36.497 Y3.112 E.00883 +G1 X-36.499 Y-1.716 E.15589 +G1 X-36.477 Y-2.22 E.01627 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.071 Y-5.155 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-35.709 Y-5.711 E.02601 +G1 X-36.129 Y-6.291 E.02199 +G1 X-36.128 Y-4.093 E.06753 +G1 X-35.752 Y-4.584 E.019 +G1 X-35.101 Y-5.129 E.02609 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.686 Y-5.215 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.494706 +G1 F15000 +G1 X-35.743 Y-5.215 E.00208 +G1 X-35.771 Y-5.166 E.00208 +G1 X-35.743 Y-5.117 E.00208 +G1 X-35.686 Y-5.117 E.00208 +G1 X-35.678 Y-5.132 E.00061 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.426 Y-7.695 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.521764 +G1 F15000 +G1 X-36.453 Y-7.388 E.01202 +;WIDTH:0.481058 +G1 X-36.479 Y-7.081 E.011 +;WIDTH:0.421637 +G1 X-36.506 Y-6.774 E.00951 +G1 X-36.506 Y-3.254 E.10863 +;WIDTH:0.433657 +G1 X-36.492 Y-2.778 E.01517 +;WIDTH:0.462324 +G1 X-36.475 Y-2.26 E.01771 +;WIDTH:0.465734 +G1 X-36.327 Y-2.734 E.01712 +;WIDTH:0.421355 +G1 X-36.178 Y-3.208 E.01533 +G1 X-35.797 Y-3.911 E.02465 +G1 X-35.455 Y-4.351 E.01719 +G1 X-34.533 Y-5.098 E.03659 +G1 X-34.372 Y-5.174 E.00548 +G1 X-34.743 Y-5.369 E.01292 +G1 X-35.461 Y-5.995 E.02938 +G1 X-36.032 Y-6.816 E.03083 +;WIDTH:0.437946 +G1 X-36.158 Y-7.096 E.0099 +;WIDTH:0.47384 +G1 X-36.284 Y-7.377 E.0108 +;WIDTH:0.491787 +G1 X-36.409 Y-7.658 E.01125 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.445 Y-7.737 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.523711 +G1 F15000 +G1 X-36.463 Y-8.043 E.01201 +;WIDTH:0.486901 +G1 X-36.482 Y-8.348 E.01109 +;WIDTH:0.435039 +G1 X-36.5 Y-8.654 E.00979 +G1 X-36.5 Y-12.469 E.12188 +G1 X-36.448 Y-12.999 E.01702 +G1 X-36.366 Y-13.493 E.016 +G1 X-36.206 Y-13.967 E.016 +M73 P83 R1 +G1 X-36.004 Y-14.427 E.01603 +M73 P83 R1 +G1 X-35.72 Y-14.839 E.01602 +G1 X-35.409 Y-15.232 E.016 +G1 X-35.041 Y-15.571 E.016 +G1 X-34.643 Y-15.877 E.01603 +G1 X-33.665 Y-16.32 E.03429 +G1 X-32.791 Y-16.489 E.02845 +G1 X11.688 Y-16.492 E1.42124 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.006 Y-15.07 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.349 Y-15.517 E.01729 +G1 X16.087 Y-16.119 E.02928 +G1 X13.935 Y-16.119 E.06613 +G1 X14.709 Y-15.456 E.03131 +G1 X14.982 Y-15.102 E.01373 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.032 Y-15.767 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.459962 +G1 F15000 +G1 X14.98 Y-15.767 E.00177 +G1 X14.954 Y-15.722 E.00177 +G1 X14.98 Y-15.677 E.00177 +G1 X15.038 Y-15.687 E.00201 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.137 Y-16.484 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.422094 +G1 F15000 +G1 X11.728 Y-16.49 E.19803 +G1 X12.805 Y-16.268 E.03397 +G1 X13.704 Y-15.824 E.03098 +G1 X14.461 Y-15.171 E.03089 +G1 X14.931 Y-14.533 E.0245 +G1 X15 Y-14.392 E.00484 +G1 X15.068 Y-14.531 E.00478 +G1 X15.648 Y-15.287 E.02945 +G1 X16.431 Y-15.909 E.03089 +G1 X17.344 Y-16.316 E.03089 +G1 X18.098 Y-16.476 E.02383 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.14 Y-16.485 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.436179 +G1 F15000 +G1 X32.69 Y-16.492 E.46624 +G1 X33.236 Y-16.43 E.01761 +G1 X33.732 Y-16.291 E.01651 +G1 X34.2 Y-16.113 E.01606 +G1 X34.642 Y-15.875 E.01608 +G1 X35.038 Y-15.568 E.01606 +G1 X35.41 Y-15.232 E.01605 +G1 X35.725 Y-14.843 E.01605 +G1 X36.004 Y-14.427 E.01607 +G1 X36.204 Y-13.967 E.01607 +G1 X36.373 Y-13.495 E.01607 +G1 X36.453 Y-12.999 E.01607 +G1 X36.5 Y-12.49 E.0164 +G1 X36.5 Y-8.652 E.12298 +;WIDTH:0.452528 +G1 X36.481 Y-8.394 E.00864 +;WIDTH:0.490208 +G1 X36.463 Y-8.136 E.00943 +;WIDTH:0.527888 +G1 X36.444 Y-7.878 E.01023 +;WIDTH:0.546728 +G1 F14886.623 +G1 X36.426 Y-7.62 E.01062 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.372 Y-5.16 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X34.743 Y-4.964 E.01287 +G1 X35.461 Y-4.338 E.02927 +G1 X36.032 Y-3.518 E.03073 +;WIDTH:0.443476 +G1 X36.154 Y-3.279 E.00874 +;WIDTH:0.490428 +G1 X36.275 Y-3.041 E.00976 +;WIDTH:0.53738 +G1 X36.396 Y-2.802 E.01079 +;WIDTH:0.57306 +G1 F14147.207 +G1 X36.423 Y-2.751 E.00249 +;WIDTH:0.564606 +G1 F14376.467 +G1 X36.444 Y-2.955 E.00872 +;WIDTH:0.52329 +G1 F15000 +G1 X36.465 Y-3.159 E.00803 +;WIDTH:0.481974 +G1 X36.485 Y-3.363 E.00734 +;WIDTH:0.421322 +G1 X36.506 Y-3.567 E.00632 +G1 X36.506 Y-6.564 E.09241 +;WIDTH:0.443215 +G1 X36.483 Y-6.881 E.01039 +;WIDTH:0.489645 +G1 X36.459 Y-7.199 E.01159 +;WIDTH:0.536075 +G1 X36.436 Y-7.517 E.0128 +;WIDTH:0.572501 +G1 F14162.139 +G1 X36.423 Y-7.58 E.00278 +;WIDTH:0.564998 +G1 F14365.673 +G1 X36.306 Y-7.354 E.01082 +;WIDTH:0.52357 +G1 F15000 +G1 X36.189 Y-7.128 E.00996 +;WIDTH:0.482142 +G1 X36.072 Y-6.902 E.0091 +;WIDTH:0.424333 +G1 X35.956 Y-6.676 E.0079 +G1 X35.352 Y-5.879 E.03107 +G1 X34.754 Y-5.403 E.02377 +G1 X34.406 Y-5.181 E.01282 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.075 Y-5.175 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X35.708 Y-4.623 E.02583 +G1 X36.129 Y-4.043 E.022 +G1 X36.129 Y-6.273 E.06852 +G1 X35.651 Y-5.65 E.02411 +G1 X35.106 Y-5.2 E.02172 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.746 Y-5.206 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.48664 +G1 F15000 +G1 X35.691 Y-5.206 E.00201 +G1 X35.663 Y-5.158 E.00201 +G1 X35.691 Y-5.11 E.00201 +G1 X35.754 Y-5.124 E.00234 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.473 Y-2.15 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.511311 +G1 F15000 +G1 X36.449 Y-2.431 E.01073 +;WIDTH:0.535962 +G1 X36.426 Y-2.711 E.0113 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X36.449 Y-2.431 +;WIPE_END +G1 X28.895 Y-3.52 Z1.8 F30000 +G1 X14.082 Y-5.655 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.937 Y-5.655 E.05699 +G1 X15.54 Y-5.994 E.01603 +G1 X15.069 Y-6.634 E.0244 +G1 X15 Y-6.775 E.00483 +G1 X14.675 Y-6.3 E.01767 +G1 X14.352 Y-5.879 E.01629 +G1 X14.113 Y-5.68 E.00956 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.02 Y-6.102 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.468228 +G1 F15000 +G1 X14.967 Y-6.102 E.00184 +G1 X14.94 Y-6.056 E.00184 +G1 X14.967 Y-6.01 E.00184 +G1 X15.026 Y-6.021 E.00211 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X14.967 Y-6.01 +G1 X14.94 Y-6.056 +G1 X14.967 Y-6.102 +G1 X15.02 Y-6.102 +;WIPE_END +G1 X7.92 Y-3.299 Z1.8 F30000 +G1 X-36.15 Y14.098 Z1.8 +G1 Z1.4 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.435865 +G1 F15000 +G1 X-35.966 Y14.489 E.01382 +G1 X-35.463 Y15.181 E.02741 +G1 X-35.039 Y15.569 E.01839 +G1 X-34.641 Y15.874 E.01605 +G1 X-34.196 Y16.103 E.01605 +G1 X-33.736 Y16.303 E.01606 +G1 X-33.247 Y16.415 E.01606 +G1 X-32.735 Y16.492 E.01659 +G1 X-29.81 Y16.492 E.09364 +;WIDTH:0.447724 +G1 X-29.586 Y16.473 E.00743 +;WIDTH:0.487694 +G1 X-29.361 Y16.454 E.00817 +;WIDTH:0.527665 +G1 X-29.137 Y16.435 E.0089 +;WIDTH:0.54765 +G1 F14859.445 +G1 X-28.912 Y16.416 E.00927 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.469 Y15.682 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.451452 +G1 F15000 +G1 X-26.521 Y15.682 E.0017 +G1 X-26.546 Y15.726 E.0017 +G1 X-26.521 Y15.77 E.0017 +G1 X-26.464 Y15.761 E.00191 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.435 Y16.119 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-26.209 Y15.456 E.03131 +G1 X-26.501 Y15.077 E.01469 +G1 X-26.859 Y15.532 E.01778 +G1 X-27.59 Y16.119 E.0288 +G1 X-25.475 Y16.119 E.06498 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.592 Y16.461 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-24.309 Y16.267 E.02284 +G1 X-25.204 Y15.824 E.03066 +G1 X-25.961 Y15.171 E.03072 +G1 X-26.434 Y14.528 E.02453 +G1 X-26.5 Y14.392 E.00466 +G1 X-27.067 Y15.216 E.03072 +G1 X-27.929 Y15.908 E.03397 +;WIDTH:0.443233 +G1 X-28.225 Y16.065 E.01092 +;WIDTH:0.489701 +G1 X-28.52 Y16.223 E.01219 +;WIDTH:0.536169 +G1 X-28.815 Y16.38 E.01346 +;WIDTH:0.573482 +G1 F14135.967 +G1 X-28.872 Y16.412 E.00282 +;WIDTH:0.566616 +G1 F14321.292 +G1 X-28.618 Y16.433 E.01091 +;WIDTH:0.524726 +G1 F15000 +G1 X-28.363 Y16.454 E.01004 +;WIDTH:0.482835 +G1 X-28.108 Y16.475 E.00916 +;WIDTH:0.422387 +G1 X-27.853 Y16.496 E.00791 +G1 X-23.664 Y16.496 E.12954 +G1 X-23.628 Y16.478 E.00125 +;LAYER_CHANGE +;Z:1.6 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;1.6 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-23.664 Y16.496 +G1 X-25.624 Y16.496 +;WIPE_END +;AFTER_LAYER_CHANGE +;1.6 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z2 I.705 J-.992 P1 F30000 +G1 X-36.005 Y9.111 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.4887 +G1 F16824.441 +G1 X-36.026 Y9.203 E.00342 +;WIDTH:0.525951 +G1 F15526.953 +G1 X-36.14 Y9.993 E.03143 +G1 X-36.14 Y10.599 E.02385 +G1 X-36.508 Y10.197 E.02144 +G1 X-36.854 Y10.004 E.0156 +G1 X-36.572 Y9.831 E.01304 +G1 X-36.233 Y9.53 E.01783 +G1 X-36.024 Y9.146 E.0172 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15526.953 +G1 X-36.026 Y9.203 +G1 X-36.14 Y9.993 +G1 X-36.14 Y10.599 +G1 X-36.504 Y10.201 +;WIPE_END +G1 X-29.22 Y12.483 Z2 F30000 +G1 X-26.5 Y13.335 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-26.466 Y13.465 E.00449 +G1 X-26.117 Y14.297 E.02991 +G1 X-25.573 Y15.016 E.02992 +G1 X-24.866 Y15.577 E.02992 +G1 X-24.043 Y15.944 E.02992 +G1 X-23.54 Y16.04 E.01696 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.453 Y16.078 E.00355 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.365 Y16.116 E.00392 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.277 Y16.154 E.00429 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.192 E.00467 +G1 X32.757 Y16.192 E2.73304 +G1 X33.602 Y16.029 E.04204 +;WIDTH:0.655272 +G1 F12247.857 +G1 X34.067 Y15.831 E.02524 +G1 X34.551 Y15.573 E.02734 +;WIDTH:0.643371 +G1 F12490.612 +G1 X35.166 Y15.062 E.03914 +;WIDTH:0.655272 +G1 F12247.857 +G1 X35.478 Y14.664 E.02524 +G1 X35.778 Y14.205 E.02733 +;WIDTH:0.64337 +G1 F12490.633 +G1 X36.056 Y13.501 E.03706 +;WIDTH:0.649623 +G1 F12361.897 +G1 X36.127 Y13.139 E.01821 +G1 X36.196 Y12.587 E.02751 +G1 X36.355 Y12.75 E.01125 +G1 X36.763 Y13.039 E.02469 +G1 X36.659 Y13.587 E.02759 +;WIDTH:0.655271 +G1 F12247.877 +G1 X36.484 Y14.098 E.02694 +G1 X36.258 Y14.587 E.0269 +G1 X35.962 Y15.039 E.02694 +G1 X35.621 Y15.456 E.0269 +G1 X35.221 Y15.82 E.02694 +G1 X34.787 Y16.14 E.0269 +G1 X34.31 Y16.392 E.02694 +G1 X33.81 Y16.594 E.0269 +G1 X33.285 Y16.72 E.02694 +G1 X32.69 Y16.792 E.02994 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.792 E2.72975 +G1 X-23.297 Y16.816 E.00539 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.405 Y16.84 E.00496 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.513 Y16.864 E.00453 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.62 Y16.888 E.00409 +;WIDTH:0.449999 +G1 F18000 +G1 X-32.776 Y16.888 E.30371 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.372 Y16.478 E.01832 +G1 X-34.856 Y16.212 E.01832 +G1 X-35.303 Y15.888 E.01832 +G1 X-35.705 Y15.51 E.01831 +G1 X-36.041 Y15.104 E.01746 +G1 X-36.307 Y14.691 E.0163 +G1 X-36.54 Y14.221 E.0174 +;WIDTH:0.498866 +G1 F16449.315 +G1 X-36.555 Y14.108 E.00425 +;WIDTH:0.547733 +G1 F14856.986 +G1 X-36.57 Y13.994 E.00471 +;WIDTH:0.5966 +G1 F13545.731 +G1 X-36.584 Y13.881 E.00517 +;WIDTH:0.645467 +G1 F12447.162 +G1 X-36.599 Y13.767 E.00562 +G1 X-36.668 Y13.551 E.01115 +;WIDTH:0.649622 +G1 F12361.918 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.00939 +G1 X-36.196 Y12.588 E.02645 +G1 X-36.07 Y13.461 E.0436 +;WIDTH:0.642407 +G1 F12510.696 +G1 X-35.996 Y13.582 E.00696 +;WIDTH:0.593293 +G1 F13627.122 +G1 X-35.922 Y13.704 E.00639 +;WIDTH:0.544179 +G1 F14962.326 +G1 X-35.848 Y13.826 E.00582 +;WIDTH:0.495065 +G1 F16587.598 +G1 X-35.688 Y14.169 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-35.395 Y14.603 E.01736 +G1 X-35.073 Y15.016 E.01736 +G1 X-34.489 Y15.499 E.02512 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.75 Y16.096 E.09751 +G1 X-28.819 Y15.899 E.03157 +M73 P84 R1 +G1 X-28.011 Y15.499 E.02992 +M73 P84 R1 +G1 X-27.328 Y14.91 E.02992 +G1 X-26.812 Y14.169 E.02992 +G1 X-26.514 Y13.372 E.02824 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-26.466 Y13.465 +G1 X-26.117 Y14.297 +G1 X-25.573 Y15.016 +G1 X-25.501 Y15.073 +;WIPE_END +G1 X-18.765 Y11.484 Z2 F30000 +G1 X12.682 Y-5.265 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X13.48 Y-5.66 E.02953 +G1 X13.996 Y-6.08 E.02206 +G1 X14.453 Y-6.611 E.02325 +G1 X14.818 Y-7.275 E.02512 +G1 X14.983 Y-7.843 E.01964 +G1 X15.312 Y-6.997 E.03012 +G1 X15.522 Y-6.678 E.01266 +G1 X15.927 Y-6.151 E.02207 +G1 X16.634 Y-5.59 E.02992 +G1 X17.318 Y-5.265 E.02512 +G1 X13.439 Y-5.263 E.12866 +G1 X12.722 Y-5.265 E.0238 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X13.48 Y-5.66 +G1 X13.996 Y-6.08 +G1 X14.309 Y-6.444 +;WIPE_END +G1 X6.945 Y-4.439 Z2 F30000 +G1 X-33.249 Y6.5 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X-33.681 Y6.601 E.01472 +G1 X-34.489 Y7.001 E.02992 +G1 X-35.172 Y7.59 E.02992 +G1 X-35.551 Y8.112 E.02139 +G1 X-35.719 Y8.409 E.01133 +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.02241 +G1 X-35.907 Y8.609 E.01366 +;WIDTH:0.451257 +G1 F18000 +G1 X-35.924 Y8.308 E.01002 +;WIDTH:0.449999 +G1 X-36.06 Y7.814 E.017 +G1 X-36.331 Y7.404 E.01631 +G1 X-36.725 Y7.105 E.01638 +G1 X-36.898 Y7.027 E.00631 +G1 X-36.898 Y-12.481 E.64712 +G1 X-36.862 Y-13.051 E.01894 +G1 X-36.759 Y-13.593 E.01832 +G1 X-36.588 Y-14.119 E.01832 +G1 X-36.353 Y-14.618 E.01832 +G1 X-36.057 Y-15.084 E.01832 +G1 X-35.705 Y-15.51 E.01832 +G1 X-35.303 Y-15.888 E.01831 +G1 X-34.856 Y-16.212 E.01832 +G1 X-34.372 Y-16.478 E.01832 +G1 X-33.859 Y-16.682 E.01832 +G1 X-33.324 Y-16.819 E.01832 +G1 X-32.776 Y-16.888 E.01832 +G1 X32.776 Y-16.888 E2.17448 +G1 X33.324 Y-16.819 E.01832 +G1 X33.859 Y-16.682 E.01832 +G1 X34.372 Y-16.478 E.01832 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01832 +G1 X36.057 Y-15.084 E.01832 +G1 X36.353 Y-14.618 E.01831 +G1 X36.588 Y-14.119 E.01832 +G1 X36.759 Y-13.593 E.01832 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y-2.097 E.34447 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.874 Y-1.989 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.849 Y-1.882 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.825 Y-1.775 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X36.801 Y-1.667 E.00539 +G1 X36.801 Y6.966 E.42312 +G1 X36.357 Y7.252 E.02587 +G1 X36.199 Y7.42 E.0113 +G1 X36.199 Y-1.667 E.44539 +G1 X36.164 Y-1.761 E.00492 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.129 Y-1.856 E.00452 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.094 Y-1.95 E.00413 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.059 Y-2.044 E.00373 +;WIDTH:0.449999 +G1 F18000 +G1 X35.966 Y-2.632 E.01975 +G1 X35.617 Y-3.464 E.02992 +G1 X35.172 Y-4.076 E.02512 +G1 X34.959 Y-4.281 E.00981 +G1 X34.567 Y-4.607 E.0169 +G1 X33.966 Y-4.953 E.02301 +G1 X33.286 Y-5.174 E.02372 +G1 X33.681 Y-5.268 E.01347 +G1 X34.366 Y-5.59 E.02512 +G1 X34.665 Y-5.814 E.01239 +G1 X35.172 Y-6.257 E.02234 +G1 X35.688 Y-6.997 E.02992 +G1 X36.003 Y-7.842 E.02992 +G1 X36.102 Y-8.666 E.02752 +G1 X36.102 Y-12.469 E.12614 +G1 X36.05 Y-12.948 E.01601 +G1 X35.966 Y-13.465 E.01736 +G1 X35.688 Y-14.169 E.02512 +G1 X35.395 Y-14.603 E.01736 +G1 X35.073 Y-15.016 E.01736 +G1 X34.489 Y-15.499 E.02512 +G1 X34.024 Y-15.738 E.01736 +G1 X33.543 Y-15.944 E.01736 +G1 X32.69 Y-16.096 E.02874 +G1 X18.25 Y-16.096 E.47899 +G1 X17.319 Y-15.899 E.03157 +G1 X16.511 Y-15.499 E.02992 +G1 X15.828 Y-14.91 E.02992 +G1 X15.312 Y-14.169 E.02992 +G1 X15 Y-13.334 E.02957 +G1 X14.966 Y-13.465 E.00448 +G1 X14.617 Y-14.297 E.02992 +G1 X14.073 Y-15.016 E.02992 +G1 X13.366 Y-15.576 E.02992 +G1 X12.542 Y-15.944 E.02992 +G1 X11.69 Y-16.096 E.02874 +G1 X-32.742 Y-16.096 E1.47389 +G1 X-33.476 Y-15.962 E.02476 +G1 X-33.985 Y-15.78 E.01793 +G1 X-34.417 Y-15.521 E.0167 +G1 X-34.849 Y-15.226 E.01736 +G1 X-35.368 Y-14.673 E.02512 +G1 X-35.636 Y-14.224 E.01736 +G1 X-35.872 Y-13.757 E.01736 +G1 X-36.068 Y-12.987 E.02633 +G1 X-36.102 Y-12.469 E.01725 +G1 X-36.102 Y-8.624 E.12752 +G1 X-35.966 Y-7.702 E.03094 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.172 Y-6.257 E.02512 +G1 X-34.976 Y-6.071 E.00897 +G1 X-34.366 Y-5.59 E.02576 +G1 X-33.681 Y-5.268 E.02512 +G1 X-33.249 Y-5.167 E.01472 +G1 X-33.681 Y-5.066 E.01472 +G1 X-34.489 Y-4.665 E.02992 +G1 X-35.172 Y-4.076 E.02992 +G1 X-35.688 Y-3.336 E.02992 +G1 X-36.003 Y-2.491 E.02992 +G1 X-36.102 Y-1.667 E.02752 +G1 X-36.102 Y3.042 E.15623 +G1 X-35.966 Y3.965 E.03094 +G1 X-35.617 Y4.797 E.02992 +G1 X-35.172 Y5.41 E.02512 +G1 X-34.932 Y5.632 E.01087 +G1 X-34.366 Y6.076 E.02386 +G1 X-33.681 Y6.399 E.02512 +G1 X-33.288 Y6.491 E.0134 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-33.681 Y6.601 +G1 X-34.489 Y7.001 +G1 X-35.011 Y7.452 +;WIPE_END +G1 X-27.382 Y7.66 Z2 F30000 +G1 X36.142 Y9.392 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.530587 +G1 F15379.346 +G1 X36.39 Y9.702 E.01576 +G1 X36.842 Y10 E.02151 +G1 X36.391 Y10.298 E.0215 +G1 X36.142 Y10.608 E.01577 +G1 X36.142 Y9.432 E.04672 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.242 Y8.453 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.525694 +G1 F12000 +G1 X36.241 Y8.455 E.00009 +G1 X36.282 Y8.607 E.00619 +;WIDTH:0.491264 +G1 X36.323 Y8.759 E.00575 +;WIDTH:0.456834 +G1 X36.364 Y8.911 E.00531 +;WIDTH:0.422404 +G1 X36.596 Y9.3 E.01402 +;WIDTH:0.419999 +G1 X36.955 Y9.577 E.01392 +G1 X37.29 Y9.668 E.01068 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01408 +G1 X36.391 Y11.024 E.01393 +;WIDTH:0.455231 +G1 X36.341 Y11.167 E.00511 +;WIDTH:0.490463 +G1 X36.291 Y11.311 E.00555 +;WIDTH:0.525694 +G1 X36.241 Y11.455 E.00598 +G1 X36.282 Y11.607 E.00619 +;WIDTH:0.491264 +G1 X36.323 Y11.759 E.00575 +;WIDTH:0.456834 +G1 X36.364 Y11.911 E.00531 +;WIDTH:0.422404 +G1 X36.596 Y12.3 E.01402 +;WIDTH:0.419999 +G1 X36.955 Y12.577 E.01392 +G1 X37.279 Y12.666 E.01034 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.553 Y16.19 E.01848 +G1 X-35.991 Y15.779 E.01848 +G1 X-36.375 Y15.315 E.01848 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.222 Y13.254 E.01705 +G1 X-37.279 Y12.666 E.01816 +G1 X-37.024 Y12.608 E.00805 +G1 X-36.644 Y12.352 E.01408 +G1 X-36.544 Y12.23 E.00484 +;WIDTH:0.46509 +G1 X-36.404 Y11.965 E.01031 +;WIDTH:0.51018 +G1 X-36.264 Y11.7 E.01141 +;WIDTH:0.538046 +G1 X-36.264 Y11.23 E.01897 +G1 X-36.319 Y11.133 E.00451 +;WIDTH:0.498697 +G1 X-36.374 Y11.036 E.00415 +;WIDTH:0.459348 +G1 X-36.429 Y10.939 E.00379 +;WIDTH:0.419999 +G1 X-36.703 Y10.593 E.01355 +M73 P85 R1 +G1 X-37.07 Y10.376 E.0131 +M73 P85 R1 +G1 X-37.29 Y10.321 E.00696 +G1 X-37.29 Y9.668 E.02005 +G1 X-37.024 Y9.608 E.00839 +G1 X-36.644 Y9.352 E.01408 +G1 X-36.391 Y8.976 E.01391 +G1 X-36.293 Y8.535 E.01388 +G1 X-36.365 Y8.089 E.01388 +G1 X-36.596 Y7.7 E.01392 +G1 X-36.955 Y7.423 E.01393 +G1 X-37.29 Y7.331 E.01067 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.397 Y-17.205 E.01848 +G1 X33.98 Y-17.055 E.01848 +G1 X34.539 Y-16.834 E.01848 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.697 Y-14.807 E.01848 +G1 X36.953 Y-14.263 E.01848 +G1 X37.139 Y-13.691 E.01848 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.331 E.60916 +G1 X37.024 Y7.391 E.00837 +G1 X36.645 Y7.648 E.01408 +G1 X36.391 Y8.024 E.01393 +;WIDTH:0.455231 +G1 X36.341 Y8.167 E.00511 +;WIDTH:0.490463 +G1 X36.291 Y8.311 E.00555 +;WIDTH:0.525694 +G1 X36.255 Y8.415 E.00432 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.241 Y8.455 +G1 X36.282 Y8.607 +G1 X36.323 Y8.759 +G1 X36.364 Y8.911 +G1 X36.596 Y9.3 +G1 X36.955 Y9.577 +G1 X37.29 Y9.668 +G1 X37.29 Y9.9 +;WIPE_END +G1 X29.659 Y9.757 Z2 F30000 +G1 X-26.274 Y8.71 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.514898 +G1 F12000 +G1 X-26.276 Y8.679 E.00119 +;WIDTH:0.467449 +G1 X-26.292 Y8.362 E.01099 +;WIDTH:0.419999 +G1 X-26.534 Y7.714 E.02124 +G1 X-26.854 Y7.247 E.01742 +G1 X-27.047 Y7.025 E.00903 +G1 X-27.506 Y6.646 E.01829 +G1 X-28.138 Y6.348 E.02148 +G1 X-28.831 Y6.216 E.02167 +;WIDTH:0.454327 +G1 X-29.115 Y6.232 E.00953 +;WIDTH:0.488655 +G1 X-29.399 Y6.248 E.01033 +;WIDTH:0.522983 +G1 X-29.683 Y6.265 E.01112 +;WIDTH:0.527381 +G1 X-32.689 Y6.258 E.11868 +G1 X-32.949 Y6.191 E.01059 +;WIDTH:0.491587 +G1 X-33.209 Y6.125 E.00981 +;WIDTH:0.455793 +G1 X-33.469 Y6.059 E.00903 +;WIDTH:0.419999 +G1 X-34.199 Y5.722 E.02471 +G1 X-34.855 Y5.179 E.02615 +G1 X-35.3 Y4.566 E.02327 +G1 X-35.602 Y3.821 E.02471 +G1 X-35.71 Y3.013 E.02504 +G1 X-35.71 Y-1.667 E.14382 +G1 X-35.614 Y-2.442 E.02398 +G1 X-35.323 Y-3.192 E.02471 +G1 X-34.855 Y-3.846 E.02471 +G1 X-34.239 Y-4.363 E.02471 +G1 X-33.514 Y-4.711 E.02471 +;WIDTH:0.455793 +G1 X-33.241 Y-4.782 E.00949 +;WIDTH:0.491587 +G1 X-32.968 Y-4.853 E.01031 +;WIDTH:0.527381 +G1 X-32.695 Y-4.924 E.01113 +G1 X11.69 Y-4.924 E1.75221 +G1 X11.973 Y-4.907 E.01119 +;WIDTH:0.491587 +G1 X12.256 Y-4.889 E.01037 +;WIDTH:0.455793 +G1 X12.539 Y-4.871 E.00954 +;WIDTH:0.419999 +G1 X17.461 Y-4.871 E.15126 +;WIDTH:0.455793 +G1 X17.704 Y-4.889 E.00819 +;WIDTH:0.491587 +G1 X17.947 Y-4.907 E.0089 +;WIDTH:0.527381 +G1 X18.189 Y-4.924 E.00961 +G1 X32.69 Y-4.924 E.57244 +G1 X32.931 Y-4.856 E.00989 +;WIDTH:0.491587 +G1 X33.172 Y-4.788 E.00916 +;WIDTH:0.455793 +G1 X33.413 Y-4.72 E.00843 +;WIDTH:0.419999 +G1 X33.884 Y-4.562 E.01527 +G1 X34.37 Y-4.268 E.01746 +G1 X34.826 Y-3.878 E.01842 +G1 X35.3 Y-3.233 E.0246 +G1 X35.602 Y-2.487 E.02471 +G1 X35.71 Y-1.68 E.02504 +G1 X35.71 Y7.826 E.29209 +;WIDTH:0.455231 +G1 X35.728 Y8.021 E.00656 +;WIDTH:0.490463 +G1 X35.745 Y8.215 E.00712 +;WIDTH:0.525694 +G1 X35.763 Y8.41 E.00769 +G1 X35.746 Y8.597 E.00741 +;WIDTH:0.491264 +G1 X35.728 Y8.785 E.00688 +;WIDTH:0.456834 +G1 X35.711 Y8.972 E.00635 +;WIDTH:0.422404 +G1 X35.71 Y9.557 E.01809 +;WIDTH:0.419999 +G1 X35.71 Y10.826 E.03898 +;WIDTH:0.455231 +G1 X35.728 Y11.021 E.00656 +;WIDTH:0.490463 +G1 X35.745 Y11.215 E.00712 +;WIDTH:0.525694 +G1 X35.763 Y11.41 E.00769 +G1 X35.746 Y11.597 E.00741 +;WIDTH:0.491264 +G1 X35.728 Y11.785 E.00688 +;WIDTH:0.456834 +G1 X35.711 Y11.972 E.00635 +;WIDTH:0.422404 +G1 X35.71 Y12.501 E.01634 +;WIDTH:0.419999 +G1 X35.602 Y13.321 E.02542 +G1 X35.323 Y14.025 E.02327 +G1 X34.855 Y14.679 E.02471 +G1 X34.239 Y15.196 E.02471 +G1 X33.514 Y15.544 E.02471 +G1 X32.708 Y15.704 E.02524 +G1 X-23.19 Y15.704 E1.71758 +G1 X-23.969 Y15.559 E.02436 +G1 X-24.699 Y15.222 E.02471 +G1 X-25.323 Y14.714 E.02471 +G1 X-25.8 Y14.066 E.02471 +G1 X-26.102 Y13.321 E.0247 +;WIDTH:0.45382 +G1 X-26.155 Y13.052 E.00919 +;WIDTH:0.48764 +G1 X-26.208 Y12.783 E.00994 +;WIDTH:0.52146 +G1 X-26.261 Y12.513 E.0107 +G1 X-26.261 Y9.999 E.09804 +;WIDTH:0.570985 +G1 X-26.285 Y9.182 E.03517 +G1 X-26.273 Y9.089 E.00405 +;WIDTH:0.542942 +G1 X-26.26 Y8.996 E.00383 +;WIDTH:0.514898 +G1 X-26.272 Y8.75 E.00949 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.276 Y8.679 +G1 X-26.292 Y8.362 +G1 X-26.534 Y7.714 +G1 X-26.854 Y7.247 +G1 X-27.047 Y7.025 +G1 X-27.093 Y6.987 +;WIPE_END +G1 X-19.823 Y4.665 Z2 F30000 +G1 X11.722 Y-5.409 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.527381 +G1 F12000 +G1 X-32.69 Y-5.409 E1.75327 +G1 X-32.949 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X-33.209 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X-33.469 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X-34.199 Y-5.945 E.02471 +G1 X-34.855 Y-6.487 E.02615 +G1 X-35.3 Y-7.1 E.02327 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.68 Y-12.926 E.01407 +G1 X-35.492 Y-13.659 E.02327 +G1 X-35.11 Y-14.367 E.02471 +G1 X-34.563 Y-14.957 E.02471 +G1 X-33.887 Y-15.393 E.02472 +G1 X-33.397 Y-15.577 E.0161 +G1 X-32.707 Y-15.704 E.02155 +G1 X11.69 Y-15.704 E1.36418 +G1 X12.469 Y-15.559 E.02436 +G1 X13.199 Y-15.222 E.02471 +G1 X13.823 Y-14.714 E.02471 +G1 X14.3 Y-14.066 E.02471 +G1 X14.602 Y-13.321 E.02471 +;WIDTH:0.45382 +G1 X14.655 Y-13.058 E.00899 +;WIDTH:0.48764 +G1 X14.708 Y-12.794 E.00973 +;WIDTH:0.52146 +G1 X14.761 Y-12.531 E.01047 +G1 X14.761 Y-8.666 E.15071 +G1 X14.708 Y-8.424 E.00965 +;WIDTH:0.48764 +G1 X14.656 Y-8.183 E.00896 +;WIDTH:0.45382 +G1 X14.603 Y-7.941 E.00828 +;WIDTH:0.419999 +G1 X14.475 Y-7.464 E.01519 +G1 X14.11 Y-6.8 E.02327 +G1 X13.704 Y-6.342 E.01878 +G1 X13.236 Y-5.968 E.01842 +G1 X12.514 Y-5.622 E.0246 +;WIDTH:0.455793 +G1 X12.25 Y-5.551 E.0092 +;WIDTH:0.491587 +G1 X11.986 Y-5.48 E.00999 +;WIDTH:0.527381 +G1 X11.761 Y-5.419 E.00921 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X9.761 Y-5.419 +;WIPE_END +G1 X16.917 Y-8.073 Z2 F30000 +M73 P86 R1 +G1 X34.823 Y-14.714 Z2 +M73 P86 R1 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.419999 +G1 F12000 +G1 X35.3 Y-14.066 E.02471 +G1 X35.602 Y-13.321 E.02471 +G1 X35.71 Y-12.513 E.02504 +G1 X35.71 Y-8.666 E.11821 +G1 X35.614 Y-7.892 E.02398 +G1 X35.323 Y-7.142 E.02471 +G1 X34.855 Y-6.487 E.02471 +G1 X34.199 Y-5.945 E.02615 +G1 X33.514 Y-5.622 E.02327 +;WIDTH:0.455793 +G1 X33.246 Y-5.551 E.00933 +;WIDTH:0.491587 +G1 X32.978 Y-5.48 E.01014 +;WIDTH:0.527381 +G1 X32.71 Y-5.409 E.01095 +G1 X18.31 Y-5.409 E.56845 +G1 X18.051 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X17.791 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X17.531 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X16.801 Y-5.945 E.02471 +G1 X16.177 Y-6.453 E.02471 +G1 X15.677 Y-7.142 E.02615 +G1 X15.398 Y-7.846 E.02327 +;WIDTH:0.45382 +G1 X15.345 Y-8.112 E.00908 +;WIDTH:0.48764 +G1 X15.292 Y-8.378 E.00982 +;WIDTH:0.52146 +G1 X15.239 Y-8.643 E.01057 +G1 X15.239 Y-12.501 E.15041 +G1 X15.288 Y-12.759 E.01025 +;WIDTH:0.48764 +G1 X15.337 Y-13.017 E.00952 +;WIDTH:0.45382 +G1 X15.386 Y-13.275 E.0088 +;WIDTH:0.419999 +G1 X15.677 Y-14.025 E.02471 +G1 X16.145 Y-14.679 E.02471 +G1 X16.761 Y-15.196 E.02471 +G1 X17.486 Y-15.544 E.02471 +G1 X18.292 Y-15.704 E.02524 +G1 X32.69 Y-15.704 E.44241 +G1 X33.469 Y-15.559 E.02436 +G1 X34.199 Y-15.222 E.02471 +G1 X34.792 Y-14.739 E.02348 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X35.3 Y-14.066 +G1 X35.602 Y-13.321 +G1 X35.649 Y-12.971 +;WIPE_END +G1 X28.456 Y-10.417 Z2 F30000 +G1 X-26.811 Y9.215 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.570985 +G1 F12000 +G1 X-26.739 Y9.979 E.03301 +;WIDTH:0.52146 +G1 X-26.739 Y12.501 E.09832 +G1 X-26.788 Y12.759 E.01025 +;WIDTH:0.48764 +G1 X-26.837 Y13.017 E.00952 +;WIDTH:0.45382 +G1 X-26.886 Y13.275 E.0088 +;WIDTH:0.419999 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.645 Y14.679 E.02472 +G1 X-28.261 Y15.196 E.02471 +G1 X-28.986 Y15.544 E.02471 +G1 X-29.792 Y15.704 E.02524 +M73 P86 R0 +G1 X-32.69 Y15.704 E.08905 +M73 P86 R0 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.823 Y14.714 E.02471 +G1 X-35.3 Y14.066 E.02471 +G1 X-35.577 Y13.383 E.02267 +G1 X-35.678 Y12.77 E.01909 +G1 X-35.71 Y12.164 E.01865 +;WIDTH:0.455133 +G1 X-35.728 Y11.985 E.00603 +;WIDTH:0.490267 +G1 X-35.745 Y11.806 E.00655 +;WIDTH:0.525401 +G1 X-35.763 Y11.628 E.00706 +G1 X-35.746 Y11.057 E.02246 +;WIDTH:0.491333 +G1 X-35.728 Y10.646 E.01502 +;WIDTH:0.455666 +G1 X-35.71 Y10.235 E.01383 +;WIDTH:0.419999 +G1 X-35.665 Y9.635 E.01851 +G1 X-35.585 Y9.137 E.01549 +G1 X-35.323 Y8.475 E.02189 +G1 X-34.823 Y7.786 E.02615 +G1 X-34.239 Y7.304 E.02327 +G1 X-33.514 Y6.956 E.02471 +;WIDTH:0.455793 +G1 X-33.241 Y6.885 E.00949 +;WIDTH:0.491587 +G1 X-32.968 Y6.813 E.01031 +;WIDTH:0.527381 +G1 X-32.695 Y6.742 E.01113 +G1 X-29.81 Y6.744 E.11389 +;WIDTH:0.522983 +G1 X-29.551 Y6.81 E.01048 +;WIDTH:0.488655 +G1 X-29.291 Y6.876 E.00973 +;WIDTH:0.454327 +G1 X-29.031 Y6.941 E.00898 +;WIDTH:0.419999 +G1 X-28.261 Y7.304 E.02615 +G1 X-27.677 Y7.786 E.02327 +G1 X-27.2 Y8.434 E.02471 +;WIDTH:0.467449 +G1 X-27.029 Y8.792 E.01372 +;WIDTH:0.514898 +G1 X-26.859 Y9.15 E.01525 +;WIDTH:0.542942 +G1 X-26.835 Y9.183 E.00165 +;WIDTH:0.570985 +G1 X-26.835 Y9.183 E.00002 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.739 Y9.979 +G1 X-26.739 Y11.177 +;WIPE_END +G1 X-34.189 Y9.516 Z2 F30000 +G1 X-36.005 Y9.111 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.48122 +G1 F17111.567 +G1 X-35.964 Y8.987 E.00467 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F17111.567 +G1 X-36.005 Y9.111 +;WIPE_END +G1 X-28.868 Y6.595 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.433748 +G1 F18000 +G1 X-28.823 Y6.608 E.0015 +;WIDTH:0.4814 +G1 F17104.567 +G1 X-28.642 Y6.667 E.00679 +;WIDTH:0.529051 +G1 F15427.952 +G1 X-28.461 Y6.726 E.00753 +;WIDTH:0.576702 +G1 F14050.68 +G1 X-28.28 Y6.785 E.00827 +;WIDTH:0.615368 +G1 F13101.627 +G1 X-28.026 Y6.925 E.01353 +;WIDTH:0.654034 +G1 F12272.669 +G1 X-27.773 Y7.066 E.01445 +;WIDTH:0.65529 +G1 F12247.497 +G1 X-27.331 Y7.433 E.02865 +G1 X-26.988 Y7.894 E.02868 +;WIDTH:0.6429 +G1 F12500.417 +G1 X-26.858 Y8.155 E.01427 +;WIDTH:0.593448 +G1 F13623.286 +G1 X-26.728 Y8.417 E.0131 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F13623.286 +G1 X-26.858 Y8.155 +G1 X-26.988 Y7.894 +G1 X-27.331 Y7.433 +G1 X-27.773 Y7.066 +G1 X-28.006 Y6.936 +;WIPE_END +G1 X-35.075 Y6.508 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.419999 +G1 F15000 +G1 X-35.709 Y5.956 E.02584 +G1 X-36.129 Y5.376 E.02199 +G1 X-36.129 Y6.593 E.0374 +G1 X-35.713 Y6.95 E.01683 +G1 X-35.672 Y7.011 E.00225 +G1 X-35.105 Y6.534 E.02275 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.653 Y6.493 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.403514 +G1 F15000 +G1 X-35.725 Y6.435 E.00272 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.426 Y3.972 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.521762 +G1 F15000 +G1 X-36.453 Y4.279 E.01202 +;WIDTH:0.481057 +G1 X-36.479 Y4.586 E.011 +;WIDTH:0.420828 +G1 X-36.506 Y4.893 E.00949 +G1 X-36.506 Y6.782 E.05817 +G1 X-36.107 Y7.085 E.01542 +G1 X-35.731 Y7.6 E.01963 +G1 X-35.7 Y7.666 E.00224 +G1 X-35.352 Y7.212 E.0176 +G1 X-34.754 Y6.736 E.02354 +G1 X-34.372 Y6.493 E.01394 +G1 X-34.743 Y6.297 E.0129 +G1 X-35.461 Y5.671 E.02934 +G1 X-36.032 Y4.851 E.03078 +;WIDTH:0.437946 +G1 X-36.158 Y4.57 E.0099 +;WIDTH:0.473839 +G1 X-36.284 Y4.289 E.0108 +;WIDTH:0.491785 +G1 X-36.409 Y4.009 E.01125 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.445 Y3.93 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.524741 +G1 F15000 +G1 X-36.462 Y3.657 E.01073 +;WIDTH:0.489994 +G1 X-36.48 Y3.384 E.00996 +;WIDTH:0.436832 +G1 X-36.497 Y3.112 E.00877 +G1 X-36.5 Y-1.681 E.15384 +;WIDTH:0.452528 +G1 X-36.482 Y-1.937 E.00857 +;WIDTH:0.490206 +G1 X-36.463 Y-2.193 E.00936 +;WIDTH:0.527885 +G1 X-36.445 Y-2.449 E.01015 +;WIDTH:0.546724 +G1 F14886.757 +G1 X-36.427 Y-2.705 E.01054 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.075 Y-5.158 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-35.709 Y-5.711 E.02584 +G1 X-36.129 Y-6.291 E.02199 +G1 X-36.129 Y-4.061 E.06852 +G1 X-35.651 Y-4.683 E.02411 +G1 X-35.106 Y-5.133 E.02172 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.691 Y-5.223 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.48666 +G1 F15000 +G1 X-35.746 Y-5.223 E.00201 +G1 X-35.774 Y-5.175 E.00201 +G1 X-35.746 Y-5.127 E.00201 +G1 X-35.683 Y-5.14 E.00234 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.43 Y-7.734 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.517057 +G1 F15000 +G1 X-36.455 Y-7.415 E.01236 +;WIDTH:0.478234 +G1 X-36.481 Y-7.096 E.01135 +;WIDTH:0.421866 +G1 X-36.506 Y-6.777 E.00988 +G1 X-36.506 Y-3.769 E.09288 +;WIDTH:0.442466 +G1 X-36.483 Y-3.452 E.01037 +;WIDTH:0.4874 +G1 X-36.461 Y-3.134 E.01153 +;WIDTH:0.532334 +G1 X-36.438 Y-2.817 E.0127 +;WIDTH:0.569602 +G1 F14240.106 +G1 X-36.424 Y-2.745 E.00313 +;WIDTH:0.563852 +G1 F14397.284 +G1 X-36.307 Y-2.973 E.01088 +;WIDTH:0.522751 +G1 F15000 +G1 X-36.19 Y-3.201 E.01002 +;WIDTH:0.48165 +G1 X-36.073 Y-3.429 E.00916 +;WIDTH:0.421086 +G1 X-35.956 Y-3.657 E.0079 +G1 X-35.352 Y-4.454 E.0308 +G1 X-34.754 Y-4.931 E.02357 +G1 X-34.372 Y-5.174 E.01395 +G1 X-34.743 Y-5.369 E.01291 +G1 X-35.461 Y-5.995 E.02936 +G1 X-36.032 Y-6.816 E.0308 +;WIDTH:0.437244 +G1 X-36.16 Y-7.11 E.0103 +;WIDTH:0.471734 +G1 X-36.287 Y-7.404 E.0112 +;WIDTH:0.488979 +G1 X-36.414 Y-7.698 E.01165 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.448 Y-7.774 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.519005 +G1 F15000 +G1 X-36.465 Y-8.068 E.0114 +;WIDTH:0.484077 +G1 X-36.483 Y-8.361 E.01057 +;WIDTH:0.435244 +G1 X-36.5 Y-8.654 E.0094 +G1 X-36.5 Y-12.469 E.12194 +G1 X-36.466 Y-13.001 E.01705 +G1 X-36.36 Y-13.491 E.01603 +G1 X-36.218 Y-13.972 E.01603 +G1 X-35.988 Y-14.417 E.01603 +G1 X-35.729 Y-14.846 E.016 +G1 X-35.403 Y-15.226 E.01601 +G1 X-35.044 Y-15.575 E.01601 +G1 X-34.635 Y-15.864 E.01601 +G1 X-34.202 Y-16.116 E.01601 +G1 X-33.722 Y-16.301 E.01643 +G1 X-32.793 Y-16.489 E.03032 +G1 X11.688 Y-16.492 E1.42202 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.006 Y-15.07 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.349 Y-15.517 E.01729 +G1 X16.087 Y-16.119 E.02928 +G1 X13.935 Y-16.119 E.06613 +G1 X14.709 Y-15.456 E.03131 +G1 X14.982 Y-15.102 E.01373 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.032 Y-15.767 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.45996 +G1 F15000 +G1 X14.98 Y-15.767 E.00177 +G1 X14.954 Y-15.722 E.00177 +G1 X14.98 Y-15.677 E.00177 +G1 X15.038 Y-15.687 E.00201 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.137 Y-16.484 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.422094 +G1 F15000 +G1 X11.728 Y-16.49 E.19803 +G1 X12.805 Y-16.268 E.03397 +G1 X13.704 Y-15.824 E.03098 +G1 X14.461 Y-15.171 E.03089 +G1 X14.931 Y-14.533 E.02449 +G1 X15 Y-14.392 E.00486 +G1 X15.068 Y-14.531 E.00479 +G1 X15.648 Y-15.287 E.02944 +G1 X16.431 Y-15.909 E.03089 +G1 X17.344 Y-16.316 E.03089 +G1 X18.098 Y-16.476 E.02383 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.14 Y-16.485 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.436924 +G1 F15000 +G1 X32.69 Y-16.492 E.46712 +G1 X33.245 Y-16.407 E.01805 +G1 X33.733 Y-16.296 E.01607 +G1 X34.197 Y-16.106 E.01608 +G1 X34.643 Y-15.877 E.0161 +G1 X35.04 Y-15.57 E.0161 +G1 X35.415 Y-15.238 E.0161 +G1 X35.723 Y-14.842 E.0161 +G1 X36.004 Y-14.427 E.0161 +G1 X36.203 Y-13.966 E.0161 +G1 X36.37 Y-13.494 E.01609 +G1 X36.454 Y-12.999 E.0161 +G1 X36.5 Y-12.49 E.01644 +G1 X36.5 Y-8.652 E.12321 +;WIDTH:0.452528 +G1 X36.481 Y-8.394 E.00864 +;WIDTH:0.490208 +G1 X36.463 Y-8.136 E.00943 +;WIDTH:0.527887 +G1 X36.444 Y-7.878 E.01023 +;WIDTH:0.546727 +G1 F14886.669 +G1 X36.426 Y-7.62 E.01062 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.394 Y-5.17 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X34.803 Y-4.918 E.01477 +G1 X35.462 Y-4.337 E.02698 +G1 X36.032 Y-3.518 E.03068 +;WIDTH:0.443475 +G1 X36.154 Y-3.279 E.00874 +;WIDTH:0.490427 +G1 X36.275 Y-3.041 E.00976 +;WIDTH:0.537379 +G1 X36.396 Y-2.802 E.01079 +;WIDTH:0.57306 +G1 F14147.22 +G1 X36.423 Y-2.751 E.00249 +;WIDTH:0.564606 +G1 F14376.467 +G1 X36.444 Y-2.955 E.00872 +;WIDTH:0.52329 +G1 F15000 +G1 X36.465 Y-3.159 E.00803 +;WIDTH:0.481974 +G1 X36.485 Y-3.363 E.00734 +;WIDTH:0.421322 +G1 X36.506 Y-3.567 E.00632 +G1 X36.506 Y-6.564 E.09241 +;WIDTH:0.443215 +G1 X36.483 Y-6.881 E.01039 +;WIDTH:0.489645 +G1 X36.459 Y-7.199 E.01159 +;WIDTH:0.536076 +G1 X36.436 Y-7.517 E.0128 +;WIDTH:0.572502 +G1 F14162.126 +G1 X36.423 Y-7.58 E.00278 +;WIDTH:0.564998 +G1 F14365.673 +G1 X36.306 Y-7.354 E.01082 +;WIDTH:0.52357 +G1 F15000 +G1 X36.189 Y-7.128 E.00996 +;WIDTH:0.482142 +G1 X36.072 Y-6.902 E.0091 +;WIDTH:0.424372 +G1 X35.956 Y-6.676 E.0079 +G1 X35.352 Y-5.879 E.03107 +G1 X34.533 Y-5.235 E.03238 +G1 X34.43 Y-5.187 E.00354 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.079 Y-5.178 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X35.711 Y-4.62 E.02592 +G1 X36.129 Y-4.043 E.02189 +G1 X36.129 Y-6.273 E.06852 +G1 X35.651 Y-5.65 E.02411 +G1 X35.11 Y-5.204 E.02156 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.747 Y-5.208 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.485316 +G1 F15000 +G1 X35.691 Y-5.208 E.00199 +M73 P87 R0 +G1 X35.664 Y-5.16 E.00199 +M73 P87 R0 +G1 X35.691 Y-5.112 E.00199 +G1 X35.754 Y-5.125 E.00232 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.473 Y-2.15 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.511311 +G1 F15000 +G1 X36.449 Y-2.431 E.01073 +;WIDTH:0.535962 +G1 X36.426 Y-2.711 E.0113 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X36.449 Y-2.431 +;WIPE_END +G1 X28.895 Y-3.521 Z2 F30000 +G1 X14.102 Y-5.655 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.933 Y-5.655 E.05625 +G1 X15.54 Y-5.995 E.01597 +G1 X15.065 Y-6.639 E.02459 +G1 X14.99 Y-6.796 E.00533 +G1 X14.691 Y-6.272 E.01852 +G1 X14.133 Y-5.68 E.02499 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.037 Y-6.091 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.449308 +G1 F15000 +G1 X14.986 Y-6.091 E.00168 +G1 X14.961 Y-6.047 E.00168 +G1 X14.986 Y-6.003 E.00168 +G1 X15.042 Y-6.012 E.00189 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X14.986 Y-6.003 +G1 X14.961 Y-6.047 +G1 X14.986 Y-6.091 +G1 X15.037 Y-6.091 +;WIPE_END +G1 X7.937 Y-3.29 Z2 F30000 +G1 X-36.15 Y14.098 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.433788 +G1 F15000 +G1 X-35.966 Y14.489 E.01375 +G1 X-35.463 Y15.181 E.02726 +G1 X-35.039 Y15.569 E.01829 +G1 X-34.641 Y15.874 E.01597 +G1 X-34.196 Y16.103 E.01596 +G1 X-33.724 Y16.306 E.01633 +G1 X-32.791 Y16.492 E.03031 +G1 X-29.81 Y16.492 E.09495 +;WIDTH:0.447724 +G1 X-29.586 Y16.473 E.00743 +;WIDTH:0.487694 +G1 X-29.361 Y16.454 E.00817 +;WIDTH:0.527665 +G1 X-29.137 Y16.435 E.0089 +;WIDTH:0.54765 +G1 F14859.445 +G1 X-28.912 Y16.416 E.00927 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.469 Y15.682 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.451452 +G1 F15000 +G1 X-26.521 Y15.682 E.0017 +G1 X-26.546 Y15.726 E.0017 +G1 X-26.521 Y15.77 E.0017 +G1 X-26.464 Y15.761 E.00191 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.435 Y16.119 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-26.209 Y15.456 E.03131 +G1 X-26.501 Y15.077 E.01469 +G1 X-26.859 Y15.532 E.01778 +G1 X-27.59 Y16.119 E.0288 +G1 X-25.475 Y16.119 E.06498 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.592 Y16.461 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-24.309 Y16.267 E.02284 +G1 X-25.204 Y15.824 E.03066 +G1 X-25.961 Y15.171 E.03072 +G1 X-26.434 Y14.528 E.02453 +G1 X-26.5 Y14.392 E.00466 +G1 X-27.067 Y15.216 E.03072 +G1 X-27.929 Y15.908 E.03397 +;WIDTH:0.443233 +G1 X-28.225 Y16.065 E.01092 +;WIDTH:0.489701 +G1 X-28.52 Y16.223 E.01219 +;WIDTH:0.536169 +G1 X-28.815 Y16.38 E.01346 +;WIDTH:0.573482 +G1 F14135.967 +G1 X-28.872 Y16.412 E.00282 +;WIDTH:0.566616 +G1 F14321.292 +G1 X-28.618 Y16.433 E.01091 +;WIDTH:0.524726 +G1 F15000 +G1 X-28.363 Y16.454 E.01004 +;WIDTH:0.482835 +G1 X-28.108 Y16.475 E.00916 +;WIDTH:0.422387 +G1 X-27.853 Y16.496 E.00791 +G1 X-23.664 Y16.496 E.12954 +G1 X-23.628 Y16.478 E.00125 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-23.664 Y16.496 +G1 X-25.624 Y16.496 +;WIPE_END +G1 X-18.691 Y13.303 Z2 F30000 +G1 X33.73 Y-10.844 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.444624 +G1 F12000 +G1 X33.723 Y-10.749 E.00314 +;WIDTH:0.394811 +G1 X33.274 Y-9.405 E.04063 +;WIDTH:0.419999 +G1 X33.111 Y-9.272 E.00646 +G1 X33.008 Y-9.273 E.00315 +G1 X32.846 Y-9.408 E.00648 +;WIDTH:0.397336 +G1 X32.398 Y-10.748 E.04083 +;WIDTH:0.446916 +G1 X32.391 Y-10.844 E.00315 +;WIDTH:0.496496 +G1 X32.384 Y-10.939 E.00353 +G1 X32.445 Y-10.953 E.0023 +;WIDTH:0.462728 +G1 X32.506 Y-10.966 E.00213 +;WIDTH:0.42896 +G1 X32.567 Y-10.98 E.00196 +;WIDTH:0.395192 +G1 X33.556 Y-10.98 E.0284 +;WIDTH:0.444815 +G1 X33.646 Y-10.96 E.00302 +;WIDTH:0.494437 +G1 X33.736 Y-10.94 E.0034 +G1 X33.732 Y-10.884 E.00206 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X33.723 Y-10.749 +G1 X33.274 Y-9.405 +G1 X33.111 Y-9.272 +G1 X33.008 Y-9.273 +G1 X32.905 Y-9.359 +;WIPE_END +G1 X29.292 Y-12.168 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.473615 +G1 F12000 +G1 X29.277 Y-12.094 E.00264 +;WIDTH:0.444427 +G1 X29.263 Y-12.02 E.00246 +;WIDTH:0.415238 +G1 X29.262 Y-8.445 E.10846 +;WIDTH:0.414686 +G1 X28.89 Y-8.443 E.01126 +G1 X28.89 Y-8.63 E.00566 +;WIDTH:0.415238 +G1 X28.89 Y-12.02 E.10285 +;WIDTH:0.444427 +G1 X28.905 Y-12.275 E.00836 +;WIDTH:0.473615 +G1 X28.92 Y-12.53 E.00897 +G1 X29.184 Y-12.547 E.00931 +;WIDTH:0.439649 +G1 X29.449 Y-12.564 E.00857 +;WIDTH:0.405683 +G1 X31.096 Y-12.573 E.04869 +;WIDTH:0.371896 +G1 X31.182 Y-12.582 E.00233 +;WIDTH:0.338177 +G1 X31.269 Y-12.592 E.00209 +;WIDTH:0.339922 +G1 X31.443 Y-12.579 E.00422 +;WIDTH:0.375385 +G1 X31.616 Y-12.566 E.00472 +;WIDTH:0.410848 +G1 X31.79 Y-12.553 E.00522 +G1 X31.848 Y-12.384 E.00534 +;WIDTH:0.408958 +G1 X32.163 Y-11.451 E.02937 +;WIDTH:0.449697 +G1 X32.215 Y-11.371 E.00318 +;WIDTH:0.496496 +G1 X32.268 Y-11.291 E.00354 +G1 X32.367 Y-11.305 E.00372 +;WIDTH:0.462728 +G1 X32.467 Y-11.319 E.00344 +;WIDTH:0.42896 +G1 X32.567 Y-11.332 E.00316 +;WIDTH:0.395192 +G1 X33.556 Y-11.332 E.0284 +;WIDTH:0.444815 +G1 X33.704 Y-11.312 E.00488 +;WIDTH:0.494437 +G1 X33.852 Y-11.292 E.00549 +G1 X33.904 Y-11.372 E.00352 +;WIDTH:0.4474 +G1 X33.956 Y-11.452 E.00315 +;WIDTH:0.405913 +G1 X34.32 Y-12.537 E.03386 +G1 X34.714 Y-12.564 E.01168 +G1 X34.295 Y-11.338 E.03834 +;WIDTH:0.4474 +G1 X34.212 Y-11.17 E.00616 +;WIDTH:0.494437 +G1 X34.13 Y-11.003 E.00687 +G1 X34.093 Y-10.819 E.00688 +;WIDTH:0.444624 +G1 X34.056 Y-10.636 E.00612 +;WIDTH:0.394811 +G1 X33.602 Y-9.302 E.0404 +;WIDTH:0.419999 +G1 X33.29 Y-8.446 E.02801 +G1 X32.836 Y-8.447 E.01394 +G1 X32.524 Y-9.292 E.02769 +;WIDTH:0.397336 +G1 X32.063 Y-10.635 E.041 +;WIDTH:0.446916 +G1 X32.025 Y-10.817 E.00613 +;WIDTH:0.496496 +G1 X31.988 Y-11 E.00688 +G1 X31.906 Y-11.165 E.00684 +;WIDTH:0.449691 +G1 X31.823 Y-11.331 E.00613 +;WIDTH:0.408958 +G1 X31.501 Y-12.266 E.0295 +G1 X31.423 Y-12.303 E.00257 +;WIDTH:0.361241 +G1 X31.346 Y-12.34 E.00223 +;WIDTH:0.337888 +G1 X31.235 Y-12.32 E.00269 +;WIDTH:0.371752 +G1 X31.168 Y-12.263 E.00236 +;WIDTH:0.405615 +G1 X31.102 Y-12.205 E.00261 +G1 X31.009 Y-12.202 E.00273 +G1 X29.449 Y-12.202 E.04612 +;WIDTH:0.439649 +G1 X29.37 Y-12.185 E.0026 +;WIDTH:0.473615 +G1 X29.331 Y-12.176 E.00141 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X29.277 Y-12.094 +G1 X29.263 Y-12.02 +G1 X29.262 Y-10.193 +;WIPE_END +G1 X33.106 Y-8.907 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.395998 +G1 F18000 +G1 X33.084 Y-8.869 E.00127 +G1 X33.018 Y-8.907 E.0022 +G1 X33.084 Y-8.945 E.0022 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X33.018 Y-8.907 +G1 X33.084 Y-8.869 +G1 X33.106 Y-8.907 +;WIPE_END +G1 X25.819 Y-10.92 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.498524 +G1 F12000 +G1 X25.867 Y-10.92 E.00178 +;WIDTH:0.465107 +G1 X25.915 Y-10.92 E.00165 +;WIDTH:0.43169 +G1 X25.963 Y-10.919 E.00152 +;WIDTH:0.408913 +G1 X27.034 Y-10.907 E.03195 +G1 X27.458 Y-10.786 E.01316 +;WIDTH:0.405894 +G1 X27.729 Y-10.577 E.01012 +;WIDTH:0.411627 +G1 X27.889 Y-10.381 E.00759 +;WIDTH:0.414854 +G1 X28.049 Y-9.916 E.01491 +;WIDTH:0.41423 +G1 X28.037 Y-9.399 E.01566 +;WIDTH:0.423885 +G1 X27.923 Y-9.007 E.01268 +;WIDTH:0.424093 +G1 X27.644 Y-8.673 E.01349 +G1 X27.314 Y-8.508 E.01149 +;WIDTH:0.40675 +G1 X26.796 Y-8.436 E.0155 +;WIDTH:0.401192 +G1 X25.962 Y-8.436 E.02435 +;WIDTH:0.435091 +G1 X25.699 Y-8.453 E.0084 +;WIDTH:0.46899 +G1 X25.437 Y-8.47 E.00912 +G1 X25.423 Y-8.722 E.00874 +;WIDTH:0.440504 +G1 X25.408 Y-8.973 E.00816 +;WIDTH:0.41303 +G1 X25.406 Y-10.386 E.04262 +;WIDTH:0.455777 +G1 X25.428 Y-10.564 E.00602 +;WIDTH:0.498524 +G1 X25.449 Y-10.742 E.00664 +G1 X25.427 Y-10.919 E.00665 +;WIDTH:0.456032 +G1 X25.406 Y-11.097 E.00603 +;WIDTH:0.414454 +G1 X25.404 Y-12.558 E.04423 +G1 X25.776 Y-12.559 E.01125 +G1 X25.776 Y-11.097 E.04427 +;WIDTH:0.456032 +G1 X25.798 Y-11.008 E.00308 +;WIDTH:0.498524 +G1 X25.81 Y-10.958 E.00191 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X25.867 Y-10.92 +G1 X25.915 Y-10.92 +G1 X25.963 Y-10.919 +G1 X27.034 Y-10.907 +G1 X27.458 Y-10.786 +G1 X27.714 Y-10.589 +;WIPE_END +G1 X27.519 Y-9.072 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.40791 +G1 F12000 +G1 X27.213 Y-8.858 E.01111 +;WIDTH:0.40675 +G1 X26.796 Y-8.795 E.01251 +;WIDTH:0.401192 +G1 X25.962 Y-8.795 E.02435 +;WIDTH:0.435091 +G1 X25.884 Y-8.812 E.00255 +;WIDTH:0.46899 +G1 X25.806 Y-8.829 E.00277 +G1 X25.791 Y-8.901 E.00257 +;WIDTH:0.440504 +G1 X25.777 Y-8.974 E.0024 +;WIDTH:0.41303 +G1 X25.777 Y-10.386 E.04261 +;WIDTH:0.455777 +G1 X25.798 Y-10.475 E.00307 +;WIDTH:0.498524 +G1 X25.819 Y-10.564 E.00339 +G1 X26.151 Y-10.564 E.01234 +;WIDTH:0.464126 +G1 X26.484 Y-10.563 E.0114 +;WIDTH:0.429728 +G1 X26.816 Y-10.563 E.01047 +;WIDTH:0.407478 +G1 X27.283 Y-10.47 E.01413 +;WIDTH:0.411627 +G1 X27.555 Y-10.227 E.01098 +;WIDTH:0.414897 +G1 X27.68 Y-9.869 E.01148 +G1 X27.666 Y-9.401 E.01422 +;WIDTH:0.414203 +G1 X27.535 Y-9.109 E.00967 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X27.213 Y-8.858 +G1 X26.796 Y-8.795 +G1 X25.962 Y-8.795 +G1 X25.884 Y-8.812 +G1 X25.806 Y-8.829 +G1 X25.791 Y-8.901 +G1 X25.777 Y-8.974 +G1 X25.777 Y-9.002 +;WIPE_END +G1 X20.929 Y-9.01 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.399844 +G1 F12000 +G1 X21.178 Y-8.818 E.00916 +;WIDTH:0.393679 +G1 X21.54 Y-8.725 E.01068 +;WIDTH:0.386843 +G1 X22.11 Y-8.756 E.01601 +;WIDTH:0.41413 +G1 X22.368 Y-8.858 E.00838 +;WIDTH:0.42194 +G1 X22.52 Y-8.987 E.00616 +G1 X22.694 Y-9.316 E.01149 +;WIDTH:0.410062 +G1 X23.063 Y-9.247 E.01121 +G1 X22.99 Y-9.051 E.00626 +;WIDTH:0.42194 +G1 X22.833 Y-8.773 E.00985 +G1 X22.52 Y-8.521 E.01243 +;WIDTH:0.41413 +G1 X22.14 Y-8.414 E.01195 +;WIDTH:0.386843 +G1 X21.537 Y-8.381 E.01691 +;WIDTH:0.391859 +G1 X21.105 Y-8.465 E.01251 +;WIDTH:0.400603 +G1 X20.749 Y-8.686 E.01222 +;WIDTH:0.405269 +G1 X20.586 Y-8.885 E.00759 +;WIDTH:0.406453 +G1 X20.459 Y-9.232 E.01096 +;WIDTH:0.420055 +G1 X20.472 Y-9.711 E.01473 +;WIDTH:0.428755 +G1 X20.618 Y-10.038 E.01126 +;WIDTH:0.435397 +G1 X20.896 Y-10.301 E.01223 +G1 X21.371 Y-10.517 E.0167 +;WIDTH:0.432904 +G1 X22.258 Y-10.775 E.02936 +;WIDTH:0.431442 +G1 X22.558 Y-10.938 E.01079 +;WIDTH:0.428768 +G1 X22.732 Y-11.142 E.00844 +;WIDTH:0.423659 +G1 X22.791 Y-11.545 E.01262 +;WIDTH:0.411683 +G1 X22.716 Y-11.832 E.00891 +;WIDTH:0.406406 +G1 X22.56 Y-12.039 E.00769 +G1 X22.205 Y-12.22 E.01181 +;WIDTH:0.395219 +G1 X21.654 Y-12.268 E.01588 +;WIDTH:0.411863 +G1 X21.205 Y-12.182 E.01374 +G1 X20.942 Y-12.051 E.00881 +;WIDTH:0.419891 +G1 X20.77 Y-11.855 E.00803 +G1 X20.652 Y-11.576 E.00928 +;WIDTH:0.419624 +G1 X20.294 Y-11.649 E.01122 +G1 X20.387 Y-11.932 E.00916 +;WIDTH:0.416276 +G1 X20.614 Y-12.252 E.01194 +;WIDTH:0.414978 +G1 X20.9 Y-12.456 E.01064 +;WIDTH:0.407776 +G1 X21.378 Y-12.597 E.01481 +;WIDTH:0.407462 +G1 X21.672 Y-12.61 E.00874 +;WIDTH:0.393485 +G1 X22.311 Y-12.556 E.01833 +;WIDTH:0.394686 +G1 X22.715 Y-12.364 E.01282 +;WIDTH:0.408514 +G1 X22.949 Y-12.152 E.00942 +;WIDTH:0.411939 +G1 X23.127 Y-11.761 E.01291 +;WIDTH:0.42022 +G1 X23.161 Y-11.428 E.0103 +;WIDTH:0.423691 +G1 X23.076 Y-10.976 E.01425 +;WIDTH:0.431256 +G1 X22.841 Y-10.664 E.01237 +;WIDTH:0.431443 +G1 X22.39 Y-10.409 E.0164 +;WIDTH:0.432904 +G1 X21.48 Y-10.143 E.03011 +G1 X21.071 Y-9.956 E.01431 +;WIDTH:0.429236 +G1 X20.868 Y-9.726 E.00966 +;WIDTH:0.410471 +G1 X20.811 Y-9.329 E.01199 +;WIDTH:0.403719 +G1 X20.915 Y-9.048 E.00882 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X21.178 Y-8.818 +G1 X21.54 Y-8.725 +G1 X22.11 Y-8.756 +G1 X22.368 Y-8.858 +M73 P88 R0 +G1 X22.52 Y-8.987 +M73 P88 R0 +G1 X22.627 Y-9.189 +;WIPE_END +G1 X17.06 Y-10.785 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.460637 +G1 F12000 +G1 X17.083 Y-10.692 E.00323 +;WIDTH:0.506372 +G1 X17.106 Y-10.6 E.00359 +;WIDTH:0.506582 +G1 X19.075 Y-10.6 E.07439 +G1 X19.098 Y-10.692 E.00357 +;WIDTH:0.46094 +G1 X19.12 Y-10.784 E.00322 +;WIDTH:0.415298 +G1 X19.128 Y-12.559 E.05387 +;WIDTH:0.410829 +G1 X19.495 Y-12.542 E.01102 +G1 X19.495 Y-12.366 E.00527 +;WIDTH:0.415298 +G1 X19.493 Y-10.783 E.04806 +;WIDTH:0.46094 +G1 X19.47 Y-10.599 E.00629 +;WIDTH:0.506582 +G1 X19.447 Y-10.416 E.00698 +G1 X19.47 Y-10.232 E.00702 +;WIDTH:0.460909 +G1 X19.493 Y-10.047 E.00633 +;WIDTH:0.415235 +G1 X19.493 Y-8.46 E.04815 +G1 X19.121 Y-8.447 E.0113 +G1 X19.121 Y-10.047 E.04854 +;WIDTH:0.460909 +G1 X19.098 Y-10.139 E.00324 +;WIDTH:0.506582 +G1 X19.075 Y-10.232 E.00359 +G1 X17.106 Y-10.231 E.07439 +;WIDTH:0.506372 +G1 X17.083 Y-10.14 E.00357 +;WIDTH:0.460723 +G1 X17.06 Y-10.048 E.00321 +;WIDTH:0.415074 +G1 X17.053 Y-8.444 E.04864 +;WIDTH:0.41088 +G1 X16.686 Y-8.461 E.01102 +G1 X16.686 Y-8.637 E.00527 +;WIDTH:0.415074 +G1 X16.688 Y-10.049 E.04284 +;WIDTH:0.460723 +G1 X16.711 Y-10.233 E.00629 +;WIDTH:0.506372 +G1 X16.734 Y-10.416 E.00697 +G1 X16.711 Y-10.6 E.00702 +;WIDTH:0.460637 +G1 X16.688 Y-10.785 E.00633 +;WIDTH:0.414902 +G1 X16.689 Y-12.56 E.05381 +;WIDTH:0.414681 +G1 X17.06 Y-12.56 E.01126 +;WIDTH:0.414902 +G1 X17.06 Y-10.825 E.05259 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X17.083 Y-10.692 +G1 X17.106 Y-10.6 +G1 X18.877 Y-10.6 +;WIPE_END +G1 X11.619 Y-12.963 Z2 F30000 +G1 X11.237 Y-13.087 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.505568 +G1 F12000 +G1 X11.702 Y-13.095 E.01753 +G1 X11.704 Y-12.421 E.02541 +;WIDTH:0.504453 +G1 X11.245 Y-12.413 E.01727 +;WIDTH:0.505568 +G1 X11.237 Y-13.047 E.02391 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X11.702 Y-13.095 +G1 X11.704 Y-12.421 +G1 X11.245 Y-12.413 +G1 X11.24 Y-12.813 +;WIPE_END +G1 X8.199 Y-12.669 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.4827 +G1 F12000 +G1 X7.188 Y-12.669 E.03622 +G1 X7.183 Y-13.109 E.01576 +G1 X8.199 Y-13.108 E.0364 +;WIDTH:0.519618 +G1 X8.346 Y-13.09 E.00576 +;WIDTH:0.556561 +G1 X8.493 Y-13.072 E.00621 +;WIDTH:0.593504 +G1 X8.64 Y-13.053 E.00666 +G1 X8.788 Y-13.072 E.00666 +;WIDTH:0.556552 +G1 X8.935 Y-13.09 E.00621 +;WIDTH:0.519599 +G1 X9.082 Y-13.108 E.00576 +;WIDTH:0.482646 +G1 X10.046 Y-13.108 E.03454 +;WIDTH:0.482621 +G1 X10.047 Y-12.669 E.01575 +G1 X9.082 Y-12.669 E.03457 +;WIDTH:0.519599 +G1 X9.008 Y-12.65 E.00295 +;WIDTH:0.556552 +G1 X8.935 Y-12.632 E.00318 +;WIDTH:0.593504 +G1 X8.861 Y-12.613 E.0034 +G1 X8.861 Y-12.558 E.00246 +;WIDTH:0.557161 +G1 X8.861 Y-12.504 E.0023 +;WIDTH:0.520818 +G1 X8.861 Y-12.449 E.00214 +;WIDTH:0.484474 +G1 X8.86 Y-8.324 E.14841 +;WIDTH:0.483999 +G1 X8.876 Y-7.927 E.01428 +;WIDTH:0.451999 +G1 X8.892 Y-7.53 E.01325 +;WIDTH:0.419999 +G1 X8.395 Y-7.532 E.01528 +G1 X8.255 Y-7.644 E.00551 +;WIDTH:0.42972 +G1 X7.828 Y-7.996 E.01744 +;WIDTH:0.435163 +G1 X7.301 Y-8.433 E.0219 +G1 X7.296 Y-8.945 E.01634 +G1 X8.074 Y-8.295 E.03241 +;WIDTH:0.42972 +G1 X8.297 Y-8.282 E.00703 +;WIDTH:0.451999 +G1 X8.358 Y-8.362 E.00337 +;WIDTH:0.483999 +G1 X8.419 Y-8.443 E.00363 +;WIDTH:0.484474 +G1 X8.42 Y-12.613 E.15006 +G1 X8.238 Y-12.659 E.00675 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X7.188 Y-12.669 +G1 X7.183 Y-13.109 +G1 X7.694 Y-13.109 +;WIPE_END +G1 X8.552 Y-7.948 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.447808 +G1 F18000 +G1 X8.527 Y-7.905 E.00167 +G1 X8.476 Y-7.905 E.00167 +G1 X8.451 Y-7.948 E.00167 +G1 X8.476 Y-7.992 E.00167 +G1 X8.527 Y-7.992 E.00167 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X8.476 Y-7.992 +G1 X8.451 Y-7.948 +G1 X8.476 Y-7.905 +G1 X8.527 Y-7.905 +G1 X8.552 Y-7.948 +;WIPE_END +G1 X11.977 Y-1.128 Z2 F30000 +G1 X13.492 Y1.888 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.420914 +G1 F12000 +G1 X13.139 Y2.059 E.01208 +;WIDTH:0.417813 +G1 X12.715 Y2.143 E.01319 +G1 X12.267 Y2.125 E.01372 +;WIDTH:0.412671 +G1 X11.811 Y2.004 E.01421 +;WIDTH:0.418611 +G1 X11.431 Y1.766 E.01373 +;WIDTH:0.422719 +G1 X11.144 Y1.433 E.0136 +G1 X10.932 Y.988 E.01524 +G1 X10.82 Y.462 E.01665 +;WIDTH:0.420893 +G1 X10.815 Y-.281 E.02289 +;WIDTH:0.419213 +G1 X10.916 Y-.88 E.01862 +;WIDTH:0.419359 +G1 X11.087 Y-1.301 E.01394 +;WIDTH:0.421865 +G1 X11.326 Y-1.651 E.0131 +G1 X11.731 Y-1.97 E.01591 +;WIDTH:0.415976 +G1 X12.222 Y-2.129 E.0157 +;WIDTH:0.414846 +G1 X12.704 Y-2.148 E.01462 +;WIDTH:0.408779 +G1 X13.197 Y-2.035 E.01505 +;WIDTH:0.406423 +G1 X13.582 Y-1.802 E.01335 +;WIDTH:0.414974 +G1 X13.916 Y-1.434 E.01507 +;WIDTH:0.420858 +G1 X14.071 Y-1.151 E.00994 +;WIDTH:0.424245 +G1 X14.222 Y-.671 E.01563 +;WIDTH:0.431012 +G1 X14.283 Y-.149 E.01663 +G1 X14.279 Y.225 E.0118 +;WIDTH:0.431489 +G1 X14.212 Y.734 E.01628 +G1 X14.017 Y1.291 E.01868 +;WIDTH:0.424802 +G1 X13.755 Y1.663 E.01414 +;WIDTH:0.421164 +G1 X13.522 Y1.862 E.00944 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X13.139 Y2.059 +G1 X12.715 Y2.143 +G1 X12.267 Y2.125 +G1 X11.811 Y2.004 +G1 X11.627 Y1.889 +;WIPE_END +G1 X13.069 Y-1.694 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.40883 +G1 F12000 +G1 X13.352 Y-1.522 E.00987 +;WIDTH:0.414192 +G1 X13.658 Y-1.15 E.01455 +G1 X13.796 Y-.817 E.01091 +;WIDTH:0.417882 +G1 X13.88 Y-.419 E.01243 +;WIDTH:0.420862 +G1 X13.901 Y.226 E.01989 +G1 X13.798 Y.816 E.01846 +;WIDTH:0.422948 +G1 X13.638 Y1.19 E.01259 +G1 X13.427 Y1.46 E.0106 +;WIDTH:0.421246 +G1 X13.166 Y1.646 E.00989 +;WIDTH:0.410015 +G1 X12.856 Y1.755 E.00982 +;WIDTH:0.405976 +G1 X12.332 Y1.772 E.01552 +;WIDTH:0.414683 +G1 X11.869 Y1.624 E.01472 +;WIDTH:0.417411 +G1 X11.619 Y1.427 E.00972 +;WIDTH:0.419064 +G1 X11.421 Y1.159 E.01021 +;WIDTH:0.419846 +G1 X11.281 Y.832 E.01093 +;WIDTH:0.420893 +G1 X11.197 Y.443 E.01226 +G1 X11.18 Y-.191 E.01954 +;WIDTH:0.419213 +G1 X11.281 Y-.791 E.01864 +;WIDTH:0.419787 +G1 X11.445 Y-1.17 E.01269 +;WIDTH:0.428747 +G1 X11.656 Y-1.45 E.01103 +;WIDTH:0.421804 +G1 X11.933 Y-1.652 E.01058 +;WIDTH:0.415705 +G1 X12.41 Y-1.784 E.01503 +;WIDTH:0.403665 +G1 X12.74 Y-1.777 E.00971 +;WIDTH:0.407409 +G1 X13.03 Y-1.704 E.00889 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X13.352 Y-1.522 +G1 X13.658 Y-1.15 +G1 X13.796 Y-.817 +G1 X13.88 Y-.419 +G1 X13.893 Y-.037 +;WIPE_END +G1 X20.897 Y-.255 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.398471 +G1 F12000 +G1 X20.437 Y1.122 E.04206 +;WIDTH:0.419999 +G1 X20.272 Y1.254 E.00649 +G1 X20.015 Y1.149 E.00852 +;WIDTH:0.401646 +G1 X19.547 Y-.255 E.04326 +;WIDTH:0.451588 +G1 X19.54 Y-.351 E.00321 +;WIDTH:0.501529 +G1 X19.533 Y-.447 E.00361 +G1 X19.594 Y-.461 E.00235 +;WIDTH:0.467286 +G1 X19.656 Y-.475 E.00218 +;WIDTH:0.433043 +G1 X19.717 Y-.489 E.002 +;WIDTH:0.3988 +G1 X20.728 Y-.489 E.02932 +;WIDTH:0.432237 +G1 X20.789 Y-.475 E.00197 +;WIDTH:0.465674 +G1 X20.849 Y-.462 E.00214 +;WIDTH:0.499111 +G1 X20.91 Y-.448 E.00231 +G1 X20.906 Y-.384 E.0024 +;WIDTH:0.465565 +G1 X20.901 Y-.319 E.00222 +;WIDTH:0.432018 +G1 X20.9 Y-.295 E.00078 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X20.437 Y1.122 +G1 X20.272 Y1.254 +G1 X20.015 Y1.149 +G1 X20.008 Y1.129 +;WIPE_END +G1 X15.523 Y.044 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.504514 +G1 F12000 +G1 X15.882 Y.044 E.01351 +;WIDTH:0.470828 +G1 X16.241 Y.045 E.01252 +;WIDTH:0.437142 +G1 X16.6 Y.046 E.01154 +;WIDTH:0.444126 +G1 X16.747 Y.03 E.00483 +;WIDTH:0.484796 +G1 X16.894 Y.015 E.00532 +G1 X17.096 Y.113 E.0081 +;WIDTH:0.451129 +G1 X17.299 Y.211 E.00749 +;WIDTH:0.417462 +G1 X17.51 Y.45 E.00974 +;WIDTH:0.416009 +G1 X17.597 Y.72 E.00863 +;WIDTH:0.427554 +G1 X17.589 Y1.117 E.01243 +G1 X17.461 Y1.429 E.01056 +;WIDTH:0.429296 +G1 X17.176 Y1.65 E.01138 +;WIDTH:0.416342 +G1 X16.712 Y1.734 E.01433 +;WIDTH:0.404898 +G1 X15.668 Y1.734 E.03081 +;WIDTH:0.440215 +G1 X15.588 Y1.716 E.00265 +;WIDTH:0.475531 +G1 X15.508 Y1.699 E.00289 +G1 X15.494 Y1.626 E.00262 +;WIDTH:0.447309 +G1 X15.48 Y1.553 E.00244 +;WIDTH:0.419086 +G1 X15.48 Y.223 E.04076 +;WIDTH:0.461709 +G1 X15.501 Y.133 E.00315 +;WIDTH:0.504514 +G1 X15.513 Y.083 E.00196 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.523 Y-.315 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F12000 +G1 X15.566 Y-.315 E.00162 +;WIDTH:0.47074 +G1 X15.609 Y-.315 E.0015 +;WIDTH:0.436966 +G1 X15.652 Y-.315 E.00139 +;WIDTH:0.403455 +G1 X16.6 Y-.314 E.02787 +;WIDTH:0.447216 +G1 X16.686 Y-.333 E.00289 +;WIDTH:0.490976 +G1 X16.772 Y-.351 E.0032 +G1 X16.828 Y-.498 E.00574 +;WIDTH:0.4462 +G1 X16.885 Y-.644 E.00517 +;WIDTH:0.414136 +G1 X17.673 Y-2.094 E.04993 +G1 X18.1 Y-2.093 E.0129 +G1 X17.199 Y-.472 E.05612 +;WIDTH:0.4462 +G1 X17.174 Y-.384 E.00301 +;WIDTH:0.490976 +G1 X17.15 Y-.295 E.00334 +G1 X17.334 Y-.191 E.00773 +;WIDTH:0.454219 +G1 X17.518 Y-.087 E.0071 +;WIDTH:0.417462 +G1 X17.754 Y.148 E.01018 +;WIDTH:0.421279 +G1 X17.94 Y.522 E.01285 +G1 X17.985 Y1.005 E.01496 +;WIDTH:0.428094 +G1 X17.897 Y1.427 E.01355 +;WIDTH:0.429296 +G1 X17.663 Y1.78 E.01332 +G1 X17.262 Y2.013 E.01461 +;WIDTH:0.416342 +G1 X16.712 Y2.096 E.01692 +;WIDTH:0.404898 +G1 X15.668 Y2.096 E.03081 +;WIDTH:0.440215 +G1 X15.4 Y2.078 E.0087 +;WIDTH:0.475531 +G1 X15.132 Y2.061 E.00947 +G1 X15.117 Y1.807 E.00896 +;WIDTH:0.447309 +G1 X15.103 Y1.553 E.00838 +;WIDTH:0.419086 +G1 X15.104 Y.223 E.04076 +;WIDTH:0.461709 +G1 X15.125 Y.044 E.00617 +;WIDTH:0.504514 +G1 X15.147 Y-.136 E.0068 +G1 X15.125 Y-.315 E.0068 +;WIDTH:0.461659 +G1 X15.104 Y-.495 E.00617 +;WIDTH:0.418804 +G1 X15.104 Y-2.092 E.04892 +;WIDTH:0.418611 +G1 X15.48 Y-2.092 E.0115 +;WIDTH:0.418804 +G1 X15.48 Y-.495 E.04892 +;WIDTH:0.461659 +G1 X15.501 Y-.405 E.00315 +;WIDTH:0.504514 +G1 X15.513 Y-.354 E.00197 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X15.566 Y-.315 +G1 X15.609 Y-.315 +G1 X15.652 Y-.315 +G1 X16.6 Y-.314 +G1 X16.686 Y-.333 +G1 X16.772 Y-.351 +G1 X16.828 Y-.498 +G1 X16.885 Y-.644 +G1 X17.081 Y-1.005 +;WIPE_END +G1 X21.027 Y-.804 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.499111 +G1 F12000 +G1 X21.079 Y-.884 E.00356 +;WIDTH:0.451619 +G1 X21.132 Y-.964 E.00319 +;WIDTH:0.405379 +G1 X21.52 Y-2.099 E.03541 +G1 X21.903 Y-2.098 E.01132 +G1 X21.474 Y-.847 E.03905 +;WIDTH:0.451619 +G1 X21.391 Y-.679 E.00623 +;WIDTH:0.499111 +G1 X21.308 Y-.512 E.00696 +G1 X21.283 Y-.388 E.00468 +;WIDTH:0.465565 +G1 X21.258 Y-.265 E.00433 +;WIDTH:0.432018 +G1 X21.234 Y-.141 E.00399 +;WIDTH:0.398471 +G1 X20.771 Y1.218 E.0416 +;WIDTH:0.419999 +G1 X20.456 Y2.087 E.02843 +G1 X20.013 Y2.088 E.01359 +G1 X19.678 Y1.236 E.02815 +;WIDTH:0.401646 +G1 X19.207 Y-.14 E.04251 +;WIDTH:0.451588 +G1 X19.169 Y-.324 E.00626 +;WIDTH:0.501529 +G1 X19.132 Y-.509 E.00703 +G1 X19.048 Y-.678 E.00707 +;WIDTH:0.454248 +G1 X18.964 Y-.848 E.00634 +;WIDTH:0.413019 +G1 X18.536 Y-2.092 E.03968 +G1 X18.931 Y-2.086 E.0119 +G1 X19.309 Y-.965 E.03567 +;WIDTH:0.454248 +G1 X19.362 Y-.884 E.00324 +;WIDTH:0.501529 +G1 X19.415 Y-.803 E.00362 +G1 X19.515 Y-.817 E.0038 +;WIDTH:0.467286 +G1 X19.616 Y-.831 E.00352 +;WIDTH:0.433043 +G1 X19.717 Y-.845 E.00323 +;WIDTH:0.3988 +G1 X20.728 Y-.845 E.02932 +;WIDTH:0.432237 +G1 X20.828 Y-.831 E.00319 +;WIDTH:0.465674 +G1 X20.927 Y-.818 E.00346 +;WIDTH:0.499111 +G1 X20.987 Y-.81 E.00225 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X21.079 Y-.884 +G1 X21.132 Y-.964 +G1 X21.52 Y-2.099 +G1 X21.903 Y-2.098 +G1 X21.837 Y-1.905 +;WIPE_END +G1 X20.268 Y1.625 Z2 F30000 +G1 Z1.6 +M73 P89 R0 +G1 E.8 F2100 +M73 P89 R0 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.408412 +G1 F18000 +G1 X20.245 Y1.665 E.00136 +G1 X20.176 Y1.625 E.00236 +G1 X20.245 Y1.585 E.00236 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X20.176 Y1.625 +G1 X20.245 Y1.665 +G1 X20.268 Y1.625 +;WIPE_END +G1 X23.054 Y.932 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.419999 +G1 F12000 +G1 X23.254 Y.831 E.00686 +;WIDTH:0.393919 +G1 X24.246 Y-1.019 E.06006 +;WIDTH:0.419999 +G1 X24.835 Y-2.091 E.03757 +G1 X25.331 Y-2.087 E.01523 +G1 X25.344 Y-.743 E.04131 +;WIDTH:0.394343 +G1 X25.344 Y2.085 E.08098 +;WIDTH:0.393307 +G1 X24.999 Y2.098 E.00985 +G1 X24.994 Y1.916 E.00518 +;WIDTH:0.394343 +G1 X24.992 Y-.743 E.07614 +;WIDTH:0.419999 +G1 X24.863 Y-.925 E.00687 +G1 X24.757 Y-.952 E.00338 +G1 X24.555 Y-.853 E.0069 +;WIDTH:0.393919 +G1 X23.561 Y.996 E.06005 +;WIDTH:0.419999 +G1 X22.959 Y2.088 E.03832 +G1 X22.484 Y2.088 E.01459 +G1 X22.47 Y.722 E.04198 +;WIDTH:0.391746 +G1 X22.47 Y-2.092 E.07998 +;WIDTH:0.390164 +G1 X22.817 Y-2.104 E.00983 +G1 X22.817 Y-1.924 E.00509 +;WIDTH:0.391746 +G1 X22.819 Y.722 E.07522 +;WIDTH:0.419999 +G1 X22.947 Y.905 E.00687 +G1 X23.015 Y.922 E.00215 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X23.254 Y.831 +G1 X24.078 Y-.706 +;WIPE_END +G1 X24.969 Y-1.346 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.504432 +G1 F16250.931 +G1 X24.941 Y-1.296 E.00217 +G1 X24.883 Y-1.296 E.00217 +G1 X24.854 Y-1.346 E.00217 +G1 X24.883 Y-1.396 E.00217 +G1 X24.941 Y-1.396 E.00217 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F16250.931 +G1 X24.883 Y-1.396 +G1 X24.854 Y-1.346 +G1 X24.883 Y-1.296 +G1 X24.941 Y-1.296 +G1 X24.969 Y-1.346 +;WIPE_END +G1 X22.956 Y1.321 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.496724 +G1 F16526.958 +G1 X22.928 Y1.37 E.0021 +G1 X22.871 Y1.37 E.0021 +G1 X22.843 Y1.321 E.0021 +G1 X22.871 Y1.272 E.0021 +G1 X22.928 Y1.272 E.0021 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F16526.958 +G1 X22.871 Y1.272 +G1 X22.843 Y1.321 +G1 X22.871 Y1.37 +G1 X22.928 Y1.37 +G1 X22.956 Y1.321 +;WIPE_END +G1 X29.115 Y-.457 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.468232 +G1 F12000 +G1 X29.133 Y-.537 E.00285 +;WIDTH:0.432489 +G1 X29.151 Y-.617 E.00261 +;WIDTH:0.417567 +G1 X29.144 Y-1.297 E.02077 +G1 X28.815 Y-1.564 E.01291 +;WIDTH:0.418396 +G1 X28.467 Y-1.699 E.01143 +G1 X28.045 Y-1.786 E.01318 +;WIDTH:0.412019 +G1 X27.664 Y-1.766 E.01149 +;WIDTH:0.414935 +G1 X27.3 Y-1.647 E.0116 +;WIDTH:0.420987 +G1 X27.033 Y-1.452 E.01017 +;WIDTH:0.428685 +G1 X26.897 Y-1.289 E.0067 +G1 X26.722 Y-.983 E.01106 +;WIDTH:0.427869 +G1 X26.603 Y-.615 E.01214 +;WIDTH:0.426479 +G1 X26.534 Y.006 E.01953 +;WIDTH:0.428746 +G1 X26.577 Y.514 E.01603 +G1 X26.67 Y.909 E.01276 +;WIDTH:0.426487 +G1 X26.821 Y1.225 E.01092 +;WIDTH:0.425737 +G1 X27.015 Y1.464 E.00961 +;WIDTH:0.423094 +G1 X27.366 Y1.693 E.013 +;WIDTH:0.408615 +G1 X27.695 Y1.774 E.01009 +;WIDTH:0.404655 +G1 X28.203 Y1.76 E.01498 +;WIDTH:0.422689 +G1 X28.579 Y1.636 E.01224 +;WIDTH:0.432111 +G1 X28.904 Y1.331 E.01414 +G1 X29.032 Y1.117 E.00792 +;WIDTH:0.415209 +G1 X29.381 Y1.239 E.01122 +;WIDTH:0.432111 +G1 X29.219 Y1.552 E.01117 +G1 X28.91 Y1.877 E.01424 +;WIDTH:0.428789 +G1 X28.612 Y2.034 E.01058 +;WIDTH:0.426683 +G1 X28.094 Y2.143 E.01656 +;WIDTH:0.41673 +G1 X27.642 Y2.127 E.01376 +;WIDTH:0.408615 +G1 X27.218 Y2.026 E.01301 +;WIDTH:0.413352 +G1 X26.852 Y1.815 E.01273 +;WIDTH:0.417919 +G1 X26.562 Y1.514 E.01278 +;WIDTH:0.426487 +G1 X26.314 Y1.049 E.01647 +;WIDTH:0.428746 +G1 X26.194 Y.562 E.01578 +G1 X26.156 Y.057 E.01591 +;WIDTH:0.422 +G1 X26.196 Y-.444 E.01554 +;WIDTH:0.418473 +G1 X26.288 Y-.901 E.01425 +;WIDTH:0.41928 +G1 X26.46 Y-1.308 E.01355 +;WIDTH:0.421794 +G1 X26.706 Y-1.658 E.0132 +G1 X27.033 Y-1.919 E.01293 +;WIDTH:0.416386 +G1 X27.369 Y-2.074 E.01125 +G1 X27.887 Y-2.148 E.01593 +;WIDTH:0.402872 +G1 X28.358 Y-2.106 E.01388 +;WIDTH:0.408915 +G1 X28.895 Y-1.934 E.0168 +;WIDTH:0.414234 +G1 X29.23 Y-1.721 E.01202 +;WIDTH:0.417567 +G1 X29.5 Y-1.45 E.01167 +G1 X29.508 Y-1.215 E.00718 +;WIDTH:0.398799 +G1 X29.505 Y-.613 E.01746 +;WIDTH:0.432487 +G1 X29.485 Y-.349 E.00842 +;WIDTH:0.468232 +G1 X29.466 Y-.084 E.00919 +G1 X29.22 Y-.071 E.00853 +;WIDTH:0.441909 +G1 X28.974 Y-.058 E.008 +;WIDTH:0.415585 +G1 X28.069 Y-.058 E.0275 +G1 X28.069 Y-.43 E.01132 +G1 X28.255 Y-.43 E.00566 +;WIDTH:0.441909 +G1 X28.685 Y-.444 E.01398 +;WIDTH:0.468232 +G1 X29.075 Y-.456 E.01351 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X29.133 Y-.537 +G1 X29.151 Y-.617 +G1 X29.144 Y-1.297 +G1 X28.815 Y-1.564 +G1 X28.467 Y-1.699 +G1 X28.133 Y-1.768 +;WIPE_END +G1 X30.828 Y1.544 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.44866 +G1 F12000 +G1 X30.843 Y1.618 E.00251 +;WIDTH:0.478153 +G1 X30.858 Y1.693 E.00269 +G1 X30.937 Y1.71 E.00287 +;WIDTH:0.443832 +G1 X31.016 Y1.727 E.00264 +;WIDTH:0.409511 +G1 X33.105 Y1.73 E.06242 +;WIDTH:0.407441 +G1 X33.091 Y2.095 E.01083 +G1 X32.916 Y2.095 E.0052 +;WIDTH:0.409511 +G1 X31.016 Y2.094 E.05676 +;WIDTH:0.443832 +G1 X30.748 Y2.076 E.00874 +;WIDTH:0.478153 +G1 X30.481 Y2.059 E.00949 +G1 X30.467 Y1.801 E.00916 +;WIDTH:0.44866 +G1 X30.452 Y1.544 E.00854 +;WIDTH:0.419167 +G1 X30.452 Y.422 E.0344 +;WIDTH:0.463078 +G1 X30.474 Y.24 E.00627 +;WIDTH:0.506988 +G1 X30.496 Y.058 E.00692 +G1 X30.474 Y-.124 E.00692 +;WIDTH:0.463078 +G1 X30.452 Y-.306 E.00627 +;WIDTH:0.419167 +G1 X30.452 Y-1.547 E.03805 +;WIDTH:0.44866 +G1 X30.467 Y-1.804 E.00854 +;WIDTH:0.478153 +G1 X30.481 Y-2.062 E.00916 +G1 X30.748 Y-2.079 E.00948 +;WIDTH:0.443828 +G1 X31.015 Y-2.097 E.00874 +;WIDTH:0.409502 +G1 X33.208 Y-2.098 E.0655 +;WIDTH:0.406189 +G1 X33.216 Y-1.735 E.01074 +G1 X33.031 Y-1.735 E.00547 +;WIDTH:0.409502 +G1 X31.016 Y-1.73 E.06019 +;WIDTH:0.443828 +G1 X30.937 Y-1.713 E.00264 +;WIDTH:0.478153 +G1 X30.858 Y-1.696 E.00287 +G1 X30.843 Y-1.621 E.00269 +;WIDTH:0.44866 +G1 X30.828 Y-1.547 E.00251 +;WIDTH:0.419167 +G1 X30.828 Y-.306 E.03805 +;WIDTH:0.463078 +G1 X30.85 Y-.215 E.0032 +;WIDTH:0.506988 +G1 X30.872 Y-.124 E.00354 +G1 X30.92 Y-.124 E.00182 +;WIDTH:0.473482 +G1 X30.968 Y-.124 E.00169 +;WIDTH:0.439976 +G1 X31.016 Y-.124 E.00155 +;WIDTH:0.40647 +G1 X32.956 Y-.125 E.05745 +;WIDTH:0.402767 +G1 X32.938 Y.235 E.01055 +G1 X32.767 Y.235 E.005 +;WIDTH:0.40647 +G1 X30.872 Y.24 E.05614 +;WIDTH:0.419167 +G1 X30.828 Y.422 E.00572 +G1 X30.828 Y1.504 E.03318 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X30.843 Y1.618 +G1 X30.858 Y1.693 +G1 X30.937 Y1.71 +G1 X31.016 Y1.727 +G1 X32.663 Y1.73 +;WIPE_END +G1 X32.748 Y9.362 Z2 F30000 +G1 X32.786 Y12.792 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.522355 +G1 F12000 +G1 X32.307 Y12.791 E.01872 +G1 X32.307 Y12.552 E.00935 +;WIDTH:0.524425 +G1 X32.308 Y8.985 E.13995 +;WIDTH:0.5288 +G1 X32.222 Y8.339 E.02578 +G1 X32.021 Y7.859 E.02061 +G1 X31.818 Y7.605 E.01289 +;WIDTH:0.529653 +G1 X31.459 Y7.359 E.01725 +G1 X31.129 Y7.239 E.01392 +;WIDTH:0.522616 +G1 X30.586 Y7.192 E.02133 +;WIDTH:0.518292 +G1 X30.082 Y7.286 E.01985 +;WIDTH:0.522731 +G1 X29.649 Y7.55 E.01984 +;WIDTH:0.528284 +G1 X29.354 Y7.957 E.01988 +;WIDTH:0.536088 +G1 X29.206 Y8.446 E.02055 +G1 X29.151 Y9.021 E.0232 +;WIDTH:0.529288 +G1 X29.144 Y12.789 E.14933 +;WIDTH:0.524572 +G1 X28.663 Y12.783 E.01889 +G1 X28.663 Y12.545 E.00932 +;WIDTH:0.529288 +G1 X28.665 Y8.967 E.14181 +;WIDTH:0.536088 +G1 X28.714 Y8.401 E.02283 +G1 X28.796 Y8.057 E.01421 +;WIDTH:0.534621 +G1 X28.968 Y7.659 E.01739 +;WIDTH:0.528538 +G1 X29.314 Y7.209 E.02245 +;WIDTH:0.525818 +G1 X29.743 Y6.919 E.02039 +G1 X30.093 Y6.791 E.01464 +;WIDTH:0.514905 +G1 X30.695 Y6.716 E.02334 +;WIDTH:0.522616 +G1 X31.197 Y6.764 E.01972 +;WIDTH:0.529653 +G1 X31.658 Y6.918 E.01925 +G1 X31.992 Y7.117 E.01543 +;WIDTH:0.527164 +G1 X32.35 Y7.481 E.02014 +;WIDTH:0.537094 +G1 X32.538 Y7.804 E.01505 +G1 X32.716 Y8.324 E.02213 +;WIDTH:0.535833 +G1 X32.79 Y9.08 E.03051 +;WIDTH:0.524425 +G1 X32.786 Y12.752 E.14408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X32.307 Y12.791 +G1 X32.307 Y12.552 +G1 X32.307 Y11.272 +;WIPE_END +G1 X25.586 Y7.655 Z2 F30000 +G1 X24.959 Y7.317 Z2 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.604667 +G1 F12000 +G1 X24.94 Y7.413 E.00447 +;WIDTH:0.566965 +G1 X24.921 Y7.509 E.00417 +;WIDTH:0.529262 +G1 X24.92 Y12.787 E.20917 +;WIDTH:0.52871 +G1 X24.435 Y12.788 E.01923 +;WIDTH:0.529262 +G1 X24.435 Y7.509 E.20924 +;WIDTH:0.566965 +G1 X24.454 Y7.177 E.0142 +;WIDTH:0.604667 +G1 X24.472 Y6.845 E.01522 +G1 X24.818 Y6.822 E.01584 +;WIDTH:0.559976 +G1 X25.163 Y6.8 E.01458 +;WIDTH:0.515284 +G1 X27.587 Y6.799 E.0933 +;WIDTH:0.51208 +G1 X27.594 Y7.267 E.01792 +G1 X27.357 Y7.268 E.00908 +;WIDTH:0.515284 +G1 X25.164 Y7.273 E.08441 +;WIDTH:0.559976 +G1 X25.061 Y7.295 E.00442 +;WIDTH:0.604667 +G1 X24.998 Y7.309 E.00297 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X24.94 Y7.413 +G1 X24.921 Y7.509 +G1 X24.921 Y9.292 +;WIPE_END +G1 X22.622 Y8.062 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.431923 +G1 F18000 +G1 X22.447 Y7.835 E.00907 +G1 X22.377 Y7.816 E.00231 +G1 X22.043 Y7.935 E.01125 +G1 X22.469 Y7.137 E.02868 +G1 X22.622 Y7.138 E.00484 +G1 X22.622 Y8.022 E.02801 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X22.447 Y7.835 +G1 X22.377 Y7.816 +G1 X22.043 Y7.935 +G1 X22.469 Y7.137 +G1 X22.622 Y7.138 +G1 X22.622 Y7.397 +;WIPE_END +G1 X19.77 Y11.672 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.4257 +G1 F18000 +G1 X19.346 Y12.463 E.02799 +G1 X19.219 Y12.463 E.00397 +G1 X19.219 Y11.549 E.02852 +G1 X19.39 Y11.735 E.0079 +G1 X19.459 Y11.753 E.00222 +G1 X19.731 Y11.682 E.00878 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X19.556 Y11.386 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.457552 +G1 F12000 +G1 X19.688 Y11.37 E.0045 +;WIDTH:0.495105 +G1 X19.82 Y11.354 E.00491 +G1 X19.84 Y11.32 E.00145 +;WIDTH:0.494732 +G1 X21.595 Y8.038 E.137 +G1 X21.757 Y7.697 E.01389 +;WIDTH:0.457366 +G1 X21.918 Y7.356 E.01275 +;WIDTH:0.419999 +G1 X22.244 Y6.753 E.02106 +G1 X23.005 Y6.757 E.02338 +G1 X23.005 Y7.701 E.02901 +;WIDTH:0.454735 +G1 X22.987 Y8.044 E.01154 +;WIDTH:0.489471 +G1 X22.97 Y8.388 E.01251 +;WIDTH:0.492122 +G1 X22.969 Y12.791 E.16116 +G1 X22.519 Y12.803 E.01646 +G1 X22.523 Y8.387 E.16162 +;WIDTH:0.489471 +G1 X22.436 Y8.296 E.0046 +;WIDTH:0.454735 +G1 X22.349 Y8.205 E.00424 +;WIDTH:0.419999 +G1 X22.278 Y8.186 E.00223 +;WIDTH:0.457366 +M73 P90 R0 +G1 X22.136 Y8.219 E.00493 +M73 P90 R0 +;WIDTH:0.494732 +G1 X21.994 Y8.252 E.00538 +G1 X20.184 Y11.628 E.14105 +;WIDTH:0.491706 +G1 X20.049 Y11.918 E.0117 +;WIDTH:0.455853 +G1 X19.913 Y12.208 E.01077 +;WIDTH:0.419999 +G1 X19.569 Y12.843 E.02218 +G1 X18.839 Y12.843 E.02244 +G1 X18.839 Y11.867 E.02999 +;WIDTH:0.452654 +G1 X18.855 Y11.526 E.0114 +;WIDTH:0.485309 +G1 X18.872 Y11.185 E.01231 +;WIDTH:0.487794 +G1 X18.874 Y6.786 E.15945 +G1 X19.319 Y6.787 E.01613 +G1 X19.314 Y11.223 E.16082 +;WIDTH:0.485309 +G1 X19.435 Y11.304 E.00526 +;WIDTH:0.452654 +G1 X19.523 Y11.364 E.00354 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X19.688 Y11.37 +G1 X19.82 Y11.354 +G1 X19.84 Y11.32 +G1 X20.624 Y9.854 +;WIPE_END +G1 X16.956 Y12.791 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.522355 +G1 F12000 +G1 X16.956 Y12.552 E.00935 +;WIDTH:0.524425 +G1 X16.957 Y8.985 E.13995 +;WIDTH:0.5288 +G1 X16.871 Y8.339 E.02578 +G1 X16.67 Y7.859 E.02061 +G1 X16.467 Y7.605 E.01289 +;WIDTH:0.529654 +G1 X16.108 Y7.359 E.01724 +G1 X15.778 Y7.239 E.01392 +;WIDTH:0.522616 +G1 X15.235 Y7.192 E.02133 +;WIDTH:0.518292 +G1 X14.731 Y7.286 E.01985 +;WIDTH:0.522731 +G1 X14.298 Y7.55 E.01984 +;WIDTH:0.528283 +G1 X14.003 Y7.957 E.01988 +;WIDTH:0.536087 +G1 X13.855 Y8.446 E.02055 +G1 X13.8 Y9.021 E.0232 +;WIDTH:0.529288 +G1 X13.793 Y12.789 E.14933 +;WIDTH:0.524571 +G1 X13.312 Y12.783 E.01889 +G1 X13.312 Y12.545 E.00932 +;WIDTH:0.529288 +G1 X13.314 Y8.967 E.1418 +;WIDTH:0.536087 +G1 X13.364 Y8.401 E.02283 +G1 X13.445 Y8.057 E.0142 +;WIDTH:0.53462 +G1 X13.617 Y7.659 E.01739 +;WIDTH:0.528536 +G1 X13.963 Y7.209 E.02245 +;WIDTH:0.525819 +G1 X14.392 Y6.919 E.02039 +G1 X14.742 Y6.791 E.01464 +;WIDTH:0.514905 +G1 X15.344 Y6.716 E.02334 +;WIDTH:0.522616 +G1 X15.846 Y6.764 E.01972 +;WIDTH:0.529654 +G1 X16.307 Y6.918 E.01925 +G1 X16.641 Y7.117 E.01543 +;WIDTH:0.527163 +G1 X16.999 Y7.481 E.02014 +;WIDTH:0.537094 +G1 X17.188 Y7.804 E.01505 +G1 X17.365 Y8.324 E.02213 +;WIDTH:0.535833 +G1 X17.439 Y9.08 E.03051 +;WIDTH:0.524425 +G1 X17.435 Y12.792 E.14565 +;WIDTH:0.522355 +G1 X16.996 Y12.791 E.01716 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X16.956 Y12.552 +G1 X16.957 Y10.794 +;WIPE_END +G1 X11.498 Y11.555 Z2 F30000 +G1 Z1.6 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.531592 +G1 F12000 +G1 X11.973 Y11.653 E.01931 +G1 X11.875 Y11.92 E.01131 +G1 X11.722 Y12.234 E.01392 +;WIDTH:0.5381 +G1 X11.435 Y12.537 E.01682 +G1 X11.052 Y12.74 E.01752 +;WIDTH:0.506853 +G1 X10.566 Y12.868 E.01898 +;WIDTH:0.497383 +G1 X10.1 Y12.896 E.01728 +G1 X9.589 Y12.864 E.01899 +;WIDTH:0.491398 +G1 X9.099 Y12.721 E.01866 +;WIDTH:0.502357 +G1 X8.745 Y12.532 E.015 +;WIDTH:0.511708 +G1 X8.469 Y12.257 E.01489 +;WIDTH:0.51439 +G1 X8.278 Y11.876 E.01638 +;WIDTH:0.521929 +G1 X8.203 Y11.357 E.02046 +;WIDTH:0.535089 +G1 X8.249 Y10.93 E.01721 +;WIDTH:0.539299 +G1 X8.376 Y10.601 E.01426 +;WIDTH:0.55279 +G1 X8.614 Y10.295 E.0161 +G1 X9.022 Y10.02 E.02046 +;WIDTH:0.555247 +G1 X9.549 Y9.802 E.02379 +;WIDTH:0.559878 +G1 X10.818 Y9.437 E.05563 +G1 X11.304 Y9.178 E.02318 +;WIDTH:0.555442 +G1 X11.542 Y8.899 E.01533 +;WIDTH:0.544553 +G1 X11.643 Y8.577 E.0138 +;WIDTH:0.526731 +G1 X11.63 Y8.092 E.01913 +;WIDTH:0.522053 +G1 X11.534 Y7.807 E.01172 +;WIDTH:0.51942 +G1 X11.268 Y7.489 E.01612 +;WIDTH:0.509642 +G1 X10.886 Y7.283 E.0165 +;WIDTH:0.507092 +G1 X10.461 Y7.185 E.01651 +;WIDTH:0.496611 +G1 X9.886 Y7.171 E.02126 +;WIDTH:0.515237 +G1 X9.285 Y7.287 E.02355 +;WIDTH:0.523419 +G1 X8.884 Y7.502 E.01782 +;WIDTH:0.538617 +G1 X8.646 Y7.773 E.01457 +G1 X8.458 Y8.191 E.01852 +;WIDTH:0.536404 +G1 X7.99 Y8.092 E.01925 +G1 X8.153 Y7.641 E.01929 +;WIDTH:0.531532 +G1 X8.46 Y7.228 E.02048 +G1 X8.866 Y6.953 E.01953 +;WIDTH:0.516688 +G1 X9.339 Y6.789 E.01933 +;WIDTH:0.504539 +G1 X9.924 Y6.716 E.02218 +;WIDTH:0.496611 +G1 X10.485 Y6.732 E.02074 +;WIDTH:0.497445 +G1 X10.91 Y6.812 E.01605 +;WIDTH:0.509727 +G1 X11.399 Y7.014 E.02011 +;WIDTH:0.523727 +G1 X11.72 Y7.269 E.01606 +G1 X11.933 Y7.55 E.01381 +;WIDTH:0.52095 +G1 X12.087 Y7.944 E.01647 +;WIDTH:0.530749 +G1 X12.126 Y8.579 E.0253 +;WIDTH:0.541956 +G1 X12.01 Y9.088 E.02123 +;WIDTH:0.550491 +G1 X11.724 Y9.486 E.02028 +;WIDTH:0.56215 +G1 X11.362 Y9.743 E.01878 +G1 X10.745 Y9.996 E.02821 +;WIDTH:0.559878 +G1 X9.419 Y10.371 E.05806 +;WIDTH:0.555247 +G1 X8.943 Y10.68 E.02367 +;WIDTH:0.55279 +G1 X8.756 Y10.944 E.01345 +;WIDTH:0.534417 +G1 X8.679 Y11.323 E.0155 +;WIDTH:0.521929 +G1 X8.734 Y11.76 E.01717 +;WIDTH:0.514028 +G1 X8.838 Y11.971 E.00904 +G1 X8.982 Y12.131 E.00825 +;WIDTH:0.509097 +G1 X9.271 Y12.312 E.01296 +;WIDTH:0.489777 +G1 X9.76 Y12.435 E.01835 +;WIDTH:0.494765 +G1 X10.438 Y12.432 E.02497 +;WIDTH:0.504545 +G1 X10.922 Y12.294 E.01891 +;WIDTH:0.535634 +G1 X11.283 Y12.009 E.01849 +G1 X11.481 Y11.591 E.01855 +;LAYER_CHANGE +;Z:1.8 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;1.8 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X11.973 Y11.653 +G1 X11.875 Y11.92 +G1 X11.722 Y12.234 +G1 X11.435 Y12.537 +G1 X11.052 Y12.74 +G1 X11.033 Y12.745 +;WIPE_END +;AFTER_LAYER_CHANGE +;1.8 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z2.2 I.094 J-1.213 P1 F30000 +G1 X-36.005 Y9.111 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.488718 +G1 F16823.762 +G1 X-36.026 Y9.203 E.00343 +;WIDTH:0.525946 +G1 F15527.114 +G1 X-36.14 Y9.993 E.03142 +G1 X-36.14 Y10.599 E.02384 +G1 X-36.508 Y10.197 E.02144 +G1 X-36.854 Y10.004 E.01559 +G1 X-36.572 Y9.831 E.01303 +G1 X-36.233 Y9.531 E.01781 +G1 X-36.024 Y9.146 E.01722 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15527.114 +G1 X-36.026 Y9.203 +G1 X-36.14 Y9.993 +G1 X-36.14 Y10.599 +G1 X-36.504 Y10.201 +;WIPE_END +G1 X-29.22 Y12.482 Z2.2 F30000 +G1 X-26.5 Y13.334 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-26.466 Y13.465 E.00448 +G1 X-26.188 Y14.169 E.02513 +G1 X-25.89 Y14.61 E.01765 +G1 X-25.573 Y15.016 E.01707 +G1 X-24.866 Y15.577 E.02992 +G1 X-24.043 Y15.944 E.02992 +G1 X-23.54 Y16.04 E.01696 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.453 Y16.078 E.00355 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.365 Y16.116 E.00392 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.277 Y16.154 E.00429 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.192 E.00467 +G1 X32.757 Y16.192 E2.73304 +G1 X33.602 Y16.029 E.04204 +;WIDTH:0.655272 +G1 F12247.857 +G1 X34.067 Y15.831 E.02524 +G1 X34.551 Y15.573 E.02734 +;WIDTH:0.643372 +G1 F12490.59 +G1 X35.166 Y15.062 E.03914 +;WIDTH:0.655272 +G1 F12247.857 +G1 X35.478 Y14.664 E.02524 +G1 X35.777 Y14.205 E.02733 +;WIDTH:0.643367 +G1 F12490.695 +G1 X36.056 Y13.501 E.03704 +;WIDTH:0.649631 +G1 F12361.735 +G1 X36.127 Y13.139 E.01823 +G1 X36.196 Y12.587 E.0275 +G1 X36.356 Y12.75 E.01126 +G1 X36.763 Y13.039 E.02468 +G1 X36.659 Y13.587 E.02759 +;WIDTH:0.655256 +G1 F12248.178 +G1 X36.484 Y14.098 E.02694 +G1 X36.258 Y14.587 E.0269 +G1 X35.962 Y15.039 E.02694 +G1 X35.621 Y15.456 E.0269 +G1 X35.221 Y15.82 E.02694 +G1 X34.787 Y16.14 E.0269 +G1 X34.31 Y16.392 E.02694 +G1 X33.81 Y16.594 E.0269 +G1 X33.285 Y16.72 E.02694 +G1 X32.69 Y16.792 E.02994 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.792 E2.72975 +G1 X-23.297 Y16.816 E.00539 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.405 Y16.84 E.00496 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.513 Y16.864 E.00453 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.62 Y16.888 E.00409 +;WIDTH:0.449999 +G1 F18000 +G1 X-32.776 Y16.888 E.30371 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.372 Y16.478 E.01832 +G1 X-34.856 Y16.212 E.01832 +G1 X-35.275 Y15.908 E.01719 +G1 X-35.652 Y15.547 E.01731 +G1 X-36.041 Y15.104 E.01954 +G1 X-36.307 Y14.691 E.0163 +G1 X-36.54 Y14.221 E.0174 +;WIDTH:0.498867 +G1 F16449.307 +G1 X-36.555 Y14.108 E.00425 +;WIDTH:0.547734 +G1 F14856.972 +G1 X-36.57 Y13.994 E.00471 +;WIDTH:0.596601 +G1 F13545.712 +G1 X-36.584 Y13.881 E.00517 +;WIDTH:0.645468 +G1 F12447.141 +G1 X-36.599 Y13.767 E.00562 +G1 X-36.668 Y13.551 E.01115 +;WIDTH:0.649624 +G1 F12361.877 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.196 Y12.588 E.02644 +G1 X-36.07 Y13.461 E.0436 +;WIDTH:0.642406 +G1 F12510.718 +G1 X-35.996 Y13.582 E.00696 +;WIDTH:0.593298 +G1 F13627.006 +G1 X-35.922 Y13.704 E.00639 +;WIDTH:0.54419 +G1 F14962.017 +G1 X-35.848 Y13.826 E.00582 +;WIDTH:0.495081 +G1 F16587.011 +G1 X-35.688 Y14.169 E.01398 +;WIDTH:0.449999 +G1 F18000 +G1 X-35.395 Y14.603 E.01736 +G1 X-35.073 Y15.016 E.01736 +G1 X-34.489 Y15.499 E.02512 +G1 X-34.024 Y15.738 E.01736 +G1 X-33.543 Y15.944 E.01736 +G1 X-32.69 Y16.096 E.02874 +G1 X-29.754 Y16.096 E.09739 +G1 X-28.916 Y15.931 E.02831 +G1 X-28.403 Y15.724 E.01837 +G1 X-27.653 Y15.228 E.02983 +G1 X-27.107 Y14.639 E.02664 +G1 X-26.812 Y14.169 E.01838 +G1 X-26.514 Y13.372 E.02825 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-26.466 Y13.465 +G1 X-26.188 Y14.169 +G1 X-25.89 Y14.61 +G1 X-25.573 Y15.016 +G1 X-25.501 Y15.072 +;WIPE_END +G1 X-18.766 Y11.482 Z2.2 F30000 +G1 X12.643 Y-5.263 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X13.097 Y-5.442 E.01622 +G1 X13.849 Y-5.941 E.02993 +G1 X14.453 Y-6.611 E.02991 +G1 X14.818 Y-7.275 E.02513 +G1 X14.983 Y-7.843 E.01964 +M73 P91 R0 +G1 X15.312 Y-6.997 E.03012 +M73 P91 R0 +G1 X15.522 Y-6.678 E.01267 +G1 X15.927 Y-6.151 E.02206 +G1 X16.634 Y-5.59 E.02992 +G1 X17.318 Y-5.265 E.02512 +G1 X12.683 Y-5.263 E.15376 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X13.097 Y-5.442 +G1 X13.849 Y-5.941 +G1 X14.282 Y-6.421 +;WIPE_END +G1 X6.917 Y-4.419 Z2.2 F30000 +G1 X-33.249 Y6.5 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X-33.681 Y6.601 E.01472 +G1 X-34.489 Y7.001 E.02992 +G1 X-35.172 Y7.59 E.02992 +G1 X-35.551 Y8.112 E.02138 +G1 X-35.719 Y8.409 E.01133 +;WIDTH:0.481194 +G1 F17112.581 +G1 X-35.964 Y8.987 E.02242 +G1 X-35.908 Y8.609 E.01367 +;WIDTH:0.451242 +G1 F18000 +G1 X-35.924 Y8.308 E.01002 +;WIDTH:0.449999 +G1 X-36.06 Y7.814 E.017 +G1 X-36.331 Y7.404 E.01631 +G1 X-36.725 Y7.105 E.01638 +G1 X-36.898 Y7.027 E.00631 +G1 X-36.898 Y-12.481 E.64712 +G1 X-36.862 Y-13.051 E.01894 +G1 X-36.759 Y-13.593 E.01832 +G1 X-36.588 Y-14.119 E.01832 +G1 X-36.353 Y-14.618 E.01832 +G1 X-36.057 Y-15.084 E.01832 +G1 X-35.705 Y-15.51 E.01832 +G1 X-35.303 Y-15.888 E.01831 +G1 X-34.856 Y-16.212 E.01832 +G1 X-34.372 Y-16.478 E.01832 +G1 X-33.859 Y-16.682 E.01832 +G1 X-33.324 Y-16.819 E.01832 +G1 X-32.776 Y-16.888 E.01832 +G1 X32.69 Y-16.888 E2.17162 +G1 X33.235 Y-16.83 E.0182 +G1 X33.863 Y-16.68 E.02142 +G1 X34.372 Y-16.478 E.01815 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01832 +G1 X36.041 Y-15.104 E.01746 +G1 X36.305 Y-14.694 E.0162 +G1 X36.59 Y-14.114 E.02142 +G1 X36.759 Y-13.593 E.01815 +G1 X36.862 Y-13.051 E.01832 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y-2.097 E.34447 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.874 Y-1.989 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.849 Y-1.882 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.825 Y-1.775 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X36.801 Y-1.667 E.00539 +G1 X36.801 Y6.966 E.42312 +G1 X36.357 Y7.252 E.02587 +G1 X36.199 Y7.42 E.01131 +G1 X36.199 Y-1.667 E.44539 +G1 X36.164 Y-1.761 E.00492 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.129 Y-1.856 E.00452 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.094 Y-1.95 E.00413 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.059 Y-2.044 E.00373 +;WIDTH:0.449999 +G1 F18000 +G1 X35.966 Y-2.632 E.01975 +G1 X35.617 Y-3.464 E.02992 +G1 X35.172 Y-4.076 E.02512 +G1 X34.967 Y-4.272 E.00941 +G1 X34.505 Y-4.652 E.01983 +G1 X33.966 Y-4.953 E.02047 +G1 X33.286 Y-5.174 E.02373 +G1 X33.681 Y-5.268 E.01347 +G1 X34.366 Y-5.59 E.02512 +G1 X34.592 Y-5.753 E.00924 +G1 X35.172 Y-6.257 E.02549 +G1 X35.688 Y-6.997 E.02992 +G1 X36.003 Y-7.842 E.02992 +G1 X36.102 Y-8.666 E.02752 +G1 X36.102 Y-12.469 E.12614 +G1 X36.05 Y-12.948 E.01601 +G1 X35.966 Y-13.465 E.01736 +G1 X35.688 Y-14.169 E.02512 +G1 X35.395 Y-14.603 E.01736 +G1 X35.073 Y-15.016 E.01736 +G1 X34.489 Y-15.499 E.02512 +G1 X34.024 Y-15.738 E.01736 +G1 X33.543 Y-15.944 E.01736 +G1 X32.69 Y-16.096 E.02874 +G1 X18.25 Y-16.096 E.47899 +G1 X17.319 Y-15.899 E.03157 +G1 X16.511 Y-15.499 E.02992 +G1 X15.828 Y-14.91 E.02992 +G1 X15.315 Y-14.175 E.02971 +G1 X15.071 Y-13.579 E.02137 +G1 X15.004 Y-13.324 E.00873 +G1 X14.921 Y-13.58 E.00891 +G1 X14.617 Y-14.297 E.02583 +G1 X14.073 Y-15.016 E.02992 +G1 X13.366 Y-15.576 E.02992 +G1 X12.542 Y-15.944 E.02992 +G1 X11.69 Y-16.096 E.02874 +G1 X-32.69 Y-16.096 E1.47214 +G1 X-33.171 Y-16.015 E.01617 +G1 X-33.681 Y-15.899 E.01736 +G1 X-34.366 Y-15.576 E.02512 +G1 X-34.781 Y-15.257 E.01736 +G1 X-35.17 Y-14.912 E.01725 +G1 X-35.643 Y-14.248 E.02704 +G1 X-35.872 Y-13.757 E.01798 +G1 X-36.068 Y-12.987 E.02634 +G1 X-36.102 Y-12.469 E.01725 +G1 X-36.102 Y-8.624 E.12752 +G1 X-35.966 Y-7.702 E.03094 +G1 X-35.617 Y-6.87 E.02992 +G1 X-35.172 Y-6.257 E.02512 +G1 X-34.976 Y-6.071 E.00897 +G1 X-34.366 Y-5.59 E.02576 +G1 X-33.681 Y-5.268 E.02512 +G1 X-33.249 Y-5.167 E.01472 +G1 X-33.681 Y-5.066 E.01472 +G1 X-34.489 Y-4.665 E.02992 +G1 X-35.172 Y-4.076 E.02992 +G1 X-35.688 Y-3.336 E.02992 +G1 X-36.003 Y-2.491 E.02991 +G1 X-36.102 Y-1.667 E.02753 +G1 X-36.102 Y3.042 E.15623 +G1 X-35.966 Y3.965 E.03094 +G1 X-35.617 Y4.797 E.02992 +G1 X-35.172 Y5.41 E.02512 +G1 X-34.932 Y5.632 E.01087 +G1 X-34.366 Y6.076 E.02386 +G1 X-33.681 Y6.399 E.02512 +G1 X-33.288 Y6.491 E.0134 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-33.681 Y6.601 +G1 X-34.489 Y7.001 +G1 X-35.011 Y7.452 +;WIPE_END +G1 X-27.382 Y7.66 Z2.2 F30000 +G1 X36.142 Y9.392 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.530592 +G1 F15379.189 +G1 X36.391 Y9.702 E.01577 +G1 X36.842 Y10 E.0215 +G1 X36.391 Y10.298 E.0215 +G1 X36.142 Y10.608 E.01577 +G1 X36.142 Y9.432 E.04671 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.243 Y8.45 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.525695 +G1 F12000 +G1 X36.241 Y8.455 E.00019 +G1 X36.282 Y8.607 E.00619 +;WIDTH:0.491265 +G1 X36.323 Y8.759 E.00575 +;WIDTH:0.456835 +G1 X36.364 Y8.911 E.00531 +;WIDTH:0.422404 +G1 X36.596 Y9.3 E.01402 +;WIDTH:0.419999 +G1 X36.955 Y9.577 E.01393 +G1 X37.29 Y9.669 E.01067 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01408 +G1 X36.391 Y11.023 E.01392 +;WIDTH:0.455231 +G1 X36.341 Y11.167 E.00511 +;WIDTH:0.490463 +G1 X36.291 Y11.311 E.00555 +;WIDTH:0.525694 +G1 X36.241 Y11.455 E.00599 +G1 X36.282 Y11.607 E.00619 +;WIDTH:0.491265 +G1 X36.323 Y11.759 E.00575 +;WIDTH:0.456835 +G1 X36.364 Y11.911 E.00531 +;WIDTH:0.422405 +G1 X36.596 Y12.3 E.01402 +;WIDTH:0.419999 +G1 X36.955 Y12.577 E.01393 +G1 X37.279 Y12.666 E.01033 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.552 Y16.191 E.01843 +G1 X-36.05 Y15.707 E.02136 +G1 X-36.375 Y15.315 E.01564 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.253 E.0171 +G1 X-37.279 Y12.666 E.01811 +G1 X-37.024 Y12.609 E.00803 +G1 X-36.645 Y12.352 E.01408 +G1 X-36.544 Y12.23 E.00486 +;WIDTH:0.465097 +G1 X-36.404 Y11.965 E.01031 +;WIDTH:0.510195 +G1 X-36.264 Y11.7 E.01141 +;WIDTH:0.525402 +G1 X-36.245 Y11.372 E.0129 +G1 X-36.305 Y11.231 E.00602 +;WIDTH:0.491424 +G1 X-36.364 Y11.09 E.0056 +;WIDTH:0.457446 +G1 X-36.424 Y10.949 E.00518 +;WIDTH:0.423467 +G1 X-36.704 Y10.593 E.01403 +;WIDTH:0.419999 +G1 X-37.07 Y10.376 E.01308 +G1 X-37.29 Y10.321 E.00696 +G1 X-37.29 Y9.669 E.02005 +G1 X-37.024 Y9.609 E.00837 +G1 X-36.645 Y9.352 E.01408 +G1 X-36.391 Y8.977 E.01392 +G1 X-36.293 Y8.535 E.01389 +G1 X-36.365 Y8.089 E.01388 +G1 X-36.596 Y7.7 E.01392 +G1 X-36.955 Y7.423 E.01393 +G1 X-37.29 Y7.331 E.01067 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +M73 P92 R0 +G1 X-37.139 Y-13.691 E.01848 +M73 P92 R0 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.306 Y-17.216 E.01564 +G1 X33.982 Y-17.055 E.02136 +G1 X34.539 Y-16.834 E.01843 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.648 Y-14.885 E.01564 +G1 X36.954 Y-14.262 E.02136 +G1 X37.139 Y-13.691 E.01843 +G1 X37.252 Y-13.1 E.01848 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.331 E.60916 +G1 X37.024 Y7.391 E.00837 +G1 X36.645 Y7.648 E.01408 +G1 X36.391 Y8.023 E.01392 +;WIDTH:0.455231 +G1 X36.341 Y8.167 E.00511 +;WIDTH:0.490463 +G1 X36.291 Y8.311 E.00555 +;WIDTH:0.525695 +G1 X36.256 Y8.413 E.00423 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.241 Y8.455 +G1 X36.282 Y8.607 +G1 X36.323 Y8.759 +G1 X36.364 Y8.911 +G1 X36.596 Y9.3 +G1 X36.955 Y9.577 +G1 X37.29 Y9.669 +G1 X37.29 Y9.898 +;WIPE_END +G1 X36.529 Y2.303 Z2.2 F30000 +G1 X34.823 Y-14.714 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.419999 +G1 F12000 +G1 X35.3 Y-14.066 E.02471 +G1 X35.602 Y-13.321 E.02471 +G1 X35.71 Y-12.513 E.02504 +G1 X35.71 Y-8.666 E.11821 +G1 X35.614 Y-7.892 E.02398 +G1 X35.323 Y-7.142 E.02471 +G1 X34.855 Y-6.487 E.02471 +G1 X34.199 Y-5.945 E.02615 +G1 X33.514 Y-5.622 E.02327 +;WIDTH:0.455793 +G1 X33.246 Y-5.551 E.00933 +;WIDTH:0.491587 +G1 X32.978 Y-5.48 E.01014 +;WIDTH:0.527381 +G1 X32.71 Y-5.409 E.01095 +G1 X18.31 Y-5.409 E.56845 +G1 X18.051 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X17.791 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X17.531 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X16.801 Y-5.945 E.02471 +G1 X16.177 Y-6.453 E.02471 +G1 X15.677 Y-7.142 E.02615 +G1 X15.398 Y-7.846 E.02327 +;WIDTH:0.45382 +G1 X15.345 Y-8.112 E.00908 +;WIDTH:0.48764 +G1 X15.292 Y-8.378 E.00982 +;WIDTH:0.52146 +G1 X15.239 Y-8.643 E.01057 +G1 X15.239 Y-12.501 E.15041 +G1 X15.289 Y-12.747 E.0098 +;WIDTH:0.48764 +G1 X15.339 Y-12.993 E.00911 +;WIDTH:0.45382 +G1 X15.389 Y-13.239 E.00841 +;WIDTH:0.419999 +G1 X15.674 Y-14.018 E.02546 +G1 X16.145 Y-14.679 E.02496 +G1 X16.761 Y-15.196 E.02471 +G1 X17.486 Y-15.544 E.02471 +G1 X18.292 Y-15.704 E.02524 +G1 X32.69 Y-15.704 E.44241 +G1 X33.469 Y-15.559 E.02436 +G1 X34.199 Y-15.222 E.02471 +G1 X34.792 Y-14.739 E.02348 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X35.3 Y-14.066 +G1 X35.602 Y-13.321 +G1 X35.649 Y-12.971 +;WIPE_END +G1 X28.371 Y-10.671 Z2.2 F30000 +G1 X11.726 Y-5.409 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.527381 +G1 F12000 +G1 X-32.69 Y-5.409 E1.75341 +G1 X-32.949 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X-33.209 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X-33.469 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X-34.199 Y-5.945 E.02471 +G1 X-34.855 Y-6.487 E.02615 +G1 X-35.3 Y-7.1 E.02327 +G1 X-35.602 Y-7.846 E.02471 +G1 X-35.71 Y-8.653 E.02504 +G1 X-35.71 Y-12.469 E.11723 +G1 X-35.68 Y-12.926 E.01407 +G1 X-35.492 Y-13.659 E.02328 +G1 X-35.273 Y-14.11 E.01539 +G1 X-34.854 Y-14.68 E.02174 +G1 X-34.239 Y-15.196 E.02468 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02524 +G1 X11.69 Y-15.704 E1.36422 +G1 X12.469 Y-15.559 E.02436 +G1 X13.199 Y-15.222 E.02471 +G1 X13.823 Y-14.714 E.02471 +G1 X14.3 Y-14.066 E.02471 +G1 X14.614 Y-13.275 E.02615 +;WIDTH:0.45382 +G1 X14.663 Y-13.027 E.00848 +;WIDTH:0.48764 +G1 X14.712 Y-12.778 E.00918 +;WIDTH:0.52146 +G1 X14.761 Y-12.53 E.00988 +G1 X14.761 Y-8.666 E.15066 +G1 X14.708 Y-8.424 E.00964 +;WIDTH:0.48764 +G1 X14.656 Y-8.183 E.00896 +;WIDTH:0.45382 +G1 X14.603 Y-7.941 E.00828 +;WIDTH:0.419999 +G1 X14.475 Y-7.464 E.01519 +G1 X14.109 Y-6.8 E.02328 +G1 X13.563 Y-6.209 E.0247 +G1 X12.887 Y-5.773 E.02472 +G1 X12.388 Y-5.588 E.01636 +;WIDTH:0.455793 +G1 X12.167 Y-5.528 E.0077 +;WIDTH:0.491587 +G1 X11.947 Y-5.468 E.00836 +;WIDTH:0.527381 +G1 X11.764 Y-5.419 E.00745 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X9.764 Y-5.419 +;WIPE_END +G1 X2.654 Y-2.645 Z2.2 F30000 +G1 X-26.278 Y8.643 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.467449 +G1 F12000 +G1 X-26.292 Y8.362 E.00974 +;WIDTH:0.419999 +G1 X-26.534 Y7.714 E.02124 +G1 X-26.854 Y7.247 E.01742 +G1 X-27.047 Y7.025 E.00903 +G1 X-27.506 Y6.646 E.01829 +G1 X-28.138 Y6.348 E.02148 +G1 X-28.831 Y6.216 E.02167 +;WIDTH:0.454327 +G1 X-29.115 Y6.232 E.00953 +;WIDTH:0.488655 +G1 X-29.399 Y6.248 E.01033 +;WIDTH:0.522983 +G1 X-29.683 Y6.265 E.01112 +;WIDTH:0.527381 +G1 X-32.689 Y6.258 E.11868 +G1 X-32.949 Y6.191 E.01059 +;WIDTH:0.491587 +G1 X-33.209 Y6.125 E.00981 +;WIDTH:0.455793 +G1 X-33.469 Y6.059 E.00903 +;WIDTH:0.419999 +G1 X-34.199 Y5.722 E.02471 +G1 X-34.855 Y5.179 E.02615 +G1 X-35.3 Y4.566 E.02327 +G1 X-35.602 Y3.821 E.02471 +G1 X-35.71 Y3.013 E.02504 +G1 X-35.71 Y-1.667 E.14382 +G1 X-35.614 Y-2.442 E.02399 +G1 X-35.323 Y-3.192 E.0247 +G1 X-34.855 Y-3.846 E.02471 +G1 X-34.239 Y-4.363 E.02471 +G1 X-33.514 Y-4.711 E.02471 +;WIDTH:0.455793 +G1 X-33.232 Y-4.782 E.00979 +;WIDTH:0.491587 +G1 X-32.95 Y-4.853 E.01064 +;WIDTH:0.527381 +G1 X-32.667 Y-4.924 E.01149 +G1 X11.69 Y-4.924 E1.75111 +G1 X11.933 Y-4.907 E.00964 +;WIDTH:0.491587 +G1 X12.177 Y-4.889 E.00893 +;WIDTH:0.455793 +G1 X12.42 Y-4.871 E.00821 +;WIDTH:0.419999 +G1 X17.461 Y-4.871 E.1549 +;WIDTH:0.455793 +G1 X17.704 Y-4.889 E.00819 +;WIDTH:0.491587 +G1 X17.947 Y-4.907 E.0089 +;WIDTH:0.527381 +G1 X18.189 Y-4.924 E.00961 +G1 X32.69 Y-4.924 E.57244 +G1 X32.931 Y-4.856 E.00989 +;WIDTH:0.491587 +G1 X33.172 Y-4.788 E.00916 +;WIDTH:0.455793 +G1 X33.413 Y-4.72 E.00843 +;WIDTH:0.419999 +G1 X33.882 Y-4.563 E.0152 +G1 X34.317 Y-4.307 E.01549 +G1 X34.855 Y-3.846 E.02178 +G1 X35.3 Y-3.233 E.02327 +G1 X35.602 Y-2.487 E.02471 +G1 X35.71 Y-1.68 E.02504 +G1 X35.71 Y7.826 E.29208 +;WIDTH:0.455231 +G1 X35.728 Y8.02 E.00657 +;WIDTH:0.490463 +G1 X35.745 Y8.215 E.00713 +;WIDTH:0.525695 +G1 X35.763 Y8.41 E.00769 +G1 X35.746 Y8.597 E.00741 +;WIDTH:0.491265 +G1 X35.728 Y8.785 E.00688 +;WIDTH:0.456835 +G1 X35.711 Y8.972 E.00635 +;WIDTH:0.422404 +G1 X35.71 Y9.557 E.01809 +;WIDTH:0.419999 +G1 X35.71 Y10.826 E.03897 +;WIDTH:0.455231 +G1 X35.728 Y11.02 E.00657 +;WIDTH:0.490463 +G1 X35.745 Y11.215 E.00713 +;WIDTH:0.525694 +G1 X35.763 Y11.41 E.00769 +G1 X35.746 Y11.597 E.00741 +;WIDTH:0.491265 +G1 X35.728 Y11.785 E.00688 +;WIDTH:0.456835 +G1 X35.711 Y11.972 E.00635 +;WIDTH:0.422405 +G1 X35.71 Y12.501 E.01634 +;WIDTH:0.419999 +G1 X35.602 Y13.321 E.02543 +G1 X35.323 Y14.025 E.02326 +G1 X34.855 Y14.679 E.02471 +G1 X34.239 Y15.196 E.02471 +M73 P93 R0 +G1 X33.514 Y15.544 E.02471 +M73 P93 R0 +G1 X32.708 Y15.704 E.02524 +G1 X-23.19 Y15.704 E1.71758 +G1 X-23.969 Y15.559 E.02436 +G1 X-24.699 Y15.222 E.02471 +G1 X-25.323 Y14.714 E.02471 +G1 X-25.8 Y14.066 E.02471 +G1 X-26.102 Y13.321 E.02471 +;WIDTH:0.45382 +G1 X-26.155 Y13.052 E.00918 +;WIDTH:0.48764 +G1 X-26.208 Y12.783 E.00994 +;WIDTH:0.52146 +G1 X-26.261 Y12.513 E.0107 +G1 X-26.261 Y9.999 E.09804 +;WIDTH:0.570985 +G1 X-26.285 Y9.182 E.03517 +G1 X-26.273 Y9.089 E.00405 +;WIDTH:0.542942 +G1 X-26.26 Y8.996 E.00383 +;WIDTH:0.514898 +G1 X-26.276 Y8.683 E.01206 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.811 Y9.215 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.570985 +G1 F12000 +G1 X-26.739 Y9.979 E.03301 +;WIDTH:0.52146 +G1 X-26.739 Y12.501 E.09832 +G1 X-26.788 Y12.759 E.01025 +;WIDTH:0.48764 +G1 X-26.837 Y13.017 E.00952 +;WIDTH:0.45382 +G1 X-26.886 Y13.275 E.0088 +;WIDTH:0.419999 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.455 Y14.449 E.01559 +G1 X-27.937 Y14.958 E.02153 +G1 X-28.613 Y15.393 E.02469 +G1 X-29.087 Y15.573 E.01558 +G1 X-29.793 Y15.704 E.02207 +G1 X-32.69 Y15.704 E.08901 +G1 X-33.469 Y15.559 E.02436 +G1 X-34.199 Y15.222 E.02471 +G1 X-34.823 Y14.714 E.02471 +G1 X-35.3 Y14.066 E.02471 +G1 X-35.577 Y13.383 E.02266 +G1 X-35.677 Y12.771 E.01906 +G1 X-35.71 Y12.164 E.01868 +;WIDTH:0.455134 +G1 X-35.728 Y11.985 E.00603 +;WIDTH:0.490268 +G1 X-35.745 Y11.806 E.00655 +;WIDTH:0.525402 +G1 X-35.763 Y11.628 E.00706 +;WIDTH:0.537534 +G1 X-35.769 Y11.219 E.01647 +G1 X-35.749 Y11.018 E.00813 +;WIDTH:0.498356 +G1 X-35.73 Y10.818 E.00748 +;WIDTH:0.459178 +G1 X-35.71 Y10.617 E.00684 +;WIDTH:0.419999 +G1 X-35.71 Y9.997 E.01904 +G1 X-35.617 Y9.255 E.02299 +G1 X-35.444 Y8.782 E.01548 +G1 X-35.234 Y8.342 E.01497 +G1 X-34.855 Y7.821 E.01981 +G1 X-34.239 Y7.304 E.02471 +G1 X-33.514 Y6.956 E.02471 +;WIDTH:0.455793 +G1 X-33.241 Y6.885 E.00949 +;WIDTH:0.491587 +G1 X-32.968 Y6.813 E.01031 +;WIDTH:0.527381 +G1 X-32.695 Y6.742 E.01113 +G1 X-29.81 Y6.744 E.11389 +;WIDTH:0.522983 +G1 X-29.551 Y6.81 E.01048 +;WIDTH:0.488655 +G1 X-29.291 Y6.876 E.00973 +;WIDTH:0.454327 +G1 X-29.031 Y6.941 E.00898 +;WIDTH:0.419999 +G1 X-28.261 Y7.304 E.02615 +G1 X-27.677 Y7.786 E.02327 +G1 X-27.2 Y8.434 E.02471 +;WIDTH:0.467449 +G1 X-27.029 Y8.792 E.01372 +;WIDTH:0.514898 +G1 X-26.859 Y9.15 E.01525 +;WIDTH:0.542942 +G1 X-26.835 Y9.183 E.00165 +;WIDTH:0.570985 +G1 X-26.835 Y9.183 E.00002 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.739 Y9.979 +G1 X-26.739 Y11.177 +;WIPE_END +G1 X-34.189 Y9.516 Z2.2 F30000 +G1 X-36.005 Y9.111 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.481194 +G1 F17112.581 +G1 X-35.964 Y8.987 E.00466 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F17112.581 +G1 X-36.005 Y9.111 +;WIPE_END +G1 X-28.868 Y6.595 Z2.2 F30000 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.433748 +G1 F18000 +G1 X-28.823 Y6.608 E.0015 +;WIDTH:0.4814 +G1 F17104.567 +G1 X-28.642 Y6.667 E.00679 +;WIDTH:0.529051 +G1 F15427.952 +G1 X-28.461 Y6.726 E.00753 +;WIDTH:0.576702 +G1 F14050.68 +G1 X-28.28 Y6.785 E.00827 +;WIDTH:0.615368 +G1 F13101.627 +G1 X-28.026 Y6.925 E.01353 +;WIDTH:0.654034 +G1 F12272.669 +G1 X-27.773 Y7.066 E.01445 +;WIDTH:0.655288 +G1 F12247.538 +G1 X-27.331 Y7.433 E.02865 +G1 X-26.988 Y7.894 E.02868 +;WIDTH:0.6429 +G1 F12500.417 +G1 X-26.858 Y8.155 E.01427 +;WIDTH:0.593449 +G1 F13623.261 +G1 X-26.728 Y8.417 E.0131 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F13623.261 +G1 X-26.858 Y8.155 +G1 X-26.988 Y7.894 +G1 X-27.331 Y7.433 +G1 X-27.773 Y7.066 +G1 X-28.006 Y6.936 +;WIPE_END +G1 X-35.075 Y6.508 Z2.2 F30000 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;TYPE:Internal solid infill +;WIDTH:0.419999 +G1 F15000 +G1 X-35.709 Y5.956 E.02584 +G1 X-36.129 Y5.376 E.02199 +G1 X-36.129 Y6.593 E.0374 +G1 X-35.713 Y6.95 E.01683 +G1 X-35.672 Y7.011 E.00225 +G1 X-35.105 Y6.534 E.02275 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.653 Y6.493 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.403514 +G1 F15000 +G1 X-35.725 Y6.435 E.00272 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.426 Y3.972 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.521764 +G1 F15000 +G1 X-36.453 Y4.279 E.01202 +;WIDTH:0.481058 +G1 X-36.479 Y4.586 E.011 +;WIDTH:0.420828 +G1 X-36.506 Y4.893 E.00949 +G1 X-36.506 Y6.782 E.05817 +G1 X-36.107 Y7.085 E.01542 +G1 X-35.731 Y7.6 E.01963 +G1 X-35.7 Y7.666 E.00224 +G1 X-35.352 Y7.212 E.0176 +G1 X-34.754 Y6.736 E.02354 +G1 X-34.372 Y6.493 E.01394 +G1 X-34.743 Y6.297 E.0129 +G1 X-35.461 Y5.671 E.02934 +G1 X-36.032 Y4.851 E.03078 +;WIDTH:0.437946 +G1 X-36.158 Y4.57 E.0099 +;WIDTH:0.47384 +G1 X-36.284 Y4.289 E.0108 +;WIDTH:0.491787 +G1 X-36.409 Y4.009 E.01125 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.445 Y3.93 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.524743 +G1 F15000 +G1 X-36.462 Y3.657 E.01073 +;WIDTH:0.489995 +G1 X-36.48 Y3.384 E.00996 +;WIDTH:0.436832 +G1 X-36.497 Y3.112 E.00877 +G1 X-36.5 Y-1.681 E.15385 +;WIDTH:0.452539 +G1 X-36.482 Y-1.937 E.00857 +;WIDTH:0.490239 +G1 X-36.463 Y-2.194 E.00936 +;WIDTH:0.52794 +G1 X-36.445 Y-2.45 E.01015 +;WIDTH:0.54679 +G1 F14884.807 +G1 X-36.426 Y-2.706 E.01055 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.075 Y-5.158 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-35.709 Y-5.711 E.02584 +G1 X-36.129 Y-6.291 E.02199 +G1 X-36.129 Y-4.061 E.06852 +G1 X-35.651 Y-4.683 E.02411 +G1 X-35.106 Y-5.133 E.02172 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.691 Y-5.223 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.48666 +G1 F15000 +G1 X-35.746 Y-5.223 E.00201 +G1 X-35.774 Y-5.175 E.00201 +G1 X-35.746 Y-5.127 E.00201 +G1 X-35.683 Y-5.14 E.00234 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.43 Y-7.734 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.517057 +G1 F15000 +G1 X-36.455 Y-7.415 E.01236 +;WIDTH:0.478234 +G1 X-36.481 Y-7.096 E.01135 +;WIDTH:0.421866 +G1 X-36.506 Y-6.777 E.00988 +G1 X-36.506 Y-3.77 E.09288 +;WIDTH:0.442477 +G1 X-36.483 Y-3.452 E.01036 +;WIDTH:0.487431 +G1 X-36.461 Y-3.135 E.01153 +;WIDTH:0.532386 +G1 X-36.438 Y-2.817 E.01269 +;WIDTH:0.569677 +G1 F14238.077 +G1 X-36.424 Y-2.746 E.00313 +;WIDTH:0.563929 +G1 F14395.156 +G1 X-36.307 Y-2.974 E.01087 +;WIDTH:0.522806 +G1 F15000 +G1 X-36.19 Y-3.201 E.01002 +;WIDTH:0.481683 +G1 X-36.073 Y-3.429 E.00916 +;WIDTH:0.421086 +G1 X-35.956 Y-3.657 E.00789 +G1 X-35.352 Y-4.454 E.0308 +G1 X-34.754 Y-4.931 E.02357 +G1 X-34.372 Y-5.174 E.01395 +G1 X-34.743 Y-5.369 E.01291 +G1 X-35.461 Y-5.995 E.02936 +G1 X-36.032 Y-6.816 E.0308 +;WIDTH:0.437244 +G1 X-36.16 Y-7.11 E.0103 +;WIDTH:0.471734 +G1 X-36.287 Y-7.404 E.0112 +;WIDTH:0.488979 +G1 X-36.414 Y-7.698 E.01165 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.448 Y-7.774 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.519005 +G1 F15000 +G1 X-36.465 Y-8.068 E.0114 +;WIDTH:0.484077 +G1 X-36.483 Y-8.361 E.01057 +;WIDTH:0.4346 +G1 X-36.5 Y-8.654 E.00938 +G1 X-36.5 Y-12.469 E.12174 +G1 X-36.466 Y-13.001 E.01703 +G1 X-36.358 Y-13.491 E.016 +G1 X-36.214 Y-13.971 E.01599 +G1 X-36 Y-14.424 E.01601 +G1 X-35.724 Y-14.842 E.01599 +G1 X-35.413 Y-15.236 E.016 +G1 X-35.04 Y-15.571 E.016 +G1 X-34.643 Y-15.877 E.01601 +G1 X-33.665 Y-16.32 E.03425 +G1 X-32.791 Y-16.489 E.02842 +G1 X11.688 Y-16.492 E1.41965 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.006 Y-15.07 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.349 Y-15.517 E.01729 +G1 X16.087 Y-16.119 E.02928 +G1 X13.935 Y-16.119 E.06613 +G1 X14.709 Y-15.456 E.03131 +G1 X14.982 Y-15.102 E.01373 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.032 Y-15.767 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.459962 +G1 F15000 +G1 X14.98 Y-15.767 E.00177 +G1 X14.954 Y-15.722 E.00177 +G1 X14.98 Y-15.677 E.00177 +G1 X15.038 Y-15.687 E.00201 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.137 Y-16.484 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.422094 +G1 F15000 +G1 X11.728 Y-16.49 E.19803 +G1 X12.805 Y-16.268 E.03397 +G1 X13.704 Y-15.824 E.03098 +G1 X14.461 Y-15.171 E.03089 +G1 X14.931 Y-14.533 E.02449 +G1 X15 Y-14.393 E.00482 +G1 X15.068 Y-14.531 E.00477 +G1 X15.648 Y-15.287 E.02944 +G1 X16.431 Y-15.909 E.03089 +G1 X17.344 Y-16.316 E.03089 +G1 X18.098 Y-16.476 E.02383 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.14 Y-16.485 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.435178 +G1 F15000 +G1 X32.69 Y-16.492 E.46505 +G1 X33.741 Y-16.293 E.03421 +G1 X34.197 Y-16.106 E.01575 +G1 X34.643 Y-15.877 E.01603 +G1 X35.04 Y-15.57 E.01603 +G1 X35.415 Y-15.238 E.01603 +G1 X35.722 Y-14.841 E.01603 +G1 X36.004 Y-14.41 E.01645 +G1 X36.371 Y-13.494 E.03155 +G1 X36.454 Y-12.999 E.01602 +G1 X36.5 Y-12.49 E.01637 +G1 X36.5 Y-8.652 E.12266 +;WIDTH:0.452528 +G1 X36.481 Y-8.394 E.00864 +;WIDTH:0.490206 +G1 X36.463 Y-8.136 E.00943 +;WIDTH:0.527885 +G1 X36.444 Y-7.878 E.01023 +;WIDTH:0.546724 +G1 F14886.757 +G1 X36.426 Y-7.62 E.01062 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.387 Y-5.175 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X34.777 Y-4.941 E.01396 +G1 X35.462 Y-4.337 E.02805 +G1 X36.032 Y-3.518 E.03069 +;WIDTH:0.443475 +G1 X36.154 Y-3.279 E.00874 +;WIDTH:0.490427 +G1 X36.275 Y-3.041 E.00976 +;WIDTH:0.537379 +G1 X36.396 Y-2.802 E.01079 +;WIDTH:0.57306 +G1 F14147.22 +G1 X36.423 Y-2.751 E.00249 +;WIDTH:0.564606 +G1 F14376.467 +G1 X36.444 Y-2.955 E.00872 +;WIDTH:0.52329 +G1 F15000 +G1 X36.465 Y-3.159 E.00803 +;WIDTH:0.481974 +G1 X36.485 Y-3.363 E.00734 +;WIDTH:0.421322 +G1 X36.506 Y-3.567 E.00632 +G1 X36.506 Y-6.564 E.09241 +;WIDTH:0.443214 +G1 X36.483 Y-6.881 E.01039 +;WIDTH:0.489643 +G1 X36.459 Y-7.199 E.01159 +;WIDTH:0.536073 +G1 X36.436 Y-7.517 E.0128 +;WIDTH:0.572498 +G1 F14162.234 +G1 X36.423 Y-7.58 E.00278 +;WIDTH:0.564995 +G1 F14365.769 +G1 X36.306 Y-7.354 E.01082 +;WIDTH:0.523567 +G1 F15000 +G1 X36.189 Y-7.128 E.00996 +;WIDTH:0.48214 +G1 X36.072 Y-6.902 E.0091 +;WIDTH:0.424367 +G1 X35.956 Y-6.676 E.0079 +G1 X35.352 Y-5.879 E.03107 +G1 X34.784 Y-5.406 E.02298 +G1 X34.422 Y-5.195 E.01302 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.089 Y-5.168 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X35.711 Y-4.62 E.02546 +G1 X36.129 Y-4.043 E.0219 +G1 X36.129 Y-6.273 E.06852 +G1 X35.651 Y-5.65 E.02411 +G1 X35.12 Y-5.194 E.02152 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.747 Y-5.205 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.483826 +G1 F15000 +G1 X35.692 Y-5.205 E.00198 +G1 X35.665 Y-5.158 E.00198 +G1 X35.692 Y-5.11 E.00198 +G1 X35.755 Y-5.123 E.0023 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.473 Y-2.15 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.511311 +G1 F15000 +G1 X36.449 Y-2.431 E.01073 +;WIDTH:0.535962 +G1 X36.426 Y-2.711 E.0113 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X36.449 Y-2.431 +;WIPE_END +G1 X28.895 Y-3.521 Z2.2 F30000 +G1 X14.115 Y-5.655 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X15.933 Y-5.655 E.05585 +G1 X15.54 Y-5.995 E.01597 +G1 X15.065 Y-6.639 E.02459 +G1 X14.989 Y-6.797 E.00538 +G1 X14.679 Y-6.259 E.01908 +G1 X14.144 Y-5.683 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.035 Y-6.092 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.451106 +G1 F15000 +G1 X14.984 Y-6.092 E.0017 +G1 X14.959 Y-6.048 E.0017 +G1 X14.984 Y-6.003 E.0017 +G1 X15.041 Y-6.013 E.00191 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X14.984 Y-6.003 +G1 X14.959 Y-6.048 +G1 X14.984 Y-6.092 +G1 X15.035 Y-6.092 +;WIPE_END +G1 X7.935 Y-3.291 Z2.2 F30000 +G1 X-36.15 Y14.098 Z2.2 +G1 Z1.8 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.433922 +G1 F15000 +G1 X-35.966 Y14.489 E.01375 +G1 X-35.475 Y15.166 E.02664 +G1 X-35.032 Y15.574 E.01919 +G1 X-34.641 Y15.874 E.0157 +G1 X-34.196 Y16.103 E.01597 +G1 X-33.724 Y16.306 E.01634 +G1 X-32.791 Y16.492 E.03032 +G1 X-29.81 Y16.492 E.09498 +;WIDTH:0.451855 +G1 X-29.531 Y16.469 E.00934 +;WIDTH:0.500087 +G1 X-29.252 Y16.446 E.01044 +;WIDTH:0.524204 +G1 X-28.972 Y16.423 E.01099 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.499 Y15.671 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.470062 +M73 P94 R0 +G1 F15000 +M73 P94 R0 +G1 X-26.552 Y15.671 E.00186 +G1 X-26.579 Y15.717 E.00186 +G1 X-26.552 Y15.763 E.00186 +G1 X-26.492 Y15.751 E.00213 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.436 Y16.119 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +;WIDTH:0.419999 +G1 F15000 +G1 X-26.157 Y15.518 E.02883 +G1 X-26.488 Y15.092 E.01659 +G1 X-27.248 Y15.884 E.03373 +G1 X-27.598 Y16.119 E.01297 +G1 X-25.476 Y16.119 E.06521 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.592 Y16.461 F30000 +SET_VELOCITY_LIMIT ACCEL=7000 +G1 F15000 +G1 X-24.309 Y16.267 E.02284 +G1 X-25.203 Y15.824 E.03066 +G1 X-25.913 Y15.23 E.02843 +G1 X-26.455 Y14.498 E.02799 +G1 X-26.5 Y14.406 E.00314 +G1 X-26.779 Y14.854 E.01622 +G1 X-27.519 Y15.621 E.03274 +G1 X-28.339 Y16.129 E.02964 +;WIDTH:0.442609 +G1 X-28.523 Y16.219 E.0067 +;WIDTH:0.487828 +G1 X-28.708 Y16.31 E.00746 +;WIDTH:0.533048 +G1 X-28.893 Y16.4 E.00822 +;WIDTH:0.56418 +G1 F14388.227 +G1 X-28.932 Y16.42 E.00187 +;WIDTH:0.553614 +G1 F14685.894 +G1 X-28.778 Y16.439 E.00648 +;WIDTH:0.515439 +G1 F15000 +G1 X-28.623 Y16.458 E.006 +;WIDTH:0.477263 +G1 X-28.469 Y16.477 E.00551 +;WIDTH:0.421226 +G1 X-28.314 Y16.496 E.0048 +G1 X-23.664 Y16.496 E.14336 +G1 X-23.628 Y16.478 E.00124 +;LAYER_CHANGE +;Z:2 +;HEIGHT:0.2 +;BEFORE_LAYER_CHANGE +;2 +G1 E-.8 F2100 +;WIPE_START +G1 F15000 +G1 X-23.664 Y16.496 +G1 X-25.624 Y16.496 +;WIPE_END +;AFTER_LAYER_CHANGE +;2 +;_SET_FAN_SPEED_CHANGING_LAYER +SET_VELOCITY_LIMIT ACCEL=10000 +G17 +G3 Z2.4 I.705 J-.992 P1 F30000 +G1 X-36.005 Y9.111 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.489033 +G1 F16811.885 +G1 X-36.026 Y9.204 E.00344 +;WIDTH:0.526809 +G1 F15499.422 +G1 X-36.14 Y9.994 E.0315 +G1 X-36.14 Y10.599 E.02383 +G1 X-36.53 Y10.186 E.02238 +G1 X-36.859 Y10.008 E.01476 +G1 X-36.525 Y9.797 E.01562 +G1 X-36.233 Y9.531 E.01557 +G1 X-36.024 Y9.147 E.01724 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F15499.422 +G1 X-36.026 Y9.204 +G1 X-36.14 Y9.994 +G1 X-36.14 Y10.599 +G1 X-36.511 Y10.206 +;WIPE_END +G1 X-29.226 Y12.483 Z2.4 F30000 +G1 X-26.5 Y13.334 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.449999 +G1 F18000 +G1 X-26.466 Y13.465 E.00448 +G1 X-26.188 Y14.169 E.02512 +G1 X-25.918 Y14.572 E.01606 +G1 X-25.573 Y15.016 E.01866 +G1 X-24.866 Y15.576 E.02992 +G1 X-24.042 Y15.944 E.02992 +G1 X-23.54 Y16.04 E.01696 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.453 Y16.078 E.00355 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.365 Y16.116 E.00392 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.277 Y16.154 E.00429 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.192 E.00467 +G1 X32.757 Y16.192 E2.73304 +G1 X33.602 Y16.029 E.04204 +;WIDTH:0.655272 +G1 F12247.857 +G1 X34.067 Y15.831 E.02524 +G1 X34.551 Y15.573 E.02734 +;WIDTH:0.643372 +G1 F12490.59 +G1 X35.166 Y15.062 E.03914 +;WIDTH:0.655272 +G1 F12247.857 +G1 X35.478 Y14.664 E.02524 +G1 X35.778 Y14.205 E.02733 +;WIDTH:0.643371 +G1 F12490.612 +G1 X36.056 Y13.501 E.03706 +;WIDTH:0.64962 +G1 F12361.958 +G1 X36.127 Y13.139 E.01821 +G1 X36.196 Y12.587 E.02751 +G1 X36.355 Y12.749 E.01119 +G1 X36.763 Y13.039 E.02475 +G1 X36.659 Y13.587 E.02759 +;WIDTH:0.65527 +G1 F12247.898 +G1 X36.484 Y14.098 E.02694 +G1 X36.258 Y14.587 E.0269 +G1 X35.962 Y15.039 E.02694 +G1 X35.621 Y15.456 E.0269 +G1 X35.221 Y15.82 E.02694 +G1 X34.787 Y16.14 E.0269 +G1 X34.31 Y16.392 E.02694 +G1 X33.81 Y16.594 E.0269 +G1 X33.285 Y16.72 E.02694 +G1 X32.69 Y16.792 E.02994 +;WIDTH:0.642408 +G1 F12510.675 +G1 X-23.19 Y16.792 E2.72975 +G1 X-23.297 Y16.816 E.00539 +;WIDTH:0.594306 +G1 F13602.092 +G1 X-23.405 Y16.84 E.00496 +;WIDTH:0.546204 +G1 F14902.138 +G1 X-23.513 Y16.864 E.00453 +;WIDTH:0.498102 +G1 F16476.954 +G1 X-23.62 Y16.888 E.00409 +;WIDTH:0.449999 +G1 F18000 +G1 X-32.776 Y16.888 E.30371 +G1 X-33.324 Y16.819 E.01832 +G1 X-33.859 Y16.682 E.01832 +G1 X-34.349 Y16.487 E.01749 +G1 X-34.802 Y16.242 E.01709 +G1 X-35.187 Y15.971 E.01563 +;WIDTH:0.498813 +G1 F16451.239 +G1 X-35.256 Y15.879 E.00427 +;WIDTH:0.547627 +G1 F14860.122 +G1 X-35.324 Y15.787 E.00472 +;WIDTH:0.596441 +G1 F13549.64 +G1 X-35.392 Y15.695 E.00518 +;WIDTH:0.645254 +G1 F12451.563 +G1 X-35.461 Y15.602 E.00564 +G1 X-35.665 Y15.407 E.01385 +;WIDTH:0.655274 +G1 F12247.818 +G1 X-35.984 Y15.008 E.02553 +G1 X-36.276 Y14.554 E.0269 +G1 X-36.498 Y14.062 E.02694 +G1 X-36.668 Y13.551 E.0269 +;WIDTH:0.649624 +G1 F12361.877 +G1 X-36.763 Y13.039 E.02573 +G1 X-36.606 Y12.932 E.0094 +G1 X-36.196 Y12.588 E.02644 +G1 X-36.056 Y13.501 E.04567 +;WIDTH:0.643371 +G1 F12490.612 +G1 X-35.76 Y14.244 E.03914 +;WIDTH:0.655274 +G1 F12247.818 +G1 X-35.478 Y14.664 E.02524 +G1 X-35.135 Y15.089 E.02728 +;WIDTH:0.64199 +G1 F12519.405 +G1 X-35.022 Y15.153 E.00633 +;WIDTH:0.597396 +G1 F13526.301 +G1 X-34.91 Y15.218 E.00586 +;WIDTH:0.552801 +G1 F14709.324 +G1 X-34.797 Y15.282 E.00539 +;WIDTH:0.508206 +G1 F16119.118 +G1 X-34.607 Y15.415 E.00878 +;WIDTH:0.479103 +G1 F17194.637 +G1 X-34.417 Y15.547 E.00823 +;WIDTH:0.449999 +G1 F18000 +G1 X-33.966 Y15.786 E.01693 +G1 X-33.211 Y16.03 E.02634 +G1 X-32.69 Y16.096 E.01741 +G1 X-29.753 Y16.096 E.09743 +G1 X-28.874 Y15.919 E.02973 +G1 X-28.402 Y15.724 E.01693 +G1 X-27.651 Y15.226 E.02989 +G1 X-27.079 Y14.605 E.02803 +G1 X-26.812 Y14.169 E.01693 +G1 X-26.514 Y13.372 E.02825 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-26.466 Y13.465 +G1 X-26.188 Y14.169 +G1 X-25.918 Y14.572 +G1 X-25.573 Y15.016 +G1 X-25.501 Y15.072 +;WIPE_END +G1 X-18.766 Y11.483 Z2.4 F30000 +G1 X12.661 Y-5.263 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X13.097 Y-5.442 E.01567 +G1 X13.849 Y-5.941 E.02992 +G1 X14.453 Y-6.611 E.02992 +G1 X14.818 Y-7.275 E.02512 +G1 X14.983 Y-7.843 E.01964 +G1 X15.312 Y-6.997 E.03012 +G1 X15.522 Y-6.678 E.01266 +G1 X15.927 Y-6.151 E.02207 +G1 X16.634 Y-5.59 E.02992 +G1 X17.318 Y-5.265 E.02512 +G1 X12.701 Y-5.263 E.15317 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X13.097 Y-5.442 +G1 X13.849 Y-5.941 +G1 X14.293 Y-6.433 +;WIPE_END +G1 X6.928 Y-4.43 Z2.4 F30000 +G1 X-33.286 Y6.507 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +G1 F18000 +G1 X-33.681 Y6.601 E.01347 +G1 X-34.366 Y6.924 E.02512 +G1 X-34.654 Y7.138 E.0119 +G1 X-35.172 Y7.59 E.02282 +G1 X-35.551 Y8.111 E.02135 +G1 X-35.719 Y8.409 E.01135 +;WIDTH:0.481174 +G1 F17113.364 +G1 X-35.965 Y8.988 E.02247 +G1 X-35.907 Y8.609 E.01368 +;WIDTH:0.451204 +G1 F18000 +G1 X-35.924 Y8.308 E.01002 +;WIDTH:0.449999 +G1 X-36.06 Y7.813 E.01703 +G1 X-36.331 Y7.404 E.01629 +G1 X-36.725 Y7.105 E.01638 +G1 X-36.898 Y7.027 E.00631 +G1 X-36.898 Y-12.481 E.64712 +G1 X-36.862 Y-13.051 E.01894 +G1 X-36.759 Y-13.593 E.01832 +G1 X-36.588 Y-14.119 E.01832 +G1 X-36.353 Y-14.618 E.01832 +G1 X-36.057 Y-15.084 E.01832 +G1 X-35.705 Y-15.51 E.01832 +G1 X-35.303 Y-15.888 E.01832 +G1 X-34.856 Y-16.212 E.01832 +G1 X-34.372 Y-16.478 E.01832 +G1 X-33.859 Y-16.682 E.01832 +G1 X-33.324 Y-16.819 E.01832 +G1 X-32.776 Y-16.888 E.01832 +G1 X32.776 Y-16.888 E2.17448 +G1 X33.294 Y-16.823 E.01733 +G1 X33.86 Y-16.681 E.01935 +G1 X34.372 Y-16.478 E.01826 +G1 X34.856 Y-16.212 E.01832 +G1 X35.303 Y-15.888 E.01832 +G1 X35.705 Y-15.51 E.01832 +G1 X36.057 Y-15.084 E.01832 +G1 X36.337 Y-14.643 E.01733 +G1 X36.588 Y-14.118 E.01929 +G1 X36.764 Y-13.564 E.01929 +G1 X36.862 Y-13.051 E.01733 +G1 X36.898 Y-12.481 E.01894 +G1 X36.898 Y-2.097 E.34447 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.874 Y-1.989 E.00409 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.849 Y-1.882 E.00452 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.825 Y-1.775 E.00496 +;WIDTH:0.644381 +G1 F12469.637 +G1 X36.801 Y-1.667 E.00539 +G1 X36.801 Y6.966 E.42312 +G1 X36.357 Y7.252 E.02587 +G1 X36.199 Y7.42 E.0113 +G1 X36.199 Y-1.667 E.44539 +G1 X36.164 Y-1.761 E.00492 +;WIDTH:0.595786 +G1 F13565.687 +G1 X36.129 Y-1.856 E.00452 +;WIDTH:0.54719 +G1 F14872.984 +G1 X36.094 Y-1.95 E.00413 +;WIDTH:0.498595 +G1 F16459.116 +G1 X36.059 Y-2.044 E.00373 +;WIDTH:0.449999 +G1 F18000 +G1 X35.966 Y-2.632 E.01975 +G1 X35.617 Y-3.464 E.02992 +G1 X35.172 Y-4.076 E.02512 +G1 X34.973 Y-4.265 E.0091 +G1 X34.435 Y-4.701 E.02298 +G1 X33.966 Y-4.953 E.01766 +G1 X33.286 Y-5.174 E.02372 +G1 X33.542 Y-5.223 E.00867 +G1 X33.939 Y-5.389 E.01425 +M73 P95 R0 +G1 X34.523 Y-5.696 E.0219 +M73 P95 R0 +G1 X35.172 Y-6.257 E.02847 +G1 X35.688 Y-6.997 E.02992 +G1 X36.003 Y-7.842 E.02992 +G1 X36.102 Y-8.666 E.02752 +G1 X36.102 Y-12.469 E.12614 +G1 X36.05 Y-12.948 E.01601 +G1 X35.966 Y-13.465 E.01736 +G1 X35.688 Y-14.169 E.02512 +G1 X35.395 Y-14.603 E.01736 +G1 X35.073 Y-15.016 E.01736 +G1 X34.489 Y-15.499 E.02512 +G1 X34.024 Y-15.738 E.01736 +G1 X33.542 Y-15.944 E.01736 +G1 X32.69 Y-16.096 E.02874 +G1 X18.25 Y-16.096 E.47899 +G1 X17.321 Y-15.9 E.03151 +G1 X16.578 Y-15.543 E.02733 +G1 X16.151 Y-15.226 E.01766 +G1 X15.547 Y-14.556 E.02992 +G1 X15.136 Y-13.785 E.02895 +G1 X15.009 Y-13.291 E.01694 +G1 X14.818 Y-13.892 E.02091 +G1 X14.453 Y-14.556 E.02513 +G1 X14.145 Y-14.911 E.01562 +G1 X13.737 Y-15.318 E.01911 +G1 X12.966 Y-15.786 E.02992 +G1 X12.246 Y-16.02 E.02512 +G1 X11.69 Y-16.096 E.01861 +G1 X-32.69 Y-16.096 E1.47214 +G1 X-33.171 Y-16.015 E.01617 +G1 X-33.681 Y-15.899 E.01736 +G1 X-34.366 Y-15.576 E.02512 +G1 X-34.781 Y-15.257 E.01736 +G1 X-35.172 Y-14.91 E.01736 +G1 X-35.617 Y-14.297 E.02512 +G1 X-35.827 Y-13.817 E.01736 +G1 X-36.003 Y-13.324 E.01736 +G1 X-36.102 Y-12.469 E.02857 +G1 X-36.102 Y-8.627 E.12744 +G1 X-35.984 Y-7.76 E.02902 +G1 X-35.818 Y-7.275 E.01701 +G1 X-35.368 Y-6.493 E.02992 +G1 X-34.872 Y-5.965 E.02403 +G1 X-34.556 Y-5.734 E.01298 +G1 X-33.966 Y-5.381 E.02282 +G1 X-33.286 Y-5.16 E.02372 +G1 X-33.681 Y-5.066 E.01347 +G1 X-34.366 Y-4.743 E.02512 +G1 X-34.654 Y-4.529 E.01191 +G1 X-35.172 Y-4.076 E.02282 +G1 X-35.688 Y-3.336 E.02992 +G1 X-36.003 Y-2.491 E.02992 +G1 X-36.102 Y-1.667 E.02752 +G1 X-36.102 Y3.039 E.15613 +G1 X-35.988 Y3.887 E.02839 +G1 X-35.818 Y4.392 E.01766 +G1 X-35.368 Y5.173 E.02992 +G1 X-34.849 Y5.726 E.02513 +G1 X-34.556 Y5.933 E.0119 +G1 X-33.966 Y6.286 E.02282 +G1 X-33.324 Y6.495 E.02239 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F18000 +G1 X-33.681 Y6.601 +G1 X-34.366 Y6.924 +G1 X-34.654 Y7.138 +G1 X-35.039 Y7.474 +;WIPE_END +G1 X-27.409 Y7.68 Z2.4 F30000 +G1 X36.142 Y9.392 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.5306 +G1 F15378.936 +G1 X36.39 Y9.701 E.01571 +G1 X36.842 Y10 E.02155 +G1 X36.391 Y10.298 E.0215 +G1 X36.142 Y10.608 E.01577 +G1 X36.142 Y9.432 E.04671 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.243 Y8.448 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Outer wall +;WIDTH:0.525666 +G1 F12000 +G1 X36.241 Y8.455 E.00026 +G1 X36.282 Y8.607 E.0062 +;WIDTH:0.491159 +G1 X36.323 Y8.759 E.00576 +;WIDTH:0.456651 +G1 X36.364 Y8.912 E.00532 +;WIDTH:0.422143 +G1 X36.596 Y9.3 E.01397 +;WIDTH:0.419999 +G1 X36.954 Y9.577 E.01392 +G1 X37.29 Y9.668 E.0107 +G1 X37.29 Y10.331 E.02037 +G1 X37.024 Y10.391 E.00837 +G1 X36.645 Y10.648 E.01408 +G1 X36.391 Y11.023 E.01391 +;WIDTH:0.455222 +G1 X36.341 Y11.167 E.00512 +;WIDTH:0.490444 +G1 X36.291 Y11.311 E.00555 +;WIDTH:0.525666 +G1 X36.241 Y11.455 E.00599 +G1 X36.282 Y11.607 E.0062 +;WIDTH:0.491159 +G1 X36.323 Y11.759 E.00576 +;WIDTH:0.456651 +G1 X36.364 Y11.912 E.00532 +;WIDTH:0.422143 +G1 X36.596 Y12.3 E.01397 +;WIDTH:0.419999 +G1 X36.954 Y12.577 E.01392 +G1 X37.279 Y12.666 E.01035 +G1 X37.252 Y13.1 E.01337 +G1 X37.139 Y13.691 E.01848 +G1 X36.953 Y14.263 E.01848 +G1 X36.697 Y14.807 E.01848 +G1 X36.375 Y15.315 E.01848 +G1 X35.991 Y15.779 E.01848 +G1 X35.553 Y16.19 E.01848 +G1 X35.066 Y16.544 E.01848 +G1 X34.539 Y16.834 E.01848 +G1 X33.98 Y17.055 E.01848 +G1 X33.397 Y17.205 E.01848 +G1 X32.801 Y17.28 E.01848 +G1 X-32.801 Y17.28 E2.01575 +G1 X-33.397 Y17.205 E.01848 +G1 X-33.98 Y17.055 E.01848 +G1 X-34.539 Y16.834 E.01848 +G1 X-35.066 Y16.544 E.01848 +G1 X-35.552 Y16.191 E.01846 +G1 X-36.011 Y15.755 E.01944 +G1 X-36.375 Y15.315 E.01754 +G1 X-36.697 Y14.807 E.01848 +G1 X-36.932 Y14.309 E.01693 +G1 X-37.105 Y13.797 E.01661 +G1 X-37.223 Y13.253 E.0171 +G1 X-37.279 Y12.666 E.01811 +G1 X-37.024 Y12.609 E.00803 +G1 X-36.645 Y12.352 E.01408 +G1 X-36.544 Y12.23 E.00486 +;WIDTH:0.46511 +G1 X-36.404 Y11.965 E.01031 +;WIDTH:0.510221 +G1 X-36.264 Y11.699 E.01142 +;WIDTH:0.538049 +G1 X-36.264 Y11.23 E.01896 +G1 X-36.344 Y11.093 E.0064 +;WIDTH:0.498699 +G1 X-36.423 Y10.956 E.00589 +;WIDTH:0.459349 +G1 X-36.503 Y10.819 E.00538 +;WIDTH:0.419999 +G1 X-36.824 Y10.5 E.0139 +G1 X-37.29 Y10.309 E.01546 +G1 X-37.29 Y9.669 E.01969 +G1 X-37.024 Y9.609 E.00837 +G1 X-36.645 Y9.352 E.01408 +G1 X-36.391 Y8.977 E.01391 +G1 X-36.293 Y8.535 E.0139 +G1 X-36.365 Y8.089 E.0139 +G1 X-36.596 Y7.7 E.0139 +G1 X-36.955 Y7.423 E.01393 +G1 X-37.29 Y7.331 E.01067 +G1 X-37.29 Y-12.493 E.60916 +G1 X-37.252 Y-13.1 E.01868 +G1 X-37.139 Y-13.691 E.01848 +G1 X-36.953 Y-14.263 E.01848 +G1 X-36.697 Y-14.807 E.01848 +G1 X-36.375 Y-15.315 E.01848 +G1 X-35.991 Y-15.779 E.01848 +G1 X-35.553 Y-16.19 E.01848 +G1 X-35.066 Y-16.544 E.01848 +G1 X-34.539 Y-16.834 E.01848 +G1 X-33.98 Y-17.055 E.01848 +G1 X-33.397 Y-17.205 E.01848 +G1 X-32.801 Y-17.28 E.01848 +G1 X32.801 Y-17.28 E2.01575 +G1 X33.367 Y-17.209 E.01754 +G1 X33.981 Y-17.055 E.01944 +G1 X34.539 Y-16.834 E.01846 +G1 X35.066 Y-16.544 E.01848 +G1 X35.553 Y-16.19 E.01848 +G1 X35.991 Y-15.779 E.01848 +G1 X36.375 Y-15.315 E.01848 +G1 X36.681 Y-14.833 E.01754 +G1 X36.953 Y-14.263 E.01942 +G1 X37.145 Y-13.661 E.01942 +G1 X37.252 Y-13.1 E.01754 +G1 X37.29 Y-12.493 E.01868 +G1 X37.29 Y7.331 E.60916 +G1 X37.024 Y7.391 E.00837 +G1 X36.645 Y7.648 E.01408 +G1 X36.391 Y8.023 E.01391 +;WIDTH:0.455222 +G1 X36.341 Y8.167 E.00512 +;WIDTH:0.490444 +G1 X36.291 Y8.311 E.00555 +;WIDTH:0.525666 +G1 X36.257 Y8.411 E.00415 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X36.241 Y8.455 +G1 X36.282 Y8.607 +G1 X36.323 Y8.759 +G1 X36.364 Y8.912 +G1 X36.596 Y9.3 +G1 X36.954 Y9.577 +G1 X37.29 Y9.668 +G1 X37.29 Y9.895 +;WIPE_END +G1 X36.529 Y2.301 Z2.4 F30000 +G1 X34.823 Y-14.714 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.419999 +G1 F12000 +G1 X35.3 Y-14.066 E.02471 +G1 X35.602 Y-13.321 E.02471 +G1 X35.71 Y-12.513 E.02504 +G1 X35.71 Y-8.666 E.11821 +G1 X35.614 Y-7.892 E.02398 +G1 X35.323 Y-7.142 E.02471 +G1 X34.855 Y-6.487 E.02471 +G1 X34.273 Y-5.998 E.02337 +G1 X33.469 Y-5.608 E.02746 +G1 X33.068 Y-5.531 E.01256 +;WIDTH:0.455793 +G1 X32.942 Y-5.49 E.00445 +;WIDTH:0.491587 +G1 X32.816 Y-5.45 E.00484 +;WIDTH:0.527381 +G1 X32.69 Y-5.409 E.00523 +G1 X18.31 Y-5.409 E.56767 +G1 X18.05 Y-5.475 E.01058 +;WIDTH:0.491587 +G1 X17.791 Y-5.542 E.0098 +;WIDTH:0.455793 +G1 X17.531 Y-5.608 E.00902 +;WIDTH:0.419999 +G1 X16.801 Y-5.945 E.02471 +G1 X16.177 Y-6.453 E.02471 +G1 X15.839 Y-6.909 E.01744 +G1 X15.677 Y-7.142 E.00872 +G1 X15.398 Y-7.846 E.02327 +;WIDTH:0.45382 +G1 X15.345 Y-8.112 E.00908 +;WIDTH:0.48764 +G1 X15.292 Y-8.378 E.00982 +;WIDTH:0.52146 +G1 X15.239 Y-8.643 E.01057 +G1 X15.239 Y-12.501 E.15041 +G1 X15.289 Y-12.73 E.00914 +;WIDTH:0.48764 +G1 X15.338 Y-12.959 E.00849 +;WIDTH:0.45382 +G1 X15.387 Y-13.188 E.00784 +;WIDTH:0.419999 +G1 X15.525 Y-13.703 E.01639 +G1 X15.89 Y-14.367 E.02327 +G1 X16.437 Y-14.957 E.02472 +G1 X16.834 Y-15.24 E.015 +G1 X17.487 Y-15.544 E.02211 +M73 P96 R0 +G1 X18.292 Y-15.704 E.02522 +M73 P96 R0 +G1 X32.69 Y-15.704 E.44241 +G1 X33.469 Y-15.559 E.02436 +G1 X34.199 Y-15.222 E.02471 +G1 X34.792 Y-14.739 E.02348 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X35.3 Y-14.066 +G1 X35.602 Y-13.321 +G1 X35.649 Y-12.971 +;WIPE_END +G1 X28.371 Y-10.671 Z2.4 F30000 +G1 X11.723 Y-5.409 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.527381 +G1 F12000 +G1 X-32.69 Y-5.409 E1.7533 +G1 X-32.931 Y-5.477 E.00989 +;WIDTH:0.491587 +G1 X-33.172 Y-5.545 E.00916 +;WIDTH:0.455793 +G1 X-33.413 Y-5.613 E.00843 +;WIDTH:0.419999 +G1 X-33.888 Y-5.774 E.01539 +G1 X-34.527 Y-6.179 E.02327 +G1 X-35.082 Y-6.762 E.02471 +G1 X-35.475 Y-7.464 E.02472 +G1 X-35.618 Y-7.913 E.01448 +G1 X-35.71 Y-8.654 E.02297 +G1 X-35.71 Y-12.469 E.1172 +G1 X-35.614 Y-13.275 E.02496 +G1 X-35.323 Y-14.025 E.02471 +G1 X-34.855 Y-14.679 E.02471 +G1 X-34.239 Y-15.196 E.02471 +G1 X-33.514 Y-15.544 E.02471 +G1 X-32.708 Y-15.704 E.02526 +G1 X11.69 Y-15.704 E1.3642 +G1 X12.124 Y-15.647 E.01347 +G1 X12.845 Y-15.413 E.02327 +G1 X13.527 Y-14.987 E.02471 +G1 X14.082 Y-14.405 E.02471 +G1 X14.475 Y-13.703 E.02471 +G1 X14.621 Y-13.237 E.015 +;WIDTH:0.45382 +G1 X14.667 Y-13.001 E.00806 +;WIDTH:0.48764 +G1 X14.714 Y-12.765 E.00872 +;WIDTH:0.52146 +G1 X14.761 Y-12.529 E.00938 +G1 X14.761 Y-8.666 E.15064 +G1 X14.708 Y-8.424 E.00965 +;WIDTH:0.48764 +G1 X14.656 Y-8.183 E.00896 +;WIDTH:0.45382 +G1 X14.603 Y-7.941 E.00828 +;WIDTH:0.419999 +G1 X14.475 Y-7.464 E.01519 +G1 X14.11 Y-6.8 E.02327 +G1 X13.563 Y-6.209 E.02471 +G1 X12.887 Y-5.773 E.02472 +G1 X12.432 Y-5.598 E.015 +;WIDTH:0.455793 +G1 X12.195 Y-5.535 E.00823 +;WIDTH:0.491587 +G1 X11.959 Y-5.472 E.00894 +;WIDTH:0.527381 +G1 X11.762 Y-5.419 E.00808 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X9.762 Y-5.419 +;WIPE_END +G1 X2.647 Y-2.655 Z2.4 F30000 +G1 X-26.281 Y8.585 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.467448 +G1 F12000 +G1 X-26.292 Y8.362 E.00773 +;WIDTH:0.419999 +G1 X-26.534 Y7.714 E.02124 +G1 X-26.854 Y7.247 E.01742 +G1 X-27.047 Y7.025 E.00903 +G1 X-27.506 Y6.646 E.01829 +G1 X-28.138 Y6.348 E.02148 +G1 X-28.831 Y6.216 E.02167 +;WIDTH:0.454327 +G1 X-29.115 Y6.232 E.00953 +;WIDTH:0.488655 +G1 X-29.399 Y6.248 E.01033 +;WIDTH:0.522983 +G1 X-29.683 Y6.265 E.01112 +;WIDTH:0.527381 +G1 X-32.689 Y6.258 E.11868 +G1 X-32.93 Y6.19 E.0099 +;WIDTH:0.491587 +G1 X-33.172 Y6.122 E.00917 +;WIDTH:0.455793 +G1 X-33.413 Y6.053 E.00844 +;WIDTH:0.419999 +G1 X-33.888 Y5.893 E.01539 +G1 X-34.527 Y5.487 E.02327 +G1 X-35.082 Y4.905 E.02472 +G1 X-35.475 Y4.203 E.02472 +G1 X-35.621 Y3.737 E.015 +G1 X-35.71 Y3.012 E.02245 +G1 X-35.71 Y-1.667 E.14379 +G1 X-35.614 Y-2.442 E.02398 +G1 X-35.323 Y-3.192 E.02471 +G1 X-34.855 Y-3.846 E.02471 +G1 X-34.199 Y-4.388 E.02615 +G1 X-33.514 Y-4.711 E.02327 +;WIDTH:0.455793 +G1 X-33.241 Y-4.782 E.00949 +;WIDTH:0.491587 +G1 X-32.968 Y-4.853 E.01031 +;WIDTH:0.527381 +G1 X-32.695 Y-4.924 E.01113 +G1 X11.69 Y-4.924 E1.75221 +G1 X11.96 Y-4.907 E.01068 +;WIDTH:0.491587 +G1 X12.229 Y-4.889 E.00989 +;WIDTH:0.455793 +G1 X12.499 Y-4.871 E.0091 +;WIDTH:0.419999 +G1 X17.461 Y-4.871 E.15247 +;WIDTH:0.455793 +G1 X17.704 Y-4.889 E.00819 +;WIDTH:0.491587 +G1 X17.947 Y-4.907 E.0089 +;WIDTH:0.527381 +G1 X18.189 Y-4.924 E.0096 +G1 X32.69 Y-4.924 E.57244 +G1 X32.931 Y-4.856 E.00989 +;WIDTH:0.491587 +G1 X33.172 Y-4.788 E.00916 +;WIDTH:0.455793 +G1 X33.413 Y-4.72 E.00843 +;WIDTH:0.419999 +G1 X33.879 Y-4.565 E.0151 +G1 X34.27 Y-4.34 E.01385 +G1 X34.823 Y-3.88 E.02211 +G1 X35.3 Y-3.233 E.0247 +G1 X35.602 Y-2.487 E.02471 +G1 X35.71 Y-1.68 E.02504 +G1 X35.71 Y7.825 E.29206 +;WIDTH:0.455222 +G1 X35.728 Y8.02 E.00657 +;WIDTH:0.490444 +G1 X35.745 Y8.215 E.00713 +;WIDTH:0.525666 +G1 X35.763 Y8.409 E.00769 +G1 X35.746 Y8.597 E.00743 +;WIDTH:0.491159 +G1 X35.728 Y8.785 E.0069 +;WIDTH:0.456651 +G1 X35.711 Y8.973 E.00636 +;WIDTH:0.422143 +G1 X35.71 Y9.557 E.01803 +;WIDTH:0.419999 +G1 X35.71 Y10.825 E.03897 +;WIDTH:0.455222 +G1 X35.728 Y11.02 E.00657 +;WIDTH:0.490444 +G1 X35.745 Y11.215 E.00713 +;WIDTH:0.525666 +G1 X35.763 Y11.409 E.00769 +G1 X35.746 Y11.597 E.00743 +;WIDTH:0.491159 +G1 X35.728 Y11.785 E.0069 +;WIDTH:0.456651 +G1 X35.711 Y11.973 E.00636 +;WIDTH:0.422143 +G1 X35.71 Y12.501 E.01629 +;WIDTH:0.419999 +G1 X35.602 Y13.321 E.02542 +G1 X35.323 Y14.025 E.02327 +G1 X34.855 Y14.679 E.02471 +G1 X34.239 Y15.196 E.02471 +G1 X33.514 Y15.544 E.02471 +G1 X32.708 Y15.704 E.02524 +G1 X-23.19 Y15.704 E1.71758 +G1 X-23.969 Y15.559 E.02436 +G1 X-24.699 Y15.222 E.02471 +G1 X-25.323 Y14.714 E.02471 +G1 X-25.8 Y14.066 E.02471 +G1 X-26.102 Y13.321 E.02471 +;WIDTH:0.45382 +G1 X-26.155 Y13.052 E.00918 +;WIDTH:0.48764 +G1 X-26.208 Y12.783 E.00994 +;WIDTH:0.52146 +G1 X-26.261 Y12.513 E.0107 +G1 X-26.261 Y9.999 E.09804 +;WIDTH:0.570985 +G1 X-26.285 Y9.182 E.03517 +G1 X-26.273 Y9.089 E.00405 +;WIDTH:0.542941 +G1 X-26.26 Y8.996 E.00383 +;WIDTH:0.514897 +G1 X-26.276 Y8.679 E.01222 +;WIDTH:0.467448 +G1 X-26.279 Y8.625 E.00187 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.811 Y9.215 F30000 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.570985 +G1 F12000 +G1 X-26.739 Y9.979 E.03301 +;WIDTH:0.52146 +G1 X-26.739 Y12.501 E.09832 +G1 X-26.788 Y12.759 E.01025 +;WIDTH:0.48764 +G1 X-26.837 Y13.017 E.00952 +;WIDTH:0.45382 +G1 X-26.886 Y13.275 E.0088 +;WIDTH:0.419999 +G1 X-27.177 Y14.025 E.02471 +G1 X-27.43 Y14.42 E.01442 +G1 X-27.937 Y14.957 E.02269 +G1 X-28.613 Y15.393 E.02471 +G1 X-29.05 Y15.564 E.01441 +G1 X-29.793 Y15.704 E.02324 +G1 X-32.69 Y15.704 E.08902 +G1 X-33.124 Y15.647 E.01347 +G1 X-33.845 Y15.413 E.02327 +G1 X-34.255 Y15.185 E.01441 +G1 X-34.748 Y14.775 E.01971 +G1 X-35.078 Y14.373 E.01599 +G1 X-35.323 Y14.025 E.01308 +G1 X-35.602 Y13.321 E.02327 +G1 X-35.677 Y12.771 E.01706 +G1 X-35.71 Y12.163 E.01869 +;WIDTH:0.455133 +G1 X-35.728 Y11.985 E.00603 +;WIDTH:0.490267 +G1 X-35.745 Y11.806 E.00655 +;WIDTH:0.525401 +G1 X-35.763 Y11.627 E.00706 +G1 X-35.741 Y11.024 E.02374 +;WIDTH:0.481504 +G1 X-35.725 Y10.712 E.01116 +;WIDTH:0.450752 +G1 X-35.71 Y10.4 E.01038 +;WIDTH:0.419999 +G1 X-35.665 Y9.635 E.02353 +G1 X-35.586 Y9.138 E.01547 +G1 X-35.323 Y8.475 E.02192 +G1 X-35.033 Y8.066 E.0154 +G1 X-34.823 Y7.786 E.01075 +G1 X-34.239 Y7.304 E.02327 +G1 X-33.469 Y6.941 E.02615 +G1 X-33.068 Y6.865 E.01256 +;WIDTH:0.455793 +G1 X-32.942 Y6.824 E.00445 +;WIDTH:0.491587 +G1 X-32.816 Y6.783 E.00484 +;WIDTH:0.527381 +G1 X-32.69 Y6.742 E.00523 +G1 X-29.81 Y6.744 E.1137 +;WIDTH:0.522983 +G1 X-29.55 Y6.81 E.01047 +;WIDTH:0.488655 +G1 X-29.29 Y6.876 E.00972 +;WIDTH:0.454327 +G1 X-29.031 Y6.941 E.00897 +;WIDTH:0.419999 +G1 X-28.261 Y7.304 E.02615 +G1 X-27.677 Y7.786 E.02327 +G1 X-27.2 Y8.434 E.02471 +;WIDTH:0.467448 +G1 X-27.029 Y8.792 E.01372 +;WIDTH:0.514897 +G1 X-26.859 Y9.15 E.01525 +;WIDTH:0.542941 +G1 X-26.835 Y9.183 E.00165 +;WIDTH:0.570985 +G1 X-26.835 Y9.183 E.00002 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-26.739 Y9.979 +G1 X-26.739 Y11.177 +;WIPE_END +G1 X-34.189 Y9.516 Z2.4 F30000 +G1 X-36.005 Y9.111 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;TYPE:Inner wall +;WIDTH:0.481174 +G1 F17113.364 +G1 X-35.965 Y8.988 E.00464 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F17113.364 +G1 X-36.005 Y9.111 +;WIPE_END +G1 X-28.868 Y6.595 Z2.4 F30000 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=5000 +;WIDTH:0.43375 +G1 F18000 +G1 X-28.823 Y6.608 E.0015 +;WIDTH:0.481394 +G1 F17104.777 +G1 X-28.642 Y6.667 E.00679 +;WIDTH:0.529038 +G1 F15428.352 +G1 X-28.461 Y6.726 E.00753 +;WIDTH:0.576682 +G1 F14051.207 +G1 X-28.28 Y6.785 E.00827 +;WIDTH:0.615358 +G1 F13101.856 +G1 X-28.026 Y6.925 E.01354 +;WIDTH:0.654034 +G1 F12272.669 +G1 X-27.773 Y7.066 E.01445 +;WIDTH:0.65529 +G1 F12247.497 +G1 X-27.331 Y7.433 E.02865 +G1 X-26.988 Y7.894 E.02868 +;WIDTH:0.642898 +G1 F12500.459 +G1 X-26.858 Y8.155 E.01428 +;WIDTH:0.593438 +G1 F13623.534 +G1 X-26.728 Y8.417 E.0131 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F13623.534 +G1 X-26.858 Y8.155 +G1 X-26.988 Y7.894 +G1 X-27.331 Y7.433 +G1 X-27.773 Y7.066 +G1 X-28.006 Y6.937 +;WIPE_END +M73 P97 R0 +G1 X-25.19 Y14.03 Z2.4 F30000 +M73 P97 R0 +G1 X-24.103 Y16.767 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +;TYPE:Top surface +;WIDTH:0.429357 +G1 F12000 +G1 X-23.517 Y16.182 E.02609 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.964 Y16.082 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.649 Y16.767 E.03053 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.196 Y16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.371 Y15.943 E.03672 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.743 Y15.768 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-25.742 Y16.767 E.04451 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.289 Y16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-25.091 Y15.57 E.05332 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.387 Y15.32 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.835 Y16.767 E.06448 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-27.382 Y16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-25.683 Y15.069 E.07564 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.925 Y14.764 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-27.928 Y16.767 E.08922 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-28.475 Y16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.156 Y14.449 E.10327 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.347 Y14.094 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-29.021 Y16.767 E.11908 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-29.568 Y16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-28.817 Y16.017 E.03344 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-29.521 Y16.174 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-30.114 Y16.767 E.02641 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-30.661 Y16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-30.1 Y16.207 E.02497 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-30.649 Y16.209 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-31.207 Y16.767 E.02488 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-31.754 Y16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-31.197 Y16.211 E.02478 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-31.746 Y16.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-32.3 Y16.767 E.02469 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-32.838 Y16.759 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-32.295 Y16.215 E.0242 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-32.826 Y16.2 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-33.321 Y16.695 E.02204 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-33.756 Y16.583 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-33.297 Y16.124 E.02044 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-33.711 Y15.992 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-34.154 Y16.435 E.01972 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.523 Y16.258 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-34.113 Y15.848 E.01824 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.468 Y15.656 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-34.867 Y16.055 E.01778 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.164 Y15.806 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-34.777 Y15.418 E.01725 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-35.164 Y15.806 +;WIPE_END +G1 X-28.123 Y12.859 Z2.4 F30000 +G1 X15.466 Y-5.383 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +;WIDTH:0.437078 +G1 F12000 +G1 X16.011 Y-5.928 E.02475 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.722 Y-6.197 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.909 Y-5.383 E.03695 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.351 Y-5.383 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X15.488 Y-6.52 E.05162 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.259 Y-6.848 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X13.794 Y-5.383 E.06653 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X15.208 Y-6.798 +;WIPE_END +G1 X22.648 Y-5.096 Z2.4 F30000 +G1 X36.212 Y-1.993 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +;WIDTH:0.421032 +G1 F12000 +G1 X36.777 Y-2.558 E.02465 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-3.093 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.13 Y-2.446 E.02821 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.007 Y-2.857 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-3.628 E.03357 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-4.163 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.855 Y-3.24 E.04018 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.673 Y-3.593 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-4.697 E.04813 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-5.232 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.448 Y-3.902 E.05794 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.215 Y-4.205 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-5.767 E.06807 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-6.302 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X34.932 Y-4.456 E.08041 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.64 Y-4.699 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-6.836 E.09313 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-7.371 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X34.312 Y-4.906 E.10743 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X35.726 Y-6.32 +;WIPE_END +G1 X35.768 Y-6.896 Z2.4 F30000 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-7.906 E.04398 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-8.44 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.111 Y-7.774 E.02902 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.188 Y-8.386 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-8.975 E.02566 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-9.51 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.223 Y-8.955 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.223 Y-9.49 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-10.045 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-10.579 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.223 Y-10.025 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.223 Y-10.559 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-11.114 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.777 Y-11.649 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.223 Y-11.094 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.223 Y-11.629 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.777 Y-12.184 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.763 Y-12.704 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.223 Y-12.164 E.02355 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.201 Y-12.676 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.713 Y-13.189 E.02232 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.617 Y-13.628 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.143 Y-13.153 E.02067 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.045 Y-13.591 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.488 Y-14.033 E.01929 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X36.32 Y-14.4 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.89 Y-13.97 E.01871 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.728 Y-14.343 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X36.13 Y-14.744 E.01749 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.916 Y-15.065 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.503 Y-14.652 E.01802 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.277 Y-14.961 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.674 Y-15.358 E.01731 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X35.406 Y-15.625 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.003 Y-15.222 E.01757 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.71 Y-15.464 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X35.119 Y-15.872 E.01779 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.809 Y-16.097 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X34.4 Y-15.688 E.01783 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.038 Y-15.861 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X34.466 Y-16.289 E.01863 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X34.099 Y-16.457 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X33.67 Y-16.028 E.01869 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X33.229 Y-16.121 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X33.703 Y-16.596 E.02068 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X33.276 Y-16.703 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X32.774 Y-16.201 E.02188 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X32.254 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X32.801 Y-16.763 E.02383 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X32.271 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X31.719 Y-16.216 E.02403 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X31.184 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X31.736 Y-16.767 E.02405 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X31.201 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X30.649 Y-16.215 E.02407 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X30.114 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X30.667 Y-16.767 E.02409 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X30.132 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X29.579 Y-16.214 E.0241 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X29.043 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +M73 P98 R0 +G1 F12000 +M73 P98 R0 +G1 X29.597 Y-16.767 E.02412 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X29.062 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X28.508 Y-16.213 E.02414 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X27.973 Y-16.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X28.528 Y-16.767 E.02416 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X27.993 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X27.438 Y-16.213 E.02418 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X26.903 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X27.458 Y-16.767 E.0242 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X26.923 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X26.368 Y-16.212 E.02422 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X25.832 Y-16.211 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X26.389 Y-16.767 E.02424 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X25.854 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X25.297 Y-16.211 E.02426 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X24.762 Y-16.21 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X25.319 Y-16.767 E.02428 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X24.785 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X24.227 Y-16.21 E.02429 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X23.692 Y-16.209 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X24.25 Y-16.767 E.02431 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X23.715 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X23.157 Y-16.209 E.02433 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X22.621 Y-16.209 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X23.18 Y-16.767 E.02435 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X22.646 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X22.086 Y-16.208 E.02437 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X21.551 Y-16.208 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X22.111 Y-16.767 E.02439 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X21.576 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X21.016 Y-16.207 E.02441 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X20.481 Y-16.207 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X21.041 Y-16.767 E.02443 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X20.507 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X19.946 Y-16.206 E.02445 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X19.41 Y-16.206 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X19.972 Y-16.767 E.02447 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X19.437 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X18.875 Y-16.206 E.02448 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.34 Y-16.205 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X18.902 Y-16.767 E.0245 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X18.368 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X17.716 Y-16.116 E.02838 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X16.904 Y-15.838 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X17.833 Y-16.767 E.04048 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X17.298 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.767 Y-14.236 E.11032 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.573 Y-14.577 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X16.764 Y-16.767 E.09545 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X16.229 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.338 Y-14.876 E.0824 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.08 Y-15.154 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X15.694 Y-16.767 E.07032 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X15.159 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X13.807 Y-15.415 E.05893 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X13.481 Y-15.624 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X14.625 Y-16.767 E.04984 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X14.09 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X13.152 Y-15.83 E.04085 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X12.766 Y-15.978 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X13.555 Y-16.767 E.0344 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X13.02 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X12.362 Y-16.109 E.02868 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X11.91 Y-16.191 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X12.486 Y-16.767 E.0251 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X11.951 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X11.4 Y-16.217 E.024 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X10.865 Y-16.217 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X11.416 Y-16.767 E.024 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X10.882 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X10.331 Y-16.217 E.024 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X9.796 Y-16.217 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X10.347 Y-16.767 E.02401 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X9.812 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X9.261 Y-16.216 E.02401 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X8.726 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X9.277 Y-16.767 E.02401 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X8.743 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X8.191 Y-16.216 E.02402 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X7.657 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X8.208 Y-16.767 E.02402 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X7.673 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X7.122 Y-16.216 E.02402 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X6.587 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X7.138 Y-16.767 E.02403 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X6.604 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X6.052 Y-16.216 E.02403 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.517 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X6.069 Y-16.767 E.02403 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X5.534 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X4.983 Y-16.216 E.02404 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X4.448 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X4.999 Y-16.767 E.02404 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X4.465 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X3.913 Y-16.216 E.02404 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X3.378 Y-16.216 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X3.93 Y-16.767 E.02405 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X3.395 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X2.843 Y-16.216 E.02405 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X2.309 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X2.861 Y-16.767 E.02405 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X2.326 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X1.774 Y-16.215 E.02406 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.239 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X1.791 Y-16.767 E.02406 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X1.256 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X.704 Y-16.215 E.02406 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X.169 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X.722 Y-16.767 E.02406 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X.187 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-.365 Y-16.215 E.02407 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-.9 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-.348 Y-16.767 E.02407 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-.883 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-1.435 Y-16.215 E.02407 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-1.97 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-1.417 Y-16.767 E.02408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-1.952 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-2.505 Y-16.215 E.02408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-3.039 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-2.487 Y-16.767 E.02408 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-3.022 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-3.574 Y-16.215 E.02409 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.109 Y-16.215 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-3.556 Y-16.767 E.02409 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-4.091 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.644 Y-16.215 E.02409 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.179 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-4.626 Y-16.767 E.0241 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-5.16 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.714 Y-16.214 E.0241 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-6.248 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-5.695 Y-16.767 E.0241 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-6.23 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-6.783 Y-16.214 E.02411 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-7.318 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-6.765 Y-16.767 E.02411 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-7.299 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-7.853 Y-16.214 E.02411 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-8.388 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-7.834 Y-16.767 E.02412 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-8.369 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-8.922 Y-16.214 E.02412 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-9.457 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-8.904 Y-16.767 E.02412 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-9.438 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-9.992 Y-16.214 E.02413 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.527 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-9.973 Y-16.767 E.02413 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-10.508 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-11.062 Y-16.214 E.02413 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-11.596 Y-16.214 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-11.042 Y-16.767 E.02414 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-11.577 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-12.131 Y-16.213 E.02414 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-12.666 Y-16.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-12.112 Y-16.767 E.02414 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-12.647 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-13.201 Y-16.213 E.02415 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-13.736 Y-16.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-13.181 Y-16.767 E.02415 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-13.716 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.27 Y-16.213 E.02415 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.805 Y-16.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-14.251 Y-16.767 E.02416 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-14.786 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.34 Y-16.213 E.02416 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.875 Y-16.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-15.32 Y-16.767 E.02416 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-15.855 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-16.41 Y-16.213 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-16.944 Y-16.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-16.39 Y-16.767 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-16.925 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-17.479 Y-16.213 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P99 R0 +G1 X-18.014 Y-16.213 F30000 +M73 P99 R0 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-17.459 Y-16.767 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-17.994 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-18.549 Y-16.213 E.02418 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-19.084 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-18.529 Y-16.767 E.02418 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-19.063 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-19.618 Y-16.212 E.02418 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-20.153 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-19.598 Y-16.767 E.02419 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-20.133 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-20.688 Y-16.212 E.02419 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-21.223 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-20.668 Y-16.767 E.02419 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-21.202 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-21.758 Y-16.212 E.0242 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-22.292 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-21.737 Y-16.767 E.0242 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-22.272 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-22.827 Y-16.212 E.0242 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.362 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-22.807 Y-16.767 E.02421 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-23.341 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-23.897 Y-16.212 E.02421 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.432 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-23.876 Y-16.767 E.02421 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-24.411 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.967 Y-16.212 E.02422 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.501 Y-16.212 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-24.945 Y-16.767 E.02422 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-25.48 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.036 Y-16.212 E.02422 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.571 Y-16.211 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-26.015 Y-16.767 E.02423 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-26.55 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-27.106 Y-16.211 E.02423 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-27.641 Y-16.211 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-27.084 Y-16.767 E.02423 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-27.619 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-28.175 Y-16.211 E.02424 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-28.71 Y-16.211 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-28.154 Y-16.767 E.02424 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-28.689 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-29.245 Y-16.211 E.02424 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-29.78 Y-16.211 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-29.223 Y-16.767 E.02425 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-29.758 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-30.315 Y-16.211 E.02425 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-30.849 Y-16.211 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-30.293 Y-16.767 E.02425 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-30.828 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-31.384 Y-16.211 E.02426 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-31.919 Y-16.211 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-31.362 Y-16.767 E.02426 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-31.897 Y-16.767 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-32.454 Y-16.211 E.02426 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-33.037 Y-16.162 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-32.432 Y-16.767 E.02638 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-32.995 Y-16.739 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-33.724 Y-16.01 E.03175 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-32.995 Y-16.739 +;WIPE_END +G1 X-33.374 Y-9.116 Z2.4 F30000 +G1 X-34.143 Y6.333 Z2.4 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.846 Y8.036 E.0742 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.012 Y7.668 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-34.473 Y6.129 E.06706 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.8 Y5.921 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.227 Y7.348 E.06218 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.523 Y7.109 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.075 Y5.661 E.06307 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.334 Y5.385 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y6.828 E.06288 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y6.294 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.573 Y5.089 E.05249 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.762 Y4.744 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y5.759 E.04423 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y5.224 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.947 Y4.394 E.03617 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.078 Y3.99 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y4.69 E.03049 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y4.155 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.161 Y3.539 E.02685 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.217 Y3.06 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y3.62 E.02442 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y3.085 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.218 Y2.526 E.02439 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.218 Y1.991 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y2.551 E.02436 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y2.016 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.219 Y1.457 E.02433 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.22 Y.923 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y1.481 E.02431 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y.946 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.22 Y.389 E.02428 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.221 Y-.145 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y.412 E.02425 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-.123 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.221 Y-.679 E.02422 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.222 Y-1.213 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-.658 E.02419 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-1.193 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.211 Y-1.759 E.02467 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.139 Y-2.366 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-1.727 E.02781 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-2.262 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.892 Y-3.147 E.03857 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-36.777 Y-2.262 +;WIPE_END +G1 X-34.206 Y-5.368 Z2.4 F30000 +G1 Z2 +G1 E.8 F2100 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-2.797 E.11205 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-3.331 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-34.533 Y-5.576 E.0978 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.853 Y-5.79 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-3.866 E.08383 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-4.401 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.122 Y-6.056 E.07211 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.381 Y-6.332 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-4.936 E.06083 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-5.47 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.607 Y-6.641 E.05099 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-35.797 Y-6.986 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-6.005 E.04272 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-6.54 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.968 Y-7.349 E.03524 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.104 Y-7.748 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-7.075 E.02935 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-7.609 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.169 Y-8.217 E.02648 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.223 Y-8.699 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-8.144 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-8.679 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.223 Y-9.233 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.223 Y-9.768 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-9.213 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-9.748 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.223 Y-10.303 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.223 Y-10.838 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-10.283 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-10.818 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.223 Y-11.372 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.223 Y-11.907 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-11.352 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.777 Y-11.887 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.223 Y-12.442 E.02417 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.156 Y-13.043 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.777 Y-12.422 E.02706 +SET_VELOCITY_LIMIT ACCEL=10000 +M73 P100 R0 +G1 X-36.745 Y-12.989 F30000 +M73 P100 R0 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-35.98 Y-13.754 E.03336 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-36.611 Y-13.658 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-33.661 Y-16.608 E.12855 +SET_VELOCITY_LIMIT ACCEL=10000 +G1 X-34.573 Y-16.23 F30000 +SET_VELOCITY_LIMIT ACCEL=2000 +G1 F12000 +G1 X-36.25 Y-14.553 E.07306 +G1 E-.8 F2100 +;WIPE_START +G1 F12000 +G1 X-34.836 Y-15.967 +;WIPE_END +M106 S0 +M106 P2 S0 +;TYPE:Custom +; filament end gcode +G1 E-3 F3600 +G0 X50 Y50 F30000 +M104 S0 ; turn off temperature +M73 P100 R0 +; EXECUTABLE_BLOCK_END + diff --git a/tests/fixtures/print-files/creator5-two-tool.3mf b/tests/fixtures/print-files/creator5-two-tool.3mf new file mode 100644 index 0000000..9a0b93f Binary files /dev/null and b/tests/fixtures/print-files/creator5-two-tool.3mf differ diff --git a/tests/fixtures/print-files/make-tool-change-fixture.py b/tests/fixtures/print-files/make-tool-change-fixture.py new file mode 100644 index 0000000..80a7c9c --- /dev/null +++ b/tests/fixtures/print-files/make-tool-change-fixture.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +"""Create a two-tool 3MF fixture whose gcode really changes tools. + +Starts from the single-tool AD5X fixture (a real sliced Benchy) and: + +- inserts a `T2` tool change at the first line after the byte midpoint of + Metadata/plate_1.gcode, so tool 0 prints the first half of the file and + tool 2 prints the second half; +- rewrites Metadata/slice_info.config with two filaments, ids 1 and 3. The + ids are not consecutive on purpose: the plate uses slicer filaments 1 and 3, + which print with T0 and T2, so clients must derive the tool from the id and + not from the list position; +- uses PLA for filament 1 and PETG for filament 3, matching the emulator's + default station (slot 1 PLA, slot 2 PETG). + +It also writes two-tool-toolchange.expected.json with per-tool estimates and +the grams used at 40% and 75% of the gcode bytes. The values are computed +here, independently of the app's own parser, so e2e tests can check the +app's per-tool math against them. +""" +import json +import re +import zipfile + +SRC = 'tests/fixtures/print-files/ad5x-single-tool.3mf' +DST = 'tests/fixtures/print-files/two-tool-toolchange.3mf' +EXPECTED = 'tests/fixtures/print-files/two-tool-toolchange.expected.json' + +GRAMS_PER_METRE = 2.98 # PLA/PETG at 1.75 mm; only the ratio matters here. +FILAMENTS = [ + {'id': 1, 'tool': 0, 'type': 'PLA', 'color': '#4DA3FF'}, + {'id': 3, 'tool': 2, 'type': 'PETG', 'color': '#FF8A3D'}, +] +CHECKPOINTS = [40, 75] + +with zipfile.ZipFile(SRC, 'r') as zin: + entries = {info.filename: zin.read(info.filename) for info in zin.infolist()} + +gcode = entries['Metadata/plate_1.gcode'] +midpoint = len(gcode) // 2 +insert_at = gcode.index(b'\n', midpoint) + 1 +gcode = gcode[:insert_at] + b'T2\n' + gcode[insert_at:] +entries['Metadata/plate_1.gcode'] = gcode + +# Independent per-tool extrusion sum (relative extrusion, as the file uses M83). +move = re.compile(rb'^G[0-3]\b') +e_param = re.compile(rb'(?:^|\s)E(-?\d*\.?\d+)', re.I) +tool_change = re.compile(rb'^T(\d+)\s*$') +totals = {0: 0.0, 2: 0.0} +checkpoints = {percent: None for percent in CHECKPOINTS} +tool = 0 +absolute = False +last_e = 0.0 +position = 0 +for raw in gcode.split(b'\n'): + line = raw.split(b';', 1)[0].strip() + position += len(raw) + 1 + change = tool_change.match(line) + if change: + tool = int(change.group(1)) + elif line.startswith(b'M82'): + absolute = True + elif line.startswith(b'M83'): + absolute = False + elif line.startswith(b'G92'): + found = e_param.search(line) + if found: + last_e = float(found.group(1)) + elif move.match(line): + found = e_param.search(line) + if found: + value = float(found.group(1)) + delta = value - last_e if absolute else value + if absolute: + last_e = value + totals[tool] = max(0.0, totals[tool] + delta) + for percent in CHECKPOINTS: + if checkpoints[percent] is None and position >= len(gcode) * percent / 100: + checkpoints[percent] = dict(totals) + +filament_lines = '' +estimates = {} +for filament in FILAMENTS: + used_m = round(totals[filament['tool']] / 1000, 2) + used_g = round(used_m * GRAMS_PER_METRE, 2) + estimates[filament['tool']] = {'usedM': used_m, 'usedG': used_g} + filament_lines += ( + f' \n' + ) + +slice_info = entries['Metadata/slice_info.config'].decode('utf-8') +slice_info = re.sub(r' 0 else 0.0 + entry[f'gramsAt{percent}'] = round(estimates[tool_id]['usedG'] * fraction, 3) + expected['tools'][str(tool_id)] = entry + +with open(EXPECTED, 'w', encoding='utf-8') as handle: + json.dump(expected, handle, indent=2) + handle.write('\n') + +print(f'wrote {DST}') +print(json.dumps(expected, indent=2)) diff --git a/tests/fixtures/print-files/make-two-tool-fixture.py b/tests/fixtures/print-files/make-two-tool-fixture.py new file mode 100644 index 0000000..8a06b39 --- /dev/null +++ b/tests/fixtures/print-files/make-two-tool-fixture.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +"""Create a two-tool 3mf fixture from the existing single-tool fixture. + +Rewrites Metadata/slice_info.config with two entries so +slicer-meta's ThreeMfParser reports per-filament usedG/usedM for tools 0 and +1. Also updates plate_1.json and the gcode usage comments for consistency. +""" +import json +import shutil +import zipfile + +SRC = 'tests/fixtures/print-files/ad5x-single-tool.3mf' +DST = 'tests/fixtures/print-files/creator5-two-tool.3mf' + +TOOL_G = ['11.28', '8.64'] +TOOL_M = ['3.78', '2.88'] + +with zipfile.ZipFile(SRC, 'r') as zin: + entries = {name: zin.read(name) for name in zin.namelist()} + +slice_info = entries['Metadata/slice_info.config'].decode('utf-8') +filament_line = ( + ' \n' +) +two_filaments = ( + ' \n' + ' \n' +) +assert filament_line in slice_info, 'filament line not found in slice_info.config' +slice_info = slice_info.replace(filament_line, two_filaments) +entries['Metadata/slice_info.config'] = slice_info.encode('utf-8') + +plate = json.loads(entries['Metadata/plate_1.json'].decode('utf-8')) +plate['filament_colors'] = ['#808000', '#FF0000'] +plate['filament_ids'] = [0, 1] +entries['Metadata/plate_1.json'] = json.dumps(plate).encode('utf-8') + +gcode = entries['Metadata/plate_1.gcode'].decode('utf-8') +gcode = gcode.replace('; filament_density: 1.24,1.24', '; filament_density: 1.24,1.24') +gcode = gcode.replace('; filament used [mm] = 3783.30, 0.00', '; filament used [mm] = 3783.30, 2880.00') +gcode = gcode.replace('; filament used [cm3] = 9.10, 0.00', '; filament used [cm3] = 9.10, 6.91') +gcode = gcode.replace('; filament used [g] = 11.28, 0.00', '; filament used [g] = 11.28, 8.64') +gcode = gcode.replace('; total filament used [g] = 11.28', '; total filament used [g] = 19.92') +entries['Metadata/plate_1.gcode'] = gcode.encode('utf-8') + +with zipfile.ZipFile(DST, 'w', zipfile.ZIP_DEFLATED) as zout: + for name, data in entries.items(): + zout.writestr(name, data) + +print(f'wrote {DST}') diff --git a/tests/fixtures/print-files/two-tool-toolchange.3mf b/tests/fixtures/print-files/two-tool-toolchange.3mf new file mode 100644 index 0000000..4001ed9 Binary files /dev/null and b/tests/fixtures/print-files/two-tool-toolchange.3mf differ diff --git a/tests/fixtures/print-files/two-tool-toolchange.expected.json b/tests/fixtures/print-files/two-tool-toolchange.expected.json new file mode 100644 index 0000000..a3227e2 --- /dev/null +++ b/tests/fixtures/print-files/two-tool-toolchange.expected.json @@ -0,0 +1,20 @@ +{ + "tools": { + "0": { + "filamentId": 1, + "material": "PLA", + "usedM": 2.33, + "usedG": 6.94, + "gramsAt40": 5.869, + "gramsAt75": 6.94 + }, + "2": { + "filamentId": 3, + "material": "PETG", + "usedM": 1.47, + "usedG": 4.38, + "gramsAt40": 0.0, + "gramsAt75": 1.933 + } + } +} diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json new file mode 100644 index 0000000..ba74380 --- /dev/null +++ b/tsconfig.e2e.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["tests/**/*.ts", "playwright.config.ts"] +}