From c67afad93be043a3b381a63c16acc1a42a9319e0 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 1 Nov 2026 22:49:11 -0600 Subject: [PATCH 1/2] Add coordinate frames and calibrated tile units --- README.md | 63 +++++ docs/ftc-cad-measurements.json | 18 ++ e2e/coordinates.spec.ts | 177 +++++++++++++ e2e/editing.spec.ts | 10 +- e2e/obstacle-calibration.spec.ts | 136 ++++++++++ e2e/tile-calibration.spec.ts | 62 +++++ src/App.svelte | 4 + src/config/defaults.ts | 108 +++++++- src/io/migrations.ts | 11 +- src/io/project.ts | 24 +- src/lib/FileManager.svelte | 6 +- src/lib/codegen/points.ts | 3 + src/lib/components/CoordinateField.svelte | 52 ++++ src/lib/components/ExportCodeDialog.svelte | 14 +- src/lib/components/FieldMapImage.svelte | 16 +- src/lib/components/HeadingControls.svelte | 16 +- .../components/PiecewiseHeadingEditor.svelte | 83 +++--- src/lib/components/SettingsDialog.svelte | 70 +++++- .../components/StartingPointSection.svelte | 35 +-- src/lib/components/mathtools/FieldGrid.svelte | 46 ++-- .../components/mathtools/RulerOverlay.svelte | 5 +- src/lib/export/gifExport.ts | 21 +- src/model/commands.ts | 15 +- src/model/tileCalibration.ts | 70 ++++++ src/state/files.svelte.ts | 20 +- src/state/gifExport.svelte.ts | 1 + src/state/prefs.svelte.ts | 6 + src/state/project.svelte.ts | 60 ++++- src/stores.ts | 3 + src/types.ts | 6 +- src/utils/coordinates.ts | 147 +++++++++++ src/utils/settingsPersistence.ts | 5 +- src/view/FieldPanel.svelte | 13 +- src/view/FieldViewToggles.svelte | 16 +- src/view/LayersPanel.svelte | 22 +- src/view/PlaybackBar.svelte | 7 +- src/view/inspector/ObstacleInspector.svelte | 67 ++--- src/view/inspector/PathInspector.svelte | 29 +-- src/view/snap.ts | 10 +- test/coordinate-persistence.test.ts | 120 +++++++++ test/coordinates.test.ts | 238 ++++++++++++++++++ test/field-calibration.test.ts | 149 +++++++++++ test/support/fixtures.ts | 7 +- test/tile-calibration.test.ts | 150 +++++++++++ 44 files changed, 1936 insertions(+), 205 deletions(-) create mode 100644 docs/ftc-cad-measurements.json create mode 100644 e2e/coordinates.spec.ts create mode 100644 e2e/obstacle-calibration.spec.ts create mode 100644 e2e/tile-calibration.spec.ts create mode 100644 src/lib/components/CoordinateField.svelte create mode 100644 src/model/tileCalibration.ts create mode 100644 src/utils/coordinates.ts create mode 100644 test/coordinate-persistence.test.ts create mode 100644 test/coordinates.test.ts create mode 100644 test/field-calibration.test.ts create mode 100644 test/tile-calibration.test.ts diff --git a/README.md b/README.md index 1b1e4d2a..a1d37ce0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,66 @@ # Pedro Pathing Visualizer c3: art; ans to 1 + +## Plan in FTC Coordinates + +Open **Settings → Coordinates and Units**. Select Pedro, FTC, or FTC Inverted, then Inches or Tiles. The field image remains fixed: + +| Frame | Origin | Positive X | Positive Y | Zero Heading | +| ------------ | ----------- | ---------- | ---------- | ------------ | +| Pedro | Bottom-left | Right | Up | Right | +| FTC | Center | Down | Right | Down | +| FTC Inverted | Center | Up | Left | Up | + +Headings are counterclockwise-positive degrees. Choose the frame appropriate to your field orientation; the visualizer does not infer alliance or season orientation. + +**Inches per Tile defaults to 23.5 and is editable.** The STEP assembly linked by [FIRST’s field-resources page](https://ftc-resources.firstinspires.org/ftc/field) models an interior tile repeat spacing of approximately 23.502 inches. FIRST’s nominal 24-inch description is not an exact assembled seam pitch. Measure your assembled field and adjust the calibration if needed; previously saved values are preserved. + +The calibration controls the physical scale of the field image, grid, snapping, and tile coordinates together. In a centered FTC frame, `(2, 2)` at 23.5 inches per tile is 47 inches along each axis and lands on the matching visible seam intersection. Grid presets subdivide a tile, so switching between inches and tiles changes labels rather than the physical grid or image scale. Images must contain the full six-by-six tile layout, including custom images. + +The inherited 141.5-inch field boundary stays fixed for project compatibility, centered at 70.75 inches. The six-tile image is centered inside it and clipped when larger; when smaller, a narrow margin surrounds the image. This is a compatibility model, not an exact reconstruction of every physical perimeter. With Inches selected, calibration preserves paths, field points, and custom obstacle geometry in physical inches. With Tiles selected, changing inches per tile preserves their tile coordinates in the selected frame, including control points, through points, and facing-point targets. Untouched built-in outlines follow the same centered image scale, and collision detection uses those exact displayed polygons. + +Point editors, heading editors, robot position, grid/snapping, ruler, and path length follow the selected frame or units as applicable. Robot dimensions and motion-parameter settings remain in inches. Switching frame or units preserves the physical path. Changing tile size while Tiles is selected preserves tile coordinates and updates the underlying inch positions; with Inches selected, physical positions stay fixed. Robot dimensions and motion settings are never rescaled. Saved `.pp.json` projects retain these preferences alongside canonical Pedro-inch geometry. + +## Why the default is 23.5 inches + +The default is a practical rounding of a **measured CAD tile repeat of 23.502083 inches**, not the result of dividing the field boundary by six. The difference is about 0.0021 inches per tile. The inspected BIOBUZZ STEP assembly also measures approximately 141.170 inches between the lower-rail inner extents, 141.348 inches between the inner wall-panel faces, and 24.3125 inches across an uncut tile including its interlocking tabs. These are different measurements; a nominal tile width or tab-tip width is not its assembled repeat spacing. + +Primary references: + +- [FIRST Playing Field Resources](https://ftc-resources.firstinspires.org/ftc/field): source index for the BIOBUZZ CAD and setup documentation. +- [Official STEP assembly download (ZIP)](https://ftc-resources.firstinspires.org/ftc/field/field-cad-step) and [official Onshape assembly](https://cad.onshape.com/documents/a355e772e3d24813de7852ee/w/f106353168f1f92100b81259/e/95d1e1e442b4138cccaf2d73): the model inspected for these measurements. +- [FTC Competition Manual, §9.1–9.2](https://ftc-resources.firstinspires.org/ftc/archive/2027/game/manual-09): identifies CAD as the official geometry reference and describes the field and tiles as approximately 144 inches and nominally 24 inches, respectively. +- [Event Field Setup Guide, §7.2–7.4](https://ftc-resources.firstinspires.org/ftc/field/eventfieldguide): explains tile-size variation and measuring before trimming the outside tiles. + +The [measurement record](docs/ftc-cad-measurements.json) includes the downloaded archive’s SHA-256, STEP header timestamp, units, and measurement method. It describes one CAD assembly, not a guarantee for every tile batch or assembled field. The editable calibration is intentional. The 141.5-inch software boundary remains a compatibility convention, not a claim of exact CAD dimensions. + +## Obstacles and collision alignment + +BIOBUZZ and DECODE built-in outlines use the same centered image calibration as the field art. They are plan-view approximations traced against the field images, not complete 3D CAD collision meshes. BIOBUZZ HIVE outlines intentionally cover the fixed outside bars rather than the elevated tipping cells or the open floor beneath them. + +New/reset built-ins follow changes to Inches per Tile. Unmodified legacy default polygons are recognized and upgraded when loading; custom or edited polygons keep their physical coordinates when loading and follow the selected-unit calibration behavior described above when changing tile size. Editing a built-in vertex detaches that outline from automatic calibration. Names and colors may be changed without detaching it. Project saves and session recovery retain this distinction. Opening a project with linked built-ins also selects their matching field image. + +The drawn outline and collision engine share one calibrated polygon list; the path optimizer receives it too. Robot dimensions remain in physical inches, and Safety margin expands the robot footprint for collision checks rather than changing the obstacle outline. Wall checks continue to use the fixed 141.5-inch boundary. Calibrating the image does not make these approximations a substitute for checking robot clearance against the actual field. + +## Export Safely + +The points-array export uses the selected coordinates and distance units. Java and Kotlin robot-code exports intentionally remain in **Pedro coordinates and inches**, so exports use the recalibrated inch positions after a tile-size change in Tiles mode. Do not treat a tiles/FTC points array as Pedro-inch coordinates without conversion. + +The centered frames use half this visualizer's 141.5-inch field, not the 72-inch offset used by some Pedro FTC-coordinate conversions. If using those SDK conversion helpers directly, reconcile the field-center convention rather than copying displayed coordinates without checking it. Test start pose, axes, heading, and a short path on the physical robot before autonomous use. + +## Develop and Verify + +Use a current Node.js release compatible with Vite (validated with Node 24) and pnpm (the pinned version is recorded in `package.json`). + +```sh +pnpm install --frozen-lockfile +pnpm test +pnpm check +pnpm lint +pnpm build +pnpm test:e2e +pnpm dev +``` + +Regression tests cover all frame/unit round trips, custom tile sizes, heading conversion, point editing, centered snapping, export, and project persistence. A useful manual check is an FTC path from `(0, 0)` to `(1, 0)` in tiles: it should extend downward by 23.5 inches with the default tile size. Switching to Pedro/inches should show `(70.75, 70.75)` to `(70.75, 47.25)` without moving that path. diff --git a/docs/ftc-cad-measurements.json b/docs/ftc-cad-measurements.json new file mode 100644 index 00000000..a89263d5 --- /dev/null +++ b/docs/ftc-cad-measurements.json @@ -0,0 +1,18 @@ +{ + "source": "https://ftc-resources.firstinspires.org/ftc/field/field-cad-step", + "download_sha256": "5e768b731f1ec8dcd14debba53225c43718877923c351ce08504305f68f7fe00", + "source_filename": "field-cad-step.step", + "source_header_timestamp": "2026-09-09T15:05:36Z", + "method": "STEP assembly imported with Open CASCADE; placed component bounding planes measured in millimetres, converted at 25.4 mm/in. Floor axes are X and Z; Y is height. Bounds include the CAD kernel tolerance, negligible at reported 0.001-inch precision.", + "measurements_inches": { + "interior_tile_repeat_pitch": 23.502083, + "uncut_tile_tip_to_tip": 24.3125, + "between_lower_rail_innermost_extents_X": 141.17, + "between_lower_rail_innermost_extents_Z": 141.17, + "between_glass_inner_faces_X": 141.348, + "between_glass_inner_faces_Z": 141.348, + "tile_floor_overall_X": 141.169596, + "tile_floor_overall_Z": 141.169596 + }, + "limitations": "These describe this specific downloadable BIOBUZZ CAD assembly, not guaranteed measurements for every physical field. Tile batches, assembly, and perimeter design vary." +} diff --git a/e2e/coordinates.spec.ts b/e2e/coordinates.spec.ts new file mode 100644 index 00000000..4b2c9264 --- /dev/null +++ b/e2e/coordinates.spec.ts @@ -0,0 +1,177 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function openFTC(page: Page) { + await page.addInitScript(() => { + if (localStorage.getItem("coordinate-test-seeded")) return; + localStorage.setItem("coordinate-test-seeded", "true"); + localStorage.setItem( + "pedro_session_recovery_v1", + JSON.stringify({ + version: "1.5.0", + startPoint: { x: 70.75, y: 70.75, headingDeg: -90 }, + lines: [ + { + kind: "atomic", + id: "ftc-path", + color: "#4e79a7", + endPoint: { x: 70.75, y: 47.25 }, + controlPoints: [], + heading: { type: "constant", degrees: -90 }, + }, + ], + fieldPoints: [{ x: 30, y: 40 }], + shapes: [], + settings: { + coordinateSystem: "ftc", + distanceUnit: "tiles", + tileSize: 23.5, + }, + }), + ); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByLabel("End X", { exact: true })).toHaveValue("1"); +} + +async function snapshot(page: Page) { + return page.evaluate(() => + JSON.parse(localStorage.getItem("pedro_session_recovery_v1") ?? "{}"), + ); +} + +test("FTC edits keep canonical geometry and survive recovery", async ({ + page, +}) => { + await openFTC(page); + await page.getByLabel("End X", { exact: true }).fill("2"); + await expect(page.getByLabel("End Y", { exact: true })).toHaveValue("0"); + await expect + .poll(async () => (await snapshot(page)).lines?.[0].endPoint) + .toEqual({ x: 70.75, y: 23.75 }); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByLabel("End X", { exact: true })).toHaveValue("2"); + expect((await snapshot(page)).fieldPoints).toEqual([{ x: 30, y: 40 }]); +}); + +test("switching frames changes numbers without moving the path", async ({ + page, +}) => { + await openFTC(page); + const marker = page.locator('[aria-label="Path 1 end point"]'); + const before = await marker.boundingBox(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Coordinates and Units", exact: true }), + ).toHaveAttribute("aria-expanded", "true"); + await expect( + page.getByRole("button", { name: "Display", exact: true }), + ).toHaveAttribute("aria-expanded", "false"); + await page.getByLabel("Coordinate frame").selectOption("pedro"); + await page.getByLabel("Distance units").selectOption("inches"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByLabel("End X", { exact: true })).toHaveValue("70.75"); + await expect(page.getByLabel("End Y", { exact: true })).toHaveValue("47.25"); + expect(await marker.boundingBox()).toEqual(before); +}); + +test("project export retains coordinate preferences", async ({ page }) => { + await openFTC(page); + const downloaded = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export", exact: true }).click(); + await page + .getByRole("menuitem", { name: "Export project as PP JSON" }) + .click(); + const download = await downloaded; + const stream = (await download.createReadStream())!; + let text = ""; + for await (const chunk of stream) text += chunk.toString(); + const doc = JSON.parse(text); + expect(doc.coordinates).toEqual({ + coordinateSystem: "ftc", + distanceUnit: "tiles", + tileSize: 23.5, + }); + expect(doc.lines[0].endPoint).toEqual({ x: 70.75, y: 47.25 }); +}); + +test("FTC two-by-two tiles lands on the image seams and grid after calibration", async ({ + page, +}) => { + await openFTC(page); + const marker = page.locator('[aria-label="Path 1 end point"]'); + const field = page.getByRole("application"); + const image = page.getByAltText("Field", { exact: true }); + await expect + .poll(() => + image.evaluate((element) => (element as HTMLImageElement).naturalWidth), + ) + .toBeGreaterThan(0); + await page.getByLabel("End X", { exact: true }).fill("2"); + await page.getByLabel("End Y", { exact: true }).fill("2"); + // Largest preset is half a tile; every full tile seam must be included. + const checkAlignment = async () => { + const imageBox = (await image.boundingBox())!; + const markerBox = (await marker.boundingBox())!; + const fieldBox = (await field.boundingBox())!; + const target = { + x: imageBox.x + (imageBox.width * 5) / 6, + y: imageBox.y + (imageBox.height * 5) / 6, + }; + expect(Math.abs(markerBox.x + markerBox.width / 2 - target.x)).toBeLessThan( + 0.1, + ); + expect( + Math.abs(markerBox.y + markerBox.height / 2 - target.y), + ).toBeLessThan(0.1); + const lines = await field.locator("svg.z-20 line").evaluateAll((nodes) => + nodes.map((node) => ({ + x1: Number(node.getAttribute("x1")), + y1: Number(node.getAttribute("y1")), + x2: Number(node.getAttribute("x2")), + y2: Number(node.getAttribute("y2")), + })), + ); + expect( + lines.some( + (line) => + line.x1 === line.x2 && + Math.abs(line.x1 + fieldBox.x - target.x) < 0.1, + ), + ).toBe(true); + expect( + lines.some( + (line) => + line.y1 === line.y2 && + Math.abs(line.y1 + fieldBox.y - target.y) < 0.1, + ), + ).toBe(true); + }; + // Move focus out of the numeric editor so G activates the grid shortcut. + await page.getByRole("heading", { name: "Path", exact: true }).click(); + for (let i = 0; i < 4; i++) await page.keyboard.press("g"); + await checkAlignment(); + await expect + .poll(async () => (await snapshot(page)).lines?.[0].endPoint) + .toEqual({ x: 117.75, y: 23.75 }); + const beforeUnits = await image.boundingBox(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByLabel("Distance units").selectOption("inches"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + expect(await image.boundingBox()).toEqual(beforeUnits); + await checkAlignment(); + // Recalibration in Tiles mode preserves the point on the same image seam. + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByLabel("Distance units").selectOption("tiles"); + await page.getByLabel("Inches per tile", { exact: true }).fill("24"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect + .poll(async () => (await snapshot(page)).coordinates?.tileSize) + .toBe(24); + expect((await snapshot(page)).lines[0].endPoint).toEqual({ + x: 118.75, + y: 22.75, + }); + await expect(page.getByLabel("End X", { exact: true })).toHaveValue("2"); + await expect(page.getByLabel("End Y", { exact: true })).toHaveValue("2"); + await checkAlignment(); +}); diff --git a/e2e/editing.spec.ts b/e2e/editing.spec.ts index 1c77e5d8..a31e97b8 100644 --- a/e2e/editing.spec.ts +++ b/e2e/editing.spec.ts @@ -1,3 +1,4 @@ +import { PROJECT_EXTENSION } from "../src/utils/filename"; import { expect, test, type Page } from "@playwright/test"; /** The default project: one path ending at (56, 36), drawn as `point-1-0`. */ @@ -6,7 +7,7 @@ const ENDPOINT = "#point-1-0"; async function openApp(page: Page) { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); - await page.goto("/"); + await page.goto("/", { waitUntil: "domcontentloaded" }); await expect(page.locator(ENDPOINT)).toBeVisible(); return errors; } @@ -80,7 +81,7 @@ test("adding a path is undoable", async ({ page }) => { await openApp(page); expect(await pathCount(page)).toBe(1); - await page.getByRole("button", { name: "Add path" }).click(); + await page.getByRole("button", { name: "Add path", exact: true }).click(); await expect.poll(() => pathCount(page)).toBe(2); await undo(page); @@ -139,7 +140,10 @@ test("duplicate mirrored opens a mirrored copy", async ({ page }) => { await page.getByRole("menuitem", { name: "Duplicate mirrored…" }).click(); await page.getByRole("button", { name: "Create" }).click(); await expect( - page.getByText("path_mirrored.pp", { exact: true }), + page.getByRole("button", { + name: `path_mirrored${PROJECT_EXTENSION}`, + exact: true, + }), ).toBeVisible(); // The field is 141.5" wide, so x = 56 lands on 85.5. await page.locator(ENDPOINT).click(); diff --git a/e2e/obstacle-calibration.spec.ts b/e2e/obstacle-calibration.spec.ts new file mode 100644 index 00000000..0a51c128 --- /dev/null +++ b/e2e/obstacle-calibration.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from "@playwright/test"; + +// Old untagged built-ins must upgrade, while the custom square stays physical. +test("calibrated obstacle outlines and collision contact share the image geometry", async ({ + page, +}) => { + await page.addInitScript(() => { + if (localStorage.getItem("obstacle-calibration-seeded")) return; + localStorage.setItem("obstacle-calibration-seeded", "true"); + localStorage.setItem( + "pedro_session_recovery_v1", + JSON.stringify({ + version: "2.1.0", + coordinates: { + coordinateSystem: "pedro", + distanceUnit: "inches", + tileSize: 23.5, + }, + settings: { + tileSize: 23.5, + fieldMap: "decode.webp", + experimentalFeatures: { obstacles: true }, + }, + robot: { rLength: 1, rWidth: 1, safetyMargin: 0 }, + startPoint: { x: 44, y: 70.75, headingDeg: 0 }, + lines: [ + { + kind: "atomic", + id: "cross", + color: "red", + endPoint: { x: 52, y: 70.75 }, + controlPoints: [], + heading: { type: "constant", degrees: 0 }, + }, + ], + shapes: [ + { + id: "biobuzz-red-hive", + name: "Red HIVE", + color: "red", + fillColor: "red", + vertices: [ + { x: 46, y: 50 }, + { x: 48.6, y: 50 }, + { x: 48.6, y: 91.4 }, + { x: 46, y: 91.4 }, + ], + }, + { + id: "custom", + name: "Custom", + color: "blue", + fillColor: "blue", + vertices: [ + { x: 5, y: 5 }, + { x: 6, y: 5 }, + { x: 6, y: 6 }, + { x: 5, y: 6 }, + ], + }, + ], + }), + ); + }); + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#shape-0")).toBeVisible(); + await expect(page.getByAltText("Field", { exact: true })).toHaveAttribute( + "src", + /biobuzz-(dark|light)\.webp$/, + ); + await expect( + page.getByRole("button", { name: "Hits Red HIVE", exact: true }), + ).toBeVisible(); + const verify = async () => { + const image = (await page + .getByAltText("Field", { exact: true }) + .boundingBox())!; + const vertex = await page.locator("#shape-0").evaluate((element) => { + const path = element as SVGPathElement; + const point = path + .getPointAtLength(0) + .matrixTransform(path.getScreenCTM()!); + return { x: point.x, y: point.y }; + }); + // The source outline's (46,50) vertex was traced on a 144-inch image + // inset by -1.25 inches. This normalized location must stay fixed. + expect( + Math.abs(vertex.x - image.x - (image.width * 47.25) / 144), + ).toBeLessThan(0.1); + expect( + Math.abs(vertex.y - image.y - (image.height * 92.75) / 144), + ).toBeLessThan(0.1); + const left = Number( + (await page.locator("#shape-0").getAttribute("d"))!.match(/[-\d.]+/)![0], + ); + const footprint = (await page + .getByTestId("collision-footprint") + .first() + .getAttribute("d"))! + .match(/-?\d+(?:\.\d+)?/g)! + .map(Number); + expect(Math.max(...footprint.filter((_, i) => i % 2 === 0))).toBeCloseTo( + left, + 3, + ); + }; + await verify(); + const beforeCustom = await page.locator("#shape-1").getAttribute("d"); + const beforePath = await page + .getByLabel("End X", { exact: true }) + .inputValue(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByLabel("Inches per tile", { exact: true }).fill("24"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.locator("#shape-0")).toHaveAttribute("d", /^M 46 /); + await verify(); + expect(await page.locator("#shape-1").getAttribute("d")).toBe(beforeCustom); + expect(await page.getByLabel("End X", { exact: true }).inputValue()).toBe( + beforePath, + ); + await expect + .poll(() => + page.evaluate( + () => + JSON.parse(localStorage.getItem("pedro_session_recovery_v1") ?? "{}") + .shapes?.[0].fieldCalibration?.tileSize, + ), + ) + .toBe(24); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.locator("#shape-0")).toHaveAttribute("d", /^M 46 /); + await verify(); + expect(errors).toEqual([]); +}); diff --git a/e2e/tile-calibration.spec.ts b/e2e/tile-calibration.spec.ts new file mode 100644 index 00000000..02925d23 --- /dev/null +++ b/e2e/tile-calibration.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; + +test("tile-size settings preserve tile positions and persist recalibrated inches", async ({ + page, +}) => { + await page.addInitScript(() => { + if (localStorage.getItem("tile-resize-seeded")) return; + localStorage.setItem("tile-resize-seeded", "true"); + localStorage.setItem( + "pedro_session_recovery_v1", + JSON.stringify({ + version: "2.2.0", + coordinates: { + coordinateSystem: "ftc", + distanceUnit: "tiles", + tileSize: 23.5, + }, + settings: { + coordinateSystem: "ftc", + distanceUnit: "tiles", + tileSize: 23.5, + }, + startPoint: { x: 70.75, y: 70.75, headingDeg: 0 }, + lines: [ + { + id: "path", + kind: "atomic", + color: "red", + endPoint: { x: 117.75, y: 23.75 }, + controlPoints: [], + heading: { type: "constant", degrees: 0 }, + }, + ], + shapes: [], + }), + ); + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByLabel("End X", { exact: true })).toHaveValue("2"); + await expect(page.getByLabel("End Y", { exact: true })).toHaveValue("2"); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByLabel("Inches per tile", { exact: true }).fill("24"); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await expect(page.getByLabel("End X", { exact: true })).toHaveValue("2"); + await expect(page.getByLabel("End Y", { exact: true })).toHaveValue("2"); + await expect + .poll(() => + page.evaluate(() => { + const saved = JSON.parse( + localStorage.getItem("pedro_session_recovery_v1") ?? "{}", + ); + return { + tileSize: saved.coordinates?.tileSize, + point: saved.lines?.[0].endPoint, + }; + }), + ) + .toEqual({ tileSize: 24, point: { x: 118.75, y: 22.75 } }); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByLabel("End X", { exact: true })).toHaveValue("2"); + await expect(page.getByLabel("End Y", { exact: true })).toHaveValue("2"); +}); diff --git a/src/App.svelte b/src/App.svelte index 0026e1e3..3233747e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,6 @@ + + { + if (event.currentTarget.value === "") return; + const next = editCoordinate( + point, + axis, + event.currentTarget.valueAsNumber, + $coordinateSettings, + ); + if (onpoint) onpoint(next); + else Object.assign(point, next); + }} +/> diff --git a/src/lib/components/ExportCodeDialog.svelte b/src/lib/components/ExportCodeDialog.svelte index 45c635c9..d994d9a8 100644 --- a/src/lib/components/ExportCodeDialog.svelte +++ b/src/lib/components/ExportCodeDialog.svelte @@ -5,6 +5,8 @@ import IconButton from "./ui/IconButton.svelte"; import Button from "./ui/Button.svelte"; import { checkboxClass, fieldClass, labelClass } from "./ui/styles"; + import { coordinateSettings } from "../../stores"; + import { unitLabel } from "../../utils/coordinates"; import { currentFilePath } from "../../stores"; import { generateJavaCode, @@ -62,7 +64,7 @@ case "kotlin": return generateKotlinCode(startPoint, lines, mode, mirror); case "points": - return generatePointsArray(startPoint, lines); + return generatePointsArray(startPoint, lines, $coordinateSettings); case "sequential": return generateSequentialCommandCode( startPoint, @@ -151,6 +153,16 @@ > +

+ {#if format === "points"} + Points use {$coordinateSettings.coordinateSystem ?? "pedro"} coordinates in + {unitLabel($coordinateSettings)}. + {:else} + Robot code uses Pedro coordinates and inches, regardless of display + settings. + {/if} +

+
diff --git a/src/lib/components/FieldMapImage.svelte b/src/lib/components/FieldMapImage.svelte index 347e401a..d9a2ee1b 100644 --- a/src/lib/components/FieldMapImage.svelte +++ b/src/lib/components/FieldMapImage.svelte @@ -1,18 +1,24 @@ + import { coordinateSettings } from "../../stores"; + import { displayHeading, storedHeading } from "../../utils/coordinates"; import PiecewiseHeadingEditor from "./PiecewiseHeadingEditor.svelte"; import Toggle from "./ui/Toggle.svelte"; import { fieldClass, labelClass } from "./ui/styles"; @@ -78,7 +80,7 @@ function degreesFrom(input: HTMLInputElement): number { const value = parseFloat(input.value); - return Number.isNaN(value) ? 0 : value; + return storedHeading(Number.isNaN(value) ? 0 : value, $coordinateSettings); } const degreeFieldClass = [fieldClass, "font-mono"]; @@ -117,9 +119,7 @@ class={degreeFieldClass} type="number" step="1" - min="-180" - max="180" - value={heading.startDeg} + value={displayHeading(heading.startDeg, $coordinateSettings)} oninput={(e) => update({ startDeg: degreesFrom(e.currentTarget) })} title="The heading the robot starts this path at" disabled={locked} @@ -131,9 +131,7 @@ class={degreeFieldClass} type="number" step="1" - min="-180" - max="180" - value={heading.endDeg} + value={displayHeading(heading.endDeg, $coordinateSettings)} oninput={(e) => update({ endDeg: degreesFrom(e.currentTarget) })} title="The heading the robot ends this path at" disabled={locked} @@ -147,9 +145,7 @@ class={degreeFieldClass} type="number" step="1" - min="-180" - max="180" - value={heading.degrees || 0} + value={displayHeading(heading.degrees || 0, $coordinateSettings)} oninput={(e) => update({ degrees: degreesFrom(e.currentTarget) })} onblur={(e) => { if (Number.isNaN(parseFloat(e.currentTarget.value))) { diff --git a/src/lib/components/PiecewiseHeadingEditor.svelte b/src/lib/components/PiecewiseHeadingEditor.svelte index de7eb9e2..a8be039a 100644 --- a/src/lib/components/PiecewiseHeadingEditor.svelte +++ b/src/lib/components/PiecewiseHeadingEditor.svelte @@ -1,4 +1,7 @@
@@ -43,34 +38,22 @@
diff --git a/src/lib/components/mathtools/FieldGrid.svelte b/src/lib/components/mathtools/FieldGrid.svelte index fe1d8d99..4b4a9a6d 100644 --- a/src/lib/components/mathtools/FieldGrid.svelte +++ b/src/lib/components/mathtools/FieldGrid.svelte @@ -1,10 +1,13 @@ @@ -82,7 +96,7 @@ class="field-label {labelFontSize}" text-anchor="middle" > - {formatLabel(position)}" + {formatLabel(position, true)} {/if} {/each} @@ -106,7 +120,7 @@ class="field-label {labelFontSize}" text-anchor="end" > - {formatLabel(position)}" + {formatLabel(position, false)} {/if} {/each} diff --git a/src/lib/components/mathtools/RulerOverlay.svelte b/src/lib/components/mathtools/RulerOverlay.svelte index 4d8bfb5a..52758d07 100644 --- a/src/lib/components/mathtools/RulerOverlay.svelte +++ b/src/lib/components/mathtools/RulerOverlay.svelte @@ -1,4 +1,6 @@ diff --git a/src/lib/export/gifExport.ts b/src/lib/export/gifExport.ts index 6de8d301..55700cbf 100644 --- a/src/lib/export/gifExport.ts +++ b/src/lib/export/gifExport.ts @@ -5,11 +5,11 @@ import type { Settings, StartPose, } from "../../types"; +import { FIELD_SIZE, LINE_WIDTH } from "../../config/defaults"; import { - FIELD_IMAGE_OVERHANG, - FIELD_SIZE, - LINE_WIDTH, -} from "../../config/defaults"; + fieldImageLayout, + type CoordinateSettings, +} from "../../utils/coordinates"; import { robotPoseAt } from "../../sim/pose"; import { exportAsGif } from "../../utils/gifExporter"; import { pathStem } from "../../utils/filename"; @@ -37,6 +37,7 @@ export interface GifPath { export interface GifRequest { paths: GifPath[]; fieldImageSrc: string; + coordinates: CoordinateSettings; robotImageSrc: string; /** The on-screen field size in pixels; the GIF is a fixed fraction of it. */ fieldPixels: number; @@ -67,14 +68,10 @@ export async function renderPathGif(request: GifRequest): Promise { background.height = size; const backgroundContext = background.getContext("2d"); if (!backgroundContext) throw new Error("Failed to get canvas context"); - const overhang = toX(FIELD_IMAGE_OVERHANG); - backgroundContext.drawImage( - fieldImage, - -overhang, - -overhang, - size + 2 * overhang, - size + 2 * overhang, - ); + const layout = fieldImageLayout(request.coordinates); + const offset = toX(layout.offset); + const imageSize = toX(layout.size); + backgroundContext.drawImage(fieldImage, offset, offset, imageSize, imageSize); for (const path of request.paths) { drawPathLayer(backgroundContext, path, toX, toY, toX(LINE_WIDTH)); } diff --git a/src/model/commands.ts b/src/model/commands.ts index 3fcc5c2f..ae16f6a8 100644 --- a/src/model/commands.ts +++ b/src/model/commands.ts @@ -149,10 +149,23 @@ export const updateShape = (doc) => ({ ...doc, shapes: doc.shapes.map((shape) => - shape.id === shapeId ? update(shape) : shape, + shape.id === shapeId ? detachEditedOutline(shape, update(shape)) : shape, ), }); +function detachEditedOutline(before: Shape, after: Shape): Shape { + if ( + !before.fieldCalibration || + (before.vertices.length === after.vertices.length && + before.vertices.every( + (p, i) => p.x === after.vertices[i].x && p.y === after.vertices[i].y, + )) + ) + return after; + const { fieldCalibration: _link, ...custom } = after; + return custom; +} + export const updateRobot = (patch: Partial): Command => (doc) => ({ ...doc, robot: { ...doc.robot, ...patch } }); diff --git a/src/model/tileCalibration.ts b/src/model/tileCalibration.ts new file mode 100644 index 00000000..6f56db31 --- /dev/null +++ b/src/model/tileCalibration.ts @@ -0,0 +1,70 @@ +import type { ProjectDoc } from "../io/project"; +import type { BasePoint, Heading, Path } from "../types"; +import { + fromDisplay, + toDisplay, + type CoordinateSettings, +} from "../utils/coordinates"; + +/** Keep editable tile positions while changing their physical inch spacing. */ +export function preserveTileCoordinates( + doc: ProjectDoc, + before: CoordinateSettings, + after: CoordinateSettings, +): ProjectDoc { + const point = (p: T): T => ({ + ...p, + ...fromDisplay(toDisplay(p, before), after), + }); + const heading = (h: Heading): Heading => + h.type !== "piecewise" + ? h + : { + ...h, + piecewiseHeading: { + ...h.piecewiseHeading, + segments: h.piecewiseHeading.segments.map((segment) => ({ + ...segment, + ...(segment.parameters?.point + ? { + parameters: { + ...segment.parameters, + point: point(segment.parameters.point), + }, + } + : {}), + })), + }, + }; + const path = (p: Path): Path => + p.kind === "compound" + ? { + ...p, + segments: p.segments.map(path), + ...(p.heading ? { heading: heading(p.heading) } : {}), + } + : { + ...p, + endPoint: point(p.endPoint), + controlPoints: p.controlPoints.map(point), + ...(p.throughPoints + ? { throughPoints: p.throughPoints.map(point) } + : {}), + heading: heading(p.heading), + }; + return { + ...doc, + startPoint: point(doc.startPoint), + lines: doc.lines.map(path), + fieldPoints: doc.fieldPoints.map(point), + // Linked outlines use image calibration, including clipping at field walls. + shapes: doc.shapes.map((shape) => + shape.fieldCalibration + ? shape + : { + ...shape, + vertices: shape.vertices.map(point), + }, + ), + }; +} diff --git a/src/state/files.svelte.ts b/src/state/files.svelte.ts index 93db5ab4..5b7fd2af 100644 --- a/src/state/files.svelte.ts +++ b/src/state/files.svelte.ts @@ -1,3 +1,4 @@ +import { normalizeCoordinateSettings } from "../utils/coordinates"; import { fromStore, get } from "svelte/store"; import { debounce } from "lodash"; import { @@ -51,11 +52,27 @@ export class ProjectFiles { /** The open project as it would be saved, including which overlays are shown. */ current(): ProjectDoc { - return { ...this.#project.doc, activePaths: this.#activePaths.current }; + return { + ...this.#project.doc, + coordinates: normalizeCoordinateSettings(this.#prefs.settings), + activePaths: this.#activePaths.current, + }; } /** Open a document, starting a fresh undo history. */ open(doc: ProjectDoc, options: { saved?: boolean } = {}) { + if (doc.coordinates) { + this.#prefs.value = { + ...this.#prefs.value, + ...normalizeCoordinateSettings(doc.coordinates), + }; + } + const fields = doc.shapes.flatMap((shape) => + shape.fieldCalibration ? [shape.fieldCalibration.fieldMap] : [], + ); + if (fields.length > 0 && fields.every((field) => field === fields[0])) { + this.#prefs.value = { ...this.#prefs.value, fieldMap: fields[0] }; + } this.#project.load(doc, options); activePaths.set(doc.activePaths); } @@ -77,6 +94,7 @@ export class ProjectFiles { createDefaultProject( this.#project.doc.robot, this.#prefs.settings.fieldMap, + this.#prefs.settings, ), ); currentFilePath.set(null); diff --git a/src/state/gifExport.svelte.ts b/src/state/gifExport.svelte.ts index c023bc29..a1a7da5a 100644 --- a/src/state/gifExport.svelte.ts +++ b/src/state/gifExport.svelte.ts @@ -53,6 +53,7 @@ export class GifExport { const blob = await renderPathGif({ paths: this.#paths(), fieldImageSrc, + coordinates: this.#prefs.settings, robotImageSrc: this.#prefs.settings.robotImage || "/robot.png", fieldPixels, shouldCancel: () => this.#cancelled, diff --git a/src/state/prefs.svelte.ts b/src/state/prefs.svelte.ts index 4c5ab766..f2075289 100644 --- a/src/state/prefs.svelte.ts +++ b/src/state/prefs.svelte.ts @@ -1,3 +1,5 @@ +import { untrack } from "svelte"; +import { inchesPerTile } from "../utils/coordinates"; import { debounce } from "lodash"; import type { Settings } from "../types"; import { DEFAULT_SETTINGS } from "../config/defaults"; @@ -24,6 +26,10 @@ export class Prefs { constructor(project: ProjectStore) { this.#project = project; + $effect(() => { + const tileSize = inchesPerTile(this.value); + untrack(() => project.calibrateField({ tileSize })); + }); const persist = debounce( (settings: Settings) => saveSettings(settings), 1000, diff --git a/src/state/project.svelte.ts b/src/state/project.svelte.ts index 2f5cc357..be33e072 100644 --- a/src/state/project.svelte.ts +++ b/src/state/project.svelte.ts @@ -1,3 +1,10 @@ +import { preserveTileCoordinates } from "../model/tileCalibration"; +import { calibrateFieldShapes } from "../config/defaults"; +import { + DEFAULT_TILE_SIZE, + inchesPerTile, + type CoordinateSettings, +} from "../utils/coordinates"; import { getContext, setContext } from "svelte"; import type { ProjectDoc } from "../io"; import type { Command } from "../model/commands"; @@ -17,6 +24,7 @@ export class ProjectStore { #redo = $state.raw([]); #saved = $state.raw()!; #transactionBase: ProjectDoc | null = null; + #tileSize = DEFAULT_TILE_SIZE; #lastCoalesced: { key: string; at: number } | null = null; canUndo = $derived(this.#undo.length > 0); @@ -24,8 +32,9 @@ export class ProjectStore { dirty = $derived(this.doc !== this.#saved); constructor(doc: ProjectDoc) { - this.doc = doc; - this.#saved = doc; + this.#tileSize = inchesPerTile(doc.coordinates ?? {}); + this.doc = this.#calibrated(doc); + this.#saved = this.doc; } /** @@ -37,7 +46,7 @@ export class ProjectStore { this.preview(command); return; } - const next = command(this.doc); + const next = this.#calibrated(command(this.doc)); if (next === this.doc) return; const now = Date.now(); @@ -56,7 +65,7 @@ export class ProjectStore { /** Show an edit without recording it; `commit` records everything since as one step. */ preview(command: Command) { this.#transactionBase ??= this.doc; - this.doc = command(this.doc); + this.doc = this.#calibrated(command(this.doc)); } commit() { @@ -67,7 +76,8 @@ export class ProjectStore { } cancel() { - if (this.#transactionBase) this.doc = this.#transactionBase; + if (this.#transactionBase) + this.doc = this.#calibrated(this.#transactionBase); this.#transactionBase = null; } @@ -77,7 +87,7 @@ export class ProjectStore { if (!previous) return; this.#undo = this.#undo.slice(0, -1); this.#redo = [...this.#redo, this.doc]; - this.doc = previous; + this.doc = this.#calibrated(previous); } redo() { @@ -86,7 +96,7 @@ export class ProjectStore { if (!next) return; this.#redo = this.#redo.slice(0, -1); this.#undo = [...this.#undo, this.doc]; - this.doc = next; + this.doc = this.#calibrated(next); } /** Open a different document. History starts over. */ @@ -95,8 +105,40 @@ export class ProjectStore { this.#lastCoalesced = null; this.#undo = []; this.#redo = []; - this.doc = doc; - if (options.saved ?? true) this.#saved = doc; + if (doc.coordinates) this.#tileSize = inchesPerTile(doc.coordinates); + this.doc = this.#calibrated(doc); + if (options.saved ?? true) this.#saved = this.doc; + } + + /** A display calibration follows built-ins, without moving paths or custom obstacles. */ + calibrateField(settings: CoordinateSettings) { + this.#tileSize = inchesPerTile(settings); + this.doc = this.#calibrated(this.doc); + } + + /** Settings calibration is not a path edit; keep undo/redo in the new units too. */ + changeTileSize(before: CoordinateSettings, tileSize: number) { + const after = { ...before, tileSize }; + if (inchesPerTile(before) === inchesPerTile(after)) return; + this.commit(); + this.#tileSize = inchesPerTile(after); + if (before.distanceUnit === "tiles") { + const transform = (doc: ProjectDoc) => + this.#calibrated(preserveTileCoordinates(doc, before, after)); + this.#undo = this.#undo.map(transform); + this.#redo = this.#redo.map(transform); + this.doc = transform(this.doc); + } else { + this.doc = this.#calibrated(this.doc); + } + this.#lastCoalesced = null; + } + + #calibrated(doc: ProjectDoc): ProjectDoc { + const shapes = calibrateFieldShapes(doc.shapes, { + tileSize: this.#tileSize, + }); + return shapes === doc.shapes ? doc : { ...doc, shapes }; } markSaved() { diff --git a/src/stores.ts b/src/stores.ts index 076ea6ae..47cea38a 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -1,5 +1,8 @@ import { writable } from "svelte/store"; +import type { CoordinateSettings } from "./utils/coordinates"; +export const coordinateSettings = writable({}); + // Math tools stores export const showRuler = writable(false); export const showProtractor = writable(false); diff --git a/src/types.ts b/src/types.ts index 4f22a56d..b9debac9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -112,7 +112,9 @@ export type SequenceWaitItem = { export type SequenceItem = SequencePathItem | SequenceWaitItem; -export interface Settings { +import type { CoordinateSettings } from "./utils/coordinates"; + +export interface Settings extends CoordinateSettings { forwardVelocity: number; // inches/sec strafeVelocity: number; // inches/sec aVelocity: number; @@ -165,6 +167,8 @@ export type RobotConfig = Pick< >; export interface Shape { + /** Untouched built-in outlines follow field-image calibration. Vertex edits detach them. */ + fieldCalibration?: { fieldMap: string; tileSize: number }; id: string; name?: string; vertices: BasePoint[]; diff --git a/src/utils/coordinates.ts b/src/utils/coordinates.ts new file mode 100644 index 00000000..3ef3c82b --- /dev/null +++ b/src/utils/coordinates.ts @@ -0,0 +1,147 @@ +import type { BasePoint } from "../types"; + +export type CoordinateSystem = "pedro" | "ftc" | "ftc-inverted"; +export type DistanceUnit = "inches" | "tiles"; +/** Rounded from the 23.502083-inch repeat in FIRST’s BIOBUZZ STEP; see docs/ftc-cad-measurements.json. */ +export const DEFAULT_TILE_SIZE = 23.5; +export const FIELD_INCHES = 141.5; + +export interface CoordinateSettings { + coordinateSystem?: CoordinateSystem; + distanceUnit?: DistanceUnit; + tileSize?: number; +} + +export function inchesPerUnit(settings: CoordinateSettings): number { + return settings.distanceUnit === "tiles" ? inchesPerTile(settings) : 1; +} + +/** Physical seam pitch, independent of the selected display units. */ +export function inchesPerTile(settings: CoordinateSettings): number { + const size = settings.tileSize; + return typeof size === "number" && Number.isFinite(size) && size > 0 + ? size + : DEFAULT_TILE_SIZE; +} + +export function unitLabel(settings: CoordinateSettings): string { + return settings.distanceUnit === "tiles" ? "tiles" : "in"; +} + +// These describe FTC axes relative to the fixed Pedro field image: +// FTC +X points down, +Y right; FTC Inverted +X up, +Y left. +// All frames use counterclockwise-positive headings. Storage remains Pedro/inches. +export function toDisplay( + point: BasePoint, + settings: CoordinateSettings, +): BasePoint { + const scale = inchesPerUnit(settings); + const x = point.x - FIELD_INCHES / 2; + const y = point.y - FIELD_INCHES / 2; + if (settings.coordinateSystem === "ftc") + return { x: -y / scale, y: x / scale }; + if (settings.coordinateSystem === "ftc-inverted") + return { x: y / scale, y: -x / scale }; + return { x: point.x / scale, y: point.y / scale }; +} + +export function fromDisplay( + point: BasePoint, + settings: CoordinateSettings, +): BasePoint { + const scale = inchesPerUnit(settings); + const x = point.x * scale; + const y = point.y * scale; + if (settings.coordinateSystem === "ftc") + return { x: y + FIELD_INCHES / 2, y: -x + FIELD_INCHES / 2 }; + if (settings.coordinateSystem === "ftc-inverted") + return { x: -y + FIELD_INCHES / 2, y: x + FIELD_INCHES / 2 }; + return { x, y }; +} + +function headingOffset(settings: CoordinateSettings): number { + return settings.coordinateSystem === "ftc" + ? 90 + : settings.coordinateSystem === "ftc-inverted" + ? -90 + : 0; +} + +export function displayHeading( + degrees: number, + settings: CoordinateSettings, +): number { + return degrees + headingOffset(settings); +} + +export function storedHeading( + degrees: number, + settings: CoordinateSettings, +): number { + return degrees - headingOffset(settings); +} + +export function editCoordinate( + point: BasePoint, + axis: "x" | "y", + value: number, + settings: CoordinateSettings, +): BasePoint { + if (!Number.isFinite(value)) return point; + return { + ...point, + ...fromDisplay({ ...toDisplay(point, settings), [axis]: value }, settings), + }; +} + +/** Six-tile images stay centered within the fixed physical field boundary. */ +export function fieldImageLayout(settings: CoordinateSettings) { + const size = 6 * inchesPerTile(settings); + return { size, offset: (FIELD_INCHES - size) / 2 }; +} + +// Pedro follows the calibrated image's tile corner; centered frames follow its center seam. +export function gridOrigin(settings: CoordinateSettings): number { + return settings.coordinateSystem === "ftc" || + settings.coordinateSystem === "ftc-inverted" + ? FIELD_INCHES / 2 + : fieldImageLayout(settings).offset; +} + +// Presets are fractions of a tile, so changing display units never moves the grid. +export function gridSpacing( + size: number, + settings: CoordinateSettings, +): number { + return (size / 24) * inchesPerTile(settings); +} + +export function formatPoint( + point: BasePoint, + settings: CoordinateSettings, +): string { + const value = toDisplay(point, settings); + return `${Number(value.x.toFixed(3))}, ${Number(value.y.toFixed(3))}`; +} + +/** Validate display preferences at file and browser-storage boundaries. */ +export function normalizeCoordinateSettings( + value: unknown, +): Required { + const input = + value && typeof value === "object" ? (value as CoordinateSettings) : {}; + return { + coordinateSystem: + input.coordinateSystem === "ftc" || + input.coordinateSystem === "ftc-inverted" + ? input.coordinateSystem + : "pedro", + distanceUnit: input.distanceUnit === "tiles" ? "tiles" : "inches", + tileSize: + typeof input.tileSize === "number" && + Number.isFinite(input.tileSize) && + input.tileSize > 0 + ? input.tileSize + : DEFAULT_TILE_SIZE, + }; +} diff --git a/src/utils/settingsPersistence.ts b/src/utils/settingsPersistence.ts index 8758ff81..6d1d8f1e 100644 --- a/src/utils/settingsPersistence.ts +++ b/src/utils/settingsPersistence.ts @@ -1,3 +1,4 @@ +import { normalizeCoordinateSettings } from "./coordinates"; import { DEFAULT_SETTINGS } from "../config/defaults"; import type { Settings } from "../types"; @@ -18,6 +19,8 @@ const SETTINGS_STORAGE_KEY = "pedro_settings"; */ export function normalizeLegacyFieldMap(input: Settings): Settings { const next = { ...input }; + if (next.robotImage?.startsWith("/") && !next.robotImage.startsWith("//")) + next.robotImage = next.robotImage.slice(1); // Saved settings from before x/y velocity became forward/strafe. const legacy = next as Settings & { @@ -50,7 +53,7 @@ export function normalizeLegacyFieldMap(input: Settings): Settings { next.fieldMap = DEFAULT_SETTINGS.fieldMap; } - return next; + return { ...next, ...normalizeCoordinateSettings(next) }; } function migrateSettings(stored: Partial): Settings { diff --git a/src/view/FieldPanel.svelte b/src/view/FieldPanel.svelte index 1b51f1f5..5c86430b 100644 --- a/src/view/FieldPanel.svelte +++ b/src/view/FieldPanel.svelte @@ -1,4 +1,5 @@ @@ -98,10 +99,23 @@ {@const pose = playback.robotPose} + {@const displayed = toDisplay(pose, settings)} {@const readouts = [ - { label: "X", value: pose.x.toFixed(1), unit: "in" }, - { label: "Y", value: pose.y.toFixed(1), unit: "in" }, - { label: "Heading", value: pose.heading.toFixed(0), unit: "°" }, + { + label: "X", + value: displayed.x.toFixed(2), + unit: unitLabel(settings), + }, + { + label: "Y", + value: displayed.y.toFixed(2), + unit: unitLabel(settings), + }, + { + label: "Heading", + value: displayHeading(pose.heading, settings).toFixed(0), + unit: "°", + }, { label: "t", value: pose.t?.toFixed(3) ?? "–", unit: "" }, ]}
diff --git a/src/view/PlaybackBar.svelte b/src/view/PlaybackBar.svelte index 0b4f58a2..76687c51 100644 --- a/src/view/PlaybackBar.svelte +++ b/src/view/PlaybackBar.svelte @@ -1,4 +1,6 @@
+ {#if shape.fieldCalibration} +

+ Built-in outline: follows tile calibration. Editing a vertex makes it + custom. +

+ {/if}