From 66f5fd4b2567ba2ea03fdd7b5975247d0b4d8102 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 16 Apr 2026 15:03:21 -0700 Subject: [PATCH 01/11] add ruby-check to agents file --- spec/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/AGENTS.md b/spec/AGENTS.md index ab556ccaae..ee1f362fcf 100644 --- a/spec/AGENTS.md +++ b/spec/AGENTS.md @@ -5,6 +5,10 @@ For changes to files in the `app/` or `spec/` directories. - Do not terminate test commands before they are completed. ### For the files you change +- Make sure all checks and linters pass: + ``` + bun run ruby-check + ``` - Run tests via `rspec FILES` where `FILES` is a space-separated list of spec files for the app files you changed. For example, `rspec spec/file_0_spec.rb spec/file_1_spec.rb`. From 42bb2d410aea6058a75ebdd7613f86a3cc075e45 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 16 Apr 2026 15:04:19 -0700 Subject: [PATCH 02/11] avoid long test failure output --- frontend/__test_support__/setup_tests.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/__test_support__/setup_tests.ts b/frontend/__test_support__/setup_tests.ts index 15dd9b291b..43f87b4568 100644 --- a/frontend/__test_support__/setup_tests.ts +++ b/frontend/__test_support__/setup_tests.ts @@ -1,2 +1,17 @@ import "@testing-library/jest-dom"; import "./customMatchers"; + +expect.extend({ + toContainHTML(received: Element | { innerHTML?: string }, expected: string) { + const actual = received?.innerHTML ?? ""; + const pass = actual.includes(expected); + + return { + pass, + message: () => + `expected html to${pass ? " not" : ""} contain ` + + `${this.utils.printExpected(expected)}\n` + + `received: ${this.utils.printReceived(actual)}`, + }; + }, +}); From 73dc542e47c68ff6f3e0e8fab24da35cae77dcb8 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 16 Apr 2026 15:04:40 -0700 Subject: [PATCH 03/11] add 3D mirroring option --- .../__tests__/three_d_garden_map_test.tsx | 12 +- frontend/farm_designer/three_d_garden_map.tsx | 24 +- .../three_d_garden/__tests__/helpers_test.ts | 45 ++ .../three_d_garden/bed/__tests__/bed_test.tsx | 40 +- frontend/three_d_garden/bed/bed.tsx | 47 +- .../__tests__/pointer_objects_test.tsx | 36 + .../bed/objects/pointer_objects.tsx | 10 +- frontend/three_d_garden/bot/bot.tsx | 145 ++-- .../bot/components/__tests__/tools_test.tsx | 66 ++ .../three_d_garden/bot/components/bounds.tsx | 36 +- .../bot/components/cable_carriers.tsx | 79 +-- .../bot/components/camera_view.tsx | 26 +- .../bot/components/electronics_box.tsx | 13 +- .../bot/components/gantry_beam.tsx | 13 +- .../bot/components/solenoid.tsx | 45 +- .../three_d_garden/bot/components/tools.tsx | 76 ++- .../bot/components/watering_animations.tsx | 11 +- frontend/three_d_garden/config.ts | 8 +- frontend/three_d_garden/config_overlays.tsx | 2 + .../garden/__tests__/images_test.tsx | 40 +- .../garden/__tests__/plant_instances_test.tsx | 43 +- .../garden/__tests__/plants_test.tsx | 10 + .../garden/__tests__/point_test.tsx | 12 + .../garden/__tests__/weed_test.tsx | 12 + frontend/three_d_garden/garden/grid.tsx | 31 +- frontend/three_d_garden/garden/images.tsx | 63 +- .../garden/moisture_texture.tsx | 1 + .../three_d_garden/garden/plant_instances.tsx | 10 +- frontend/three_d_garden/garden/plants.tsx | 32 +- frontend/three_d_garden/garden/point.tsx | 9 +- frontend/three_d_garden/garden/weed.tsx | 9 +- .../three_d_garden/group_order_visual.tsx | 20 +- frontend/three_d_garden/helpers.ts | 29 +- frontend/three_d_garden/triangles.ts | 2 +- frontend/three_d_garden/visualization.tsx | 24 +- .../three_d_garden/zoom_beacons_constants.tsx | 627 +++++++++--------- 36 files changed, 1103 insertions(+), 605 deletions(-) diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index d8d0d0fe51..67f8c99d1b 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -146,8 +146,8 @@ describe("", () => { seed: 0, size: 50, spread: 30, - x: 101, - y: 201, + x: 100, + y: 200, }], addPlantProps: expect.any(Object), ...EMPTY_PROPS, @@ -336,8 +336,8 @@ describe("convertPlants()", () => { seed: 0, size: 50, spread: 20, - x: 110, - y: 201, + x: 100, + y: 200, }, { icon: CROPS["generic-plant"].icon, @@ -347,8 +347,8 @@ describe("convertPlants()", () => { seed: 0, size: 50, spread: 0, - x: 1010, - y: 2001, + x: 1000, + y: 2000, }, ]); }); diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 60950c908a..6fade06eda 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -75,11 +75,25 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { config.negativeZ = props.negativeZ; config.exaggeratedZ = props.designer.threeDExaggeratedZ; + const quadrant = props.mapTransformProps.quadrant; + config.mirrorX = props.mapTransformProps.xySwap + ? [3, 4].includes(quadrant) + : [1, 4].includes(quadrant); + config.mirrorY = props.mapTransformProps.xySwap + ? [1, 4].includes(quadrant) + : [3, 4].includes(quadrant); + + const getValue = props.get3DConfigValue; + config.bedXOffset = getValue("bedXOffset"); + config.bedYOffset = getValue("bedYOffset"); + config.bedZOffset = getValue("bedZOffset"); const position = clone(INITIAL_POSITION); position.x = props.botPosition.x || 0; position.y = props.botPosition.y || 0; position.z = props.botPosition.z || 0; + if (config.mirrorY) { position.y = gridSize.y - position.y; } + if (config.mirrorX) { position.x = gridSize.x - position.x; } const { designer } = props; config.distanceIndicator = designer.distanceIndicator; @@ -89,16 +103,12 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { config.zGantryOffset = fbosConfig("gantry_height"); config.soilHeight = Math.abs(fbosConfig("soil_height")); - const getValue = props.get3DConfigValue; config.bedWallThickness = getValue("bedWallThickness"); config.bedHeight = getValue("bedHeight"); config.ccSupportSize = getValue("ccSupportSize"); config.beamLength = getValue("beamLength"); config.columnLength = getValue("columnLength"); config.zAxisLength = getValue("zAxisLength"); - config.bedXOffset = getValue("bedXOffset"); - config.bedYOffset = getValue("bedYOffset"); - config.bedZOffset = getValue("bedZOffset"); config.legSize = getValue("legSize"); config.legsFlush = !!getValue("legsFlush"); config.extraLegsX = getValue("extraLegsX"); @@ -214,15 +224,15 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { }; export const convertPlants = - (config: Config, plants: TaggedPlant[]): ThreeDGardenPlant[] => + (_config: Config, plants: TaggedPlant[]): ThreeDGardenPlant[] => plants.map(plant => ({ id: plant.body.id, label: plant.body.name, icon: findIcon(plant.body.openfarm_slug), size: plant.body.radius * 2, spread: findCrop(plant.body.openfarm_slug).spread, - x: plant.body.x + config.bedXOffset, - y: plant.body.y + config.bedYOffset, + x: plant.body.x, + y: plant.body.y, key: "", seed: 0, })); diff --git a/frontend/three_d_garden/__tests__/helpers_test.ts b/frontend/three_d_garden/__tests__/helpers_test.ts index cb3cbbbd3f..211fd3fc2e 100644 --- a/frontend/three_d_garden/__tests__/helpers_test.ts +++ b/frontend/three_d_garden/__tests__/helpers_test.ts @@ -2,6 +2,9 @@ import { clone } from "lodash"; import { easyCubicBezierCurve3, getColorFromBrightness, + get3DPositionFunc, + getGardenPositionFunc, + getWorldPositionFunc, threeSpace, zDir, zZero, @@ -53,3 +56,45 @@ describe("easyCubicBezierCurve3()", () => { expect(result).toEqual(expected); }); }); + +describe("mirror-aware position helpers", () => { + const fakeConfig = () => { + const config = clone(INITIAL); + config.botSizeX = 1000; + config.botSizeY = 500; + config.bedLengthOuter = 1200; + config.bedWidthOuter = 700; + config.bedXOffset = 100; + config.bedYOffset = 50; + return config; + }; + + it("round trips garden coordinates without mirroring", () => { + const config = fakeConfig(); + const get3DPosition = get3DPositionFunc(config); + const getGardenPosition = getGardenPositionFunc(config, false); + const world = get3DPosition({ x: 125, y: 250 }); + expect(getGardenPosition(world)).toEqual({ x: 125, y: 250 }); + }); + + it("round trips garden coordinates with x and y mirroring", () => { + const config = fakeConfig(); + config.mirrorX = true; + config.mirrorY = true; + const get3DPosition = get3DPositionFunc(config); + const getGardenPosition = getGardenPositionFunc(config, false); + const world = get3DPosition({ x: 125, y: 250 }); + expect(world).toEqual({ x: 375, y: 50 }); + expect(getGardenPosition(world)).toEqual({ x: 125, y: 250 }); + }); + + it("returns world position with mirrored x and y", () => { + const config = fakeConfig(); + config.columnLength = 200; + config.zGantryOffset = 100; + config.mirrorX = true; + config.mirrorY = true; + const getWorldPosition = getWorldPositionFunc(config); + expect(getWorldPosition({ x: 125, y: 250, z: 10 })).toEqual([375, 50, 150]); + }); +}); diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 4b037a0ea6..98a709e9d3 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -77,7 +77,7 @@ import { fakeDrawnPoint } from "../../../__test_support__/fake_designer_state"; import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import { fakePoint } from "../../../__test_support__/fake_state/resources"; import { SpecialStatus } from "farmbot"; -import { BufferGeometry } from "three"; +import { BufferGeometry, Float32BufferAttribute } from "three"; import { ActivePositionRef } from "../objects/pointer_objects"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; @@ -443,4 +443,42 @@ describe("", () => { expect(mockSetBillboardPosition).not.toHaveBeenCalled(); expect(mockSetImageScale).not.toHaveBeenCalled(); }); + + it("mirrors the rendered soil surface geometry", () => { + const p = fakeProps(); + p.config.mirrorX = true; + p.config.mirrorY = true; + p.config.bedLengthOuter = 1000; + p.config.bedWidthOuter = 800; + p.config.bedWallThickness = 100; + p.config.bedXOffset = 50; + p.config.bedYOffset = 25; + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute([ + 150, 200, 10, + 300, 400, 20, + ], 3)); + geometry.setAttribute("normal", new Float32BufferAttribute([ + 1, 2, 3, + 4, 5, 6, + ], 3)); + p.soilSurfaceGeometry = geometry; + const cloneSpy = jest.spyOn(geometry, "clone"); + + render(); + + const mirroredGeometry = cloneSpy.mock.results[0]?.value as BufferGeometry; + const position = mirroredGeometry.getAttribute("position"); + const normal = mirroredGeometry.getAttribute("normal"); + expect(position.getX(0)).toEqual(750); + expect(position.getY(0)).toEqual(550); + expect(position.getX(1)).toEqual(600); + expect(position.getY(1)).toEqual(350); + expect(normal.getX(0)).toEqual(-1); + expect(normal.getY(0)).toEqual(-2); + expect(normal.getZ(0)).toEqual(3); + expect(normal.getX(1)).toEqual(-4); + expect(normal.getY(1)).toEqual(-5); + expect(normal.getZ(1)).toEqual(6); + }); }); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 5ba8611a2d..768e912201 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -10,6 +10,7 @@ import { BufferGeometry, Mesh as MeshType, BackSide, + FrontSide, Color, } from "three"; import { range } from "lodash"; @@ -44,6 +45,7 @@ import { ImageTexture } from "../garden"; import { VertexNormalsHelper } from "three/examples/jsm/Addons.js"; import { MoistureSurface } from "../garden/moisture_texture"; import { HeightMaterial } from "../garden/height_material"; +import { soilSurfaceExtents } from "../triangles"; const soil = ( Type: typeof LinePath | typeof Shape, @@ -89,7 +91,11 @@ const Surface = (props: SurfaceProps) => { // eslint-disable-next-line no-null/no-null const ref = React.useRef(null) as React.RefObject; useHelper(ref, VertexNormalsHelper, 1000); - return + const enableHelper = [ + SurfaceDebugOption.normals, + SurfaceDebugOption.height, + ].includes(props.config.surfaceDebug); + return {props.children} ; }; @@ -279,6 +285,41 @@ export const Bed = (props: BedProps) => { const SurfaceMaterial = getSurfaceMaterial(); const surfaceTexture = soilTexture; + const mirroredAxesCount = + Number(props.config.mirrorX) + Number(props.config.mirrorY); + const soilSurfaceSide = mirroredAxesCount % 2 == 1 ? FrontSide : BackSide; + const renderSoilSurfaceGeometry = React.useMemo(() => { + if (!props.config.mirrorX && !props.config.mirrorY) { + return props.soilSurfaceGeometry; + } + const geometry = props.soilSurfaceGeometry.clone(); + const position = geometry.getAttribute("position"); + const normal = geometry.getAttribute("normal"); + const extents = soilSurfaceExtents(props.config); + const xMid = (extents.x.min + extents.x.max) / 2; + const yMid = (extents.y.min + extents.y.max) / 2; + for (let i = 0; i < position.count; i++) { + if (props.config.mirrorX) { + position.setX(i, 2 * xMid - position.getX(i)); + } + if (props.config.mirrorY) { + position.setY(i, 2 * yMid - position.getY(i)); + } + if (normal) { + if (props.config.mirrorX) { + normal.setX(i, -normal.getX(i)); + } + if (props.config.mirrorY) { + normal.setY(i, -normal.getY(i)); + } + } + } + position.needsUpdate = true; + if (normal) { normal.needsUpdate = true; } + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; + }, [props.soilSurfaceGeometry, props.config]); const soilPosition: [number, number, number] = [ threeSpace(0, bedLengthOuter) + bedXOffset, threeSpace(0, bedWidthOuter) + bedYOffset, @@ -320,7 +361,7 @@ export const Bed = (props: BedProps) => { ]); const commonSoilLayerProps = { config: props.config, - geometry: props.soilSurfaceGeometry, + geometry: renderSoilSurfaceGeometry, position: soilPosition, onClick: onSoilClick, onPointerMove: onSoilPointerMove, @@ -434,7 +475,7 @@ export const Bed = (props: BedProps) => { {surfaceTexture} diff --git a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx index 77821c3204..2ab254637d 100644 --- a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx +++ b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx @@ -113,6 +113,24 @@ describe("soilClick()", () => { gardenCoords: { x: 1360, y: 660 }, })); }); + + it("creates plant with mirrored garden coordinates", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + mockIsMobile = false; + const p = fakeProps(); + p.config.mirrorX = true; + p.config.mirrorY = true; + p.config.botSizeX = 2000; + p.config.botSizeY = 1000; + const e = { + stopPropagation: jest.fn(), + point: { x: 1, y: 2 }, + } as unknown as ThreeEvent; + soilClick(p)(e); + expect(dropPlantSpy).toHaveBeenCalledWith(expect.objectContaining({ + gardenCoords: { x: 1360, y: 660 }, + })); + }); }); describe("soilPointerMove()", () => { @@ -179,6 +197,24 @@ describe("soilPointerMove()", () => { .toHaveBeenCalledWith(110, 210, 0); }); + it("updates plant position with mirrored world coordinates", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + mockIsMobile = false; + const p = fakeProps(); + p.config.columnLength = 100; + p.config.botSizeX = 1000; + p.config.botSizeY = 800; + p.config.mirrorX = true; + p.config.mirrorY = true; + const e = { + stopPropagation: jest.fn(), + point: { x: 100, y: 200 }, + } as unknown as ThreeEvent; + soilPointerMove(p)(e); + expect(p.pointerPlantRef.current?.position.set) + .toHaveBeenCalledWith(100, 200, 0); + }); + it("skips re-rendering the same pointer position", () => { location.pathname = Path.mock(Path.cropSearch("mint")); mockIsMobile = false; diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx index 2acefb01d0..ca8a53c77b 100644 --- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx +++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx @@ -19,9 +19,9 @@ import { import { zero as zeroFunc, extents as extentsFunc, - zZero, getGardenPositionFunc, get3DPositionFunc, + getWorldPositionFunc, } from "../../helpers"; import { Config } from "../../config"; import { SpecialStatus, TaggedGenericPointer } from "farmbot"; @@ -158,6 +158,8 @@ export const PointerObjects = (props: PointerObjectsProps) => { shader.uniforms.uHalfSize = { value: halfSize }; shader.uniforms.uInside = { value: new Color("white") }; shader.uniforms.uOutside = { value: new Color("red") }; + shader.uniforms.uMirrorX = { value: config.mirrorX ? -1 : 1 }; + shader.uniforms.uMirrorY = { value: config.mirrorY ? -1 : 1 }; outOfBoundsShaderModification(shader); }} depthWrite={false} /> @@ -246,6 +248,7 @@ export const soilPointerMove = (props: SoilPointerMoveProps) => } = props; const getGardenPosition = getGardenPositionFunc(config); const get3DPosition = get3DPositionFunc(config); + const getWorldPosition = getWorldPositionFunc(config); let frame = 0; let pendingGardenPosition: ReturnType | undefined; let lastRenderedPosition: { x: number, y: number } | undefined; @@ -261,7 +264,10 @@ export const soilPointerMove = (props: SoilPointerMoveProps) => || isMobile() || !pointerPlantRef.current) { return; } const { x, y } = get3DPosition(gardenPosition); - const z = zZero(config) + props.getZ(gardenPosition.x, gardenPosition.y); + const [, , z] = getWorldPosition({ + ...gardenPosition, + z: props.getZ(gardenPosition.x, gardenPosition.y), + }); if (lastRenderedPosition?.x === x && lastRenderedPosition.y === y) { return; } diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 8c3d15a9b0..cc5e2fb3bd 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -6,7 +6,7 @@ import { } from "@react-three/drei"; import { DoubleSide, Shape, RepeatWrapping } from "three"; import { - easyCubicBezierCurve3, threeSpace, + easyCubicBezierCurve3, get3DPositionNoMirrorFunc, zDir as zDirFunc, zZero as zZeroFunc, } from "../helpers"; @@ -106,12 +106,19 @@ export interface FarmbotModelProps { export const Bot = (props: FarmbotModelProps) => { const config = props.config; const { botSizeX, botSizeY, botSizeZ, trail, laser, - bedXOffset, bedYOffset, bedLengthOuter, bedWidthOuter, tracks, + bedYOffset, bedWidthOuter, tracks, columnLength, zAxisLength, zGantryOffset, } = props.config; const { x, y, z } = props.configPosition; const zZero = zZeroFunc(config); const zDir = zDirFunc(config); + const get3DPosition = get3DPositionNoMirrorFunc(config); + const gardenXY = (gardenX: number, gardenY: number): [number, number] => { + const position = get3DPosition({ x: gardenX, y: gardenY }); + return [position.x, position.y]; + }; + const outerXY = (gardenX: number, outerY: number): [number, number] => + gardenXY(gardenX, outerY - bedYOffset); const gantryWheelPlate = useGLTF(ASSETS.models.gantryWheelPlate, LIB_DIR) as unknown as GantryWheelPlateFull; const GantryWheelPlateComponent = GantryWheelPlate(gantryWheelPlate); @@ -210,18 +217,10 @@ export const Bot = (props: FarmbotModelProps) => { const airTubeEndPosition = (kitVersion: string): [number, number, number] => { switch (kitVersion) { case "v1.7": - return [ - threeSpace(x + 80, bedLengthOuter) + bedXOffset, - threeSpace(y + 100, bedWidthOuter) + bedYOffset, - zZero - zDir * z + 245, - ]; + return [...gardenXY(x + 80, y + 100), zZero - zDir * z + 245]; case "v1.8": default: - return [ - threeSpace(x + 35, bedLengthOuter) + bedXOffset, - threeSpace(y, bedWidthOuter) + bedYOffset, - zZero - zDir * z + 245, - ]; + return [...gardenXY(x + 35, y), zZero - zDir * z + 245]; } }; @@ -238,24 +237,15 @@ export const Bot = (props: FarmbotModelProps) => { const vacuumPumpCoverPosition = (kitVersion: string): [number, number, number] => { switch (kitVersion) { case "v1.7": - return [ - threeSpace(x + 12, bedLengthOuter) + bedXOffset, - threeSpace(y + 55, bedWidthOuter) + bedYOffset, - zZero - zDir * z + 490, - ]; + return [...gardenXY(x + 12, y + 55), zZero - zDir * z + 490]; case "v1.8": default: - return [ - threeSpace(x + 2, bedLengthOuter) + bedXOffset, - threeSpace(y + 110, bedWidthOuter) + bedYOffset, - zZero + columnLength + 25, - ]; + return [...gardenXY(x + 2, y + 110), zZero + columnLength + 25]; } }; const cameraMountPosition = new THREE.Vector3( - threeSpace(x + cameraMountOffset.x, bedLengthOuter) + bedXOffset, - threeSpace(y + cameraMountOffset.y, bedWidthOuter) + bedYOffset, + ...gardenXY(x + cameraMountOffset.x, y + cameraMountOffset.y), zZero - zDir * z - 140 + zGantryOffset + 20, ); @@ -272,8 +262,7 @@ export const Bot = (props: FarmbotModelProps) => { { steps: 1, depth: columnLength, bevelEnabled: false }, ]} position={[ - threeSpace(x - extrusionWidth - 12, bedLengthOuter) + bedXOffset, - threeSpace(y + bedColumnYOffset, bedWidthOuter), + ...outerXY(x - extrusionWidth - 12, y + bedColumnYOffset), 30, ]} rotation={[0, 0, Math.PI / 2]}> @@ -284,9 +273,9 @@ export const Bot = (props: FarmbotModelProps) => { { { material={undefined} /> { @@ -338,10 +327,11 @@ export const Bot = (props: FarmbotModelProps) => { { steps: 1, depth: botSizeX + xTrackPadding, bevelEnabled: false }, ]} position={[ - threeSpace(index == 0 - ? botSizeX + xTrackPadding / 2 - : -xTrackPadding / 2, bedLengthOuter) + bedXOffset, - threeSpace(y + (index == 0 ? 2.5 : 17.5), bedWidthOuter), + ...outerXY( + index == 0 + ? botSizeX + xTrackPadding / 2 + : -xTrackPadding / 2, + y + (index == 0 ? 2.5 : 17.5)), 2, ]} rotation={[ @@ -356,8 +346,7 @@ export const Bot = (props: FarmbotModelProps) => { { { { })} { { { steps: 1, depth: zAxisLength, bevelEnabled: false }, ]} position={[ - threeSpace(x, bedLengthOuter) + bedXOffset, - threeSpace(y + utmRadius, bedWidthOuter) + bedYOffset, + ...gardenXY(x, y + utmRadius), zZero - zDir * z, ]} rotation={[0, 0, 0]}> @@ -435,8 +420,7 @@ export const Bot = (props: FarmbotModelProps) => { { { material={undefined} /> { @@ -478,8 +459,7 @@ export const Bot = (props: FarmbotModelProps) => { { @@ -501,8 +480,7 @@ export const Bot = (props: FarmbotModelProps) => { material-color={"#555"} args={[4, 4, zAxisLength - 200]} position={[ - threeSpace(x + 6, bedLengthOuter) + bedXOffset, - threeSpace(y - 30, bedWidthOuter) + bedYOffset, + ...gardenXY(x + 6, y - 30), zZero - zDir * z + zAxisLength / 2, ]} rotation={[Math.PI / 2, 0, 0]} /> @@ -512,8 +490,7 @@ export const Bot = (props: FarmbotModelProps) => { { { { receiveShadow={true} args={[easyCubicBezierCurve3( [ - threeSpace(x + 28, bedLengthOuter) + bedXOffset, - threeSpace(y, bedWidthOuter) + bedYOffset, + ...gardenXY(x + 28, y), zZero - zDir * z + 35, ], [0, 0, 100], @@ -598,8 +572,7 @@ export const Bot = (props: FarmbotModelProps) => { interval={1}> { material-color={"red"} args={[5, 5, distanceToSoil]} position={[ - threeSpace(x, bedLengthOuter) + bedXOffset, - threeSpace(y, bedWidthOuter) + bedYOffset, + ...gardenXY(x, y), zZero - zDir * z - distanceToSoil / 2, ]} rotation={[Math.PI / 2, 0, 0]} /> @@ -632,8 +604,7 @@ export const Bot = (props: FarmbotModelProps) => { { { steps: 1, depth: 6, bevelEnabled: false }, ]} position={[ - threeSpace(x - 14.5, bedLengthOuter) + bedXOffset, - threeSpace(-100, bedWidthOuter) + bedYOffset, + ...gardenXY(x - 14.5, -100), columnLength + 100, ]} rotation={[0, -Math.PI / 2, 0]}> @@ -656,8 +626,7 @@ export const Bot = (props: FarmbotModelProps) => { ", () => { expect(container).not.toContainHTML("toolbay3"); }); + it("uses mirrored xy position for tool slots", () => { + const p = fakeProps(); + p.config.mirrorX = true; + p.config.mirrorY = true; + p.config.botSizeX = 1000; + p.config.botSizeY = 500; + const tool = fakeTool(); + tool.body.name = "soil sensor"; + tool.body.id = 2; + const toolSlot = fakeToolSlot(); + toolSlot.body.x = 100; + toolSlot.body.y = 200; + toolSlot.body.id = 1; + toolSlot.body.tool_id = tool.body.id; + p.toolSlots = [{ toolSlot, tool }]; + const { container } = render(); + expect(container).toContainHTML("position=\"1265,460,391\""); + }); + + it("flips rendered pullout direction for mirrored axis", () => { + const p = fakeProps(); + p.config.mirrorX = true; + const tool = fakeTool(); + tool.body.name = "soil sensor"; + tool.body.id = 2; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 1; + toolSlot.body.tool_id = tool.body.id; + toolSlot.body.pullout_direction = ToolPulloutDirection.POSITIVE_X; + p.toolSlots = [{ toolSlot, tool }]; + const { container } = render(); + expect(container).toContainHTML(`rotation="0,0,${Math.PI / 2}"`); + }); + + it("uses mirrored bot x for gantry-mounted tools when mirrorX is active", () => { + const p = fakeProps(); + p.config.mirrorX = true; + p.configPosition.x = p.config.botSizeX - p.configPosition.x; + const tool = fakeTool(); + tool.body.name = "soil sensor"; + tool.body.id = 2; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 1; + toolSlot.body.tool_id = tool.body.id; + toolSlot.body.gantry_mounted = true; + p.toolSlots = [{ toolSlot, tool }]; + const { container } = render(); + expect(container).toContainHTML("position=\"1065,-680,391\""); + }); + + it("doesn't mirror gantry-mounted tool y when mirrorY is active", () => { + const p = fakeProps(); + p.config.mirrorY = true; + p.configPosition.y = p.config.botSizeY - p.configPosition.y; + const tool = fakeTool(); + tool.body.name = "soil sensor"; + tool.body.id = 2; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 1; + toolSlot.body.tool_id = tool.body.id; + toolSlot.body.gantry_mounted = true; + p.toolSlots = [{ toolSlot, tool }]; + const { container } = render(); + expect(container).toContainHTML("position=\"-1055,-680,391\""); + }); + it("renders vacuum animation when not in toolbay and vacuum", () => { const p = fakeProps(); p.config.vacuum = true; diff --git a/frontend/three_d_garden/bot/components/bounds.tsx b/frontend/three_d_garden/bot/components/bounds.tsx index 64d9262d2d..0521e868a7 100644 --- a/frontend/three_d_garden/bot/components/bounds.tsx +++ b/frontend/three_d_garden/bot/components/bounds.tsx @@ -3,6 +3,7 @@ import { Config, PositionConfig } from "../../config"; import { Box, Edges } from "@react-three/drei"; import { Group, MeshBasicMaterial } from "../../components"; import { + get3DPositionNoMirrorFunc, threeSpace, zero as zeroFunc, zDir as zDirFunc, @@ -19,11 +20,12 @@ export const Bounds = (props: BoundsProps) => { const { bedLengthOuter, bedWidthOuter, zAxisLength, columnLength, beamLength, bounds, - bedXOffset, bedYOffset, botSizeX, botSizeY, botSizeZ, + bedYOffset, botSizeX, botSizeY, botSizeZ, } = props.config; const { x, y, z } = props.configPosition; const zDir = zDirFunc(props.config); const zero = zeroFunc(props.config); + const get3DPosition = get3DPositionNoMirrorFunc(props.config); return { diff --git a/frontend/three_d_garden/bot/components/cable_carriers.tsx b/frontend/three_d_garden/bot/components/cable_carriers.tsx index 7ee38be500..f7639b5264 100644 --- a/frontend/three_d_garden/bot/components/cable_carriers.tsx +++ b/frontend/three_d_garden/bot/components/cable_carriers.tsx @@ -3,7 +3,7 @@ import * as THREE from "three"; import { Extrude, useGLTF } from "@react-three/drei"; import { Shape } from "three"; import { - threeSpace, + get3DPositionNoMirrorFunc, zDir as zDirFunc, zZero as zZeroFunc, } from "../../helpers"; @@ -62,11 +62,15 @@ interface CableCarrierXProps { export const CableCarrierX = (props: CableCarrierXProps) => { const { - bedHeight, cableCarriers, botSizeX, bedLengthOuter, bedXOffset, - tracks, bedWidthOuter + bedHeight, cableCarriers, botSizeX, tracks, bedYOffset, } = props.config; const { x } = props.configPosition; const bedCCSupportHeight = Math.min(150, bedHeight / 2); + const get3DPosition = get3DPositionNoMirrorFunc(props.config); + const position = get3DPosition({ + x: botSizeX / 2, + y: (tracks ? 0 : extrusionWidth) - 15 - bedYOffset, + }); return { { steps: 1, depth: 22, bevelEnabled: false }, ]} position={[ - threeSpace(botSizeX / 2, bedLengthOuter) + bedXOffset, - threeSpace((tracks ? 0 : extrusionWidth) - 15, bedWidthOuter), + position.x, + position.y, -40, ]} rotation={[-Math.PI / 2, -Math.PI, 0 * Math.PI]}> @@ -93,10 +97,10 @@ interface CableCarrierYProps { export const CableCarrierY = (props: CableCarrierYProps) => { const { - columnLength, cableCarriers, botSizeY, bedLengthOuter, bedYOffset, - bedXOffset, bedWidthOuter, kitVersion, + columnLength, cableCarriers, botSizeY, kitVersion, } = props.config; const { x, y } = props.configPosition; + const get3DPosition = get3DPositionNoMirrorFunc(props.config); const ccDepth = (kitVersion: string) => { switch (kitVersion) { case "v1.7": @@ -106,17 +110,17 @@ export const CableCarrierY = (props: CableCarrierYProps) => { return 40; } }; + const getPosition = (): [number, number, number] => { + const position = get3DPosition({ x: x - 28, y: 20 }); + return [position.x, position.y, columnLength + 150]; + }; return ; @@ -129,12 +133,13 @@ interface CableCarrierZProps { export const CableCarrierZ = (props: CableCarrierZProps) => { const { - cableCarriers, botSizeZ, zGantryOffset, bedLengthOuter, bedYOffset, - bedXOffset, bedWidthOuter, + cableCarriers, botSizeZ, zGantryOffset, } = props.config; const { x, y, z } = props.configPosition; const zZero = zZeroFunc(props.config); const zDir = zDirFunc(props.config); + const get3DPosition = get3DPositionNoMirrorFunc(props.config); + const position = get3DPosition({ x: x - 41, y: y - 25 }); return { { steps: 1, depth: 60, bevelEnabled: false }, ]} position={[ - threeSpace(x - 41, bedLengthOuter) + bedXOffset, - threeSpace(y - 25, bedWidthOuter) + bedYOffset, + position.x, + position.y, zZero - zDir * z + 125, ]} rotation={[Math.PI / 2, Math.PI, Math.PI / 2]}> @@ -159,12 +164,12 @@ export interface CableCarrierSupportVerticalProps { export const CableCarrierSupportVertical = (props: CableCarrierSupportVerticalProps) => { const { - bedLengthOuter, bedYOffset, bedXOffset, bedWidthOuter, zAxisLength, - kitVersion, + zAxisLength, kitVersion, } = props.config; const { x, y, z } = props.configPosition; const zZero = zZeroFunc(props.config); const zDir = zDirFunc(props.config); + const get3DPosition = get3DPositionNoMirrorFunc(props.config); const ccSupportVertical = useGLTF(ASSETS.models.ccSupportVertical, LIB_DIR) as unknown as CCSupportVertical; const verticalInstances = React.useMemo(() => range((zAxisLength - 350) / 200), [zAxisLength]); @@ -173,9 +178,10 @@ export const CableCarrierSupportVertical = if (!verticalRef.current || verticalInstances.length === 0) { return; } const temp = new THREE.Object3D(); verticalInstances.forEach((i, index) => { + const position = get3DPosition({ x: x + 20, y: y + 55 }); temp.position.set( - threeSpace(x + 20, bedLengthOuter) + bedXOffset, - threeSpace(y + 55, bedWidthOuter) + bedYOffset, + position.x, + position.y, zZero - zDir * z + i * 200 + 125, ); temp.rotation.set(0, 0, Math.PI / 2); @@ -185,14 +191,11 @@ export const CableCarrierSupportVertical = }); verticalRef.current.instanceMatrix.needsUpdate = true; }, [ - bedLengthOuter, - bedXOffset, - bedYOffset, - bedWidthOuter, verticalInstances, x, y, z, + get3DPosition, zDir, zZero, ]); @@ -211,13 +214,13 @@ export const CableCarrierSupportVertical = } ; case "v1.8": + const getPosition = (): [number, number, number] => { + const position = get3DPosition({ x: x + 20, y: y + 35 }); + return [position.x, position.y, zZero - zDir * z + 125]; + }; return { @@ -256,10 +259,10 @@ export interface CableCarrierSupportHorizontalProps { export const CableCarrierSupportHorizontal = (props: CableCarrierSupportHorizontalProps) => { const { - bedLengthOuter, bedYOffset, bedXOffset, bedWidthOuter, botSizeY, - columnLength, kitVersion, + botSizeY, columnLength, kitVersion, } = props.config; const { x } = props.configPosition; + const get3DPosition = get3DPositionNoMirrorFunc(props.config); const ccSupportHorizontal = useGLTF(ASSETS.models.ccSupportHorizontal, LIB_DIR) as unknown as CCSupportHorizontal; const horizontalInstances = React.useMemo(() => range((botSizeY - 10) / 300), [botSizeY]); @@ -269,9 +272,10 @@ export const CableCarrierSupportHorizontal = if (!horizontalRef.current || horizontalInstances.length === 0) { return; } const temp = new THREE.Object3D(); horizontalInstances.forEach((i, index) => { + const position = get3DPosition({ x: x - 28, y: 50 + i * 300 }); temp.position.set( - threeSpace(x - 28, bedLengthOuter) + bedXOffset, - threeSpace(50 + i * 300, bedWidthOuter) + bedYOffset, + position.x, + position.y, columnLength + 60, ); temp.rotation.set(Math.PI / 2, 0, 0); @@ -281,13 +285,10 @@ export const CableCarrierSupportHorizontal = }); horizontalRef.current.instanceMatrix.needsUpdate = true; }, [ - bedLengthOuter, - bedXOffset, - bedYOffset, - bedWidthOuter, columnLength, horizontalInstances, x, + get3DPosition, ]); switch (kitVersion) { case "v1.7": @@ -307,8 +308,8 @@ export const CableCarrierSupportHorizontal = return { +export const getCameraViewPoints = (props: CameraViewProps) => { const { config, distanceToSoil, cameraMountPosition } = props; const cameraLensPosition = cameraMountPosition.clone() .add(cameraMountToLensOffset); @@ -59,9 +59,10 @@ export const CameraView = (props: CameraViewProps) => { const offset = toV([config.imgOffsetX, config.imgOffsetY, 0]); const rotation = config.imgRotation + extraRotation(config); - const rotateTop = (point: V3) => rotatePoint(point, rotation, topCenter); - const rotateBottom = (point: V3) => rotatePoint(point, rotation, bottomCenter) - .add(offset); + const rotateTop = (point: V3) => + rotatePoint(point, rotation, topCenter); + const rotateBottom = (point: V3) => + rotatePoint(point, rotation, bottomCenter).add(offset); const TUL = [-lensSize, -lensSize, 0]; const TUR = [-lensSize, lensSize, 0]; @@ -75,13 +76,20 @@ export const CameraView = (props: CameraViewProps) => { const BLR = [xCenter + xEdgeAtSoil, yCenter + yEdgeAtSoil, -distanceToSoil]; const BOTTOM = ([BUL, BUR, BLL, BLR] as V3[]).map(rotateBottom); - const VERTICES = [ - ...TOP, - ...BOTTOM, - ]; + return { + cameraLensPosition, + points: [ + ...TOP, + ...BOTTOM, + ], + }; +}; +export const CameraView = (props: CameraViewProps) => { + const { config } = props; + const { cameraLensPosition, points } = getCameraViewPoints(props); return config.cameraView - ? + ? : <>; }; diff --git a/frontend/three_d_garden/bot/components/electronics_box.tsx b/frontend/three_d_garden/bot/components/electronics_box.tsx index c7752dc66d..600d897443 100644 --- a/frontend/three_d_garden/bot/components/electronics_box.tsx +++ b/frontend/three_d_garden/bot/components/electronics_box.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as THREE from "three"; import { Cylinder, useGLTF } from "@react-three/drei"; -import { threeSpace } from "../../helpers"; +import { get3DPositionNoMirrorFunc } from "../../helpers"; import { Config, PositionConfig } from "../../config"; import type { GLTF } from "three-stdlib"; import { ASSETS, ElectronicsBoxMaterial, LIB_DIR, PartName } from "../../constants"; @@ -80,8 +80,13 @@ export interface ElectronicsBoxProps { } export const ElectronicsBox = (props: ElectronicsBoxProps) => { - const { bedXOffset, bedLengthOuter, bedWidthOuter, columnLength } = props.config; + const { bedYOffset, columnLength } = props.config; const { x } = props.configPosition; + const get3DPosition = get3DPositionNoMirrorFunc(props.config); + const position = get3DPosition({ + x: x - 62, + y: -20 - bedYOffset, + }); const box = useGLTF(ASSETS.models.box, LIB_DIR) as unknown as Box; const btn = useGLTF(ASSETS.models.btn, LIB_DIR) as unknown as Btn; @@ -91,8 +96,8 @@ export const ElectronicsBox = (props: ElectronicsBoxProps) => { return { const { - beamLength, columnLength, bedXOffset, bedLengthOuter, bedWidthOuter, + beamLength, columnLength, bedYOffset, bedWidthOuter, } = props.config; const { x } = props.configPosition; + const get3DPosition = get3DPositionNoMirrorFunc(props.config); + const position = get3DPosition({ + x: x - extrusionWidth - 8, + y: (bedWidthOuter + beamLength) / 2 - 50 - bedYOffset, + }); return diff --git a/frontend/three_d_garden/bot/components/solenoid.tsx b/frontend/three_d_garden/bot/components/solenoid.tsx index 12d19633bd..33520248e7 100644 --- a/frontend/three_d_garden/bot/components/solenoid.tsx +++ b/frontend/three_d_garden/bot/components/solenoid.tsx @@ -3,7 +3,9 @@ import * as THREE from "three"; import { Config, PositionConfig } from "../../config"; import { Group, Mesh } from "../../components"; import { WaterTube } from "./water_tube"; -import { easyCubicBezierCurve3, threeSpace, zDir as zDirFunc } from "../../helpers"; +import { + easyCubicBezierCurve3, get3DPositionNoMirrorFunc, zDir as zDirFunc, +} from "../../helpers"; import type { GLTF } from "three-stdlib"; import { useGLTF } from "@react-three/drei"; import { ASSETS, LIB_DIR, PartName } from "../../constants"; @@ -20,27 +22,31 @@ export interface SolenoidProps { export const Solenoid = (props: SolenoidProps) => { const { config } = props; - const { - bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, - columnLength, zGantryOffset, - } = config; + const { bedYOffset, columnLength, zGantryOffset } = config; const { x, y, z } = props.configPosition; const zDir = zDirFunc(config); + const get3DPosition = get3DPositionNoMirrorFunc(config); + const outerXY = (gardenX: number, outerY: number): [number, number] => { + const position = get3DPosition({ x: gardenX, y: outerY - bedYOffset }); + return [position.x, position.y]; + }; + const gardenXY = (gardenX: number, gardenY: number): [number, number] => { + const position = get3DPosition({ x: gardenX, y: gardenY }); + return [position.x, position.y]; + }; const solenoid = useGLTF(ASSETS.models.solenoid, LIB_DIR) as unknown as SolenoidPart; return { radialSegments={8} /> { waterFlow={config.waterFlow} tubePath={easyCubicBezierCurve3( [ - threeSpace(x - 104.25, bedLengthOuter) + bedXOffset, - threeSpace(20, bedWidthOuter), + ...outerXY(x - 104.25, 20), columnLength - 98, ], [0, 0, 100], [0, -75, 5], [ - threeSpace(x - 70, bedLengthOuter) + bedXOffset, - threeSpace(35, bedWidthOuter) + bedYOffset, + ...gardenXY(x - 70, 35), columnLength + 90, ], )} @@ -80,15 +83,13 @@ export const Solenoid = (props: SolenoidProps) => { waterFlow={config.waterFlow} tubePath={easyCubicBezierCurve3( [ - threeSpace(x - 70, bedLengthOuter) + bedXOffset, - threeSpace(y + 80, bedWidthOuter) + bedYOffset, + ...gardenXY(x - 70, y + 80), columnLength + 140, ], [0, -50, 0], [0, 0, -50], [ - threeSpace(x - 32.5, bedLengthOuter) + bedXOffset, - threeSpace(y - 10, bedWidthOuter) + bedYOffset, + ...gardenXY(x - 32.5, y - 10), columnLength + 180, ], )} @@ -99,15 +100,13 @@ export const Solenoid = (props: SolenoidProps) => { waterFlow={config.waterFlow} tubePath={easyCubicBezierCurve3( [ - threeSpace(x + 32.5, bedLengthOuter) + bedXOffset, - threeSpace(y - 10, bedWidthOuter) + bedYOffset, + ...gardenXY(x + 32.5, y - 10), columnLength - zDir * z - zGantryOffset + 200, ], [0, 0, -50], [0, 0, 50], [ - threeSpace(x + 2, bedLengthOuter) + bedXOffset, - threeSpace(y + 15, bedWidthOuter) + bedYOffset, + ...gardenXY(x + 2, y + 15), columnLength - zDir * z - zGantryOffset + 75, ], )} diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 725ebd6f0c..53e816bd82 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -1,7 +1,13 @@ import React from "react"; import * as THREE from "three"; import { useGLTF } from "@react-three/drei"; -import { threeSpace, zDir as zDirFunc, zZero as zZeroFunc } from "../../helpers"; +import { + get3DPositionFunc, + get3DPositionNoMirrorFunc, + threeSpace, + zDir as zDirFunc, + zZero as zZeroFunc, +} from "../../helpers"; import { Config, PositionConfig } from "../../config"; import type { GLTF } from "three-stdlib"; import { @@ -110,13 +116,13 @@ export const convertSlotsWithTools = export const Tools = (props: ToolsProps) => { const { - bedXOffset, bedYOffset, bedLengthOuter, bedWidthOuter, bedWallThickness, + bedLengthOuter, bedWidthOuter, bedWallThickness, } = props.config; - const botPosition = { - x: props.configPosition.x, - y: props.configPosition.y, - z: props.configPosition.z, - }; + const get3DPosition = get3DPositionFunc(props.config); + const get3DPositionNoMirror = get3DPositionNoMirrorFunc(props.config); + const mirroredBotX = props.config.mirrorX + ? props.config.botSizeX - props.configPosition.x + : props.configPosition.x; const mountedToolName = isUndefined(props.toolSlots) ? props.config.tool : reduceToolName(props.mountedToolName); @@ -145,6 +151,31 @@ export const Tools = (props: ToolsProps) => { const wateringNozzle = useGLTF( ASSETS.models.wateringNozzle, LIB_DIR) as unknown as WateringNozzle; + const displayedPulloutDirection = ( + toolPulloutDirection: ToolPulloutDirection, + ): ToolPulloutDirection => { + switch (toolPulloutDirection) { + case ToolPulloutDirection.POSITIVE_X: + return props.config.mirrorX + ? ToolPulloutDirection.NEGATIVE_X + : ToolPulloutDirection.POSITIVE_X; + case ToolPulloutDirection.NEGATIVE_X: + return props.config.mirrorX + ? ToolPulloutDirection.POSITIVE_X + : ToolPulloutDirection.NEGATIVE_X; + case ToolPulloutDirection.POSITIVE_Y: + return props.config.mirrorY + ? ToolPulloutDirection.NEGATIVE_Y + : ToolPulloutDirection.POSITIVE_Y; + case ToolPulloutDirection.NEGATIVE_Y: + return props.config.mirrorY + ? ToolPulloutDirection.POSITIVE_Y + : ToolPulloutDirection.NEGATIVE_Y; + default: + return toolPulloutDirection; + } + }; + const rotationFactor = (toolPulloutDirection: ToolPulloutDirection) => { switch (toolPulloutDirection) { case ToolPulloutDirection.POSITIVE_X: return 3; @@ -165,7 +196,8 @@ export const Tools = (props: ToolsProps) => { const ToolbaySlot = (slotProps: ToolbaySlotProps) => { const { position, children, toolPulloutDirection, mounted } = slotProps; - const rotationMultiplier = rotationFactor(toolPulloutDirection); + const rotationMultiplier = + rotationFactor(displayedPulloutDirection(toolPulloutDirection)); const navigate = useNavigate(); return { const Tool = (toolProps: ToolProps) => { const { toolPulloutDirection, inToolbay, id } = toolProps; const mounted = inToolbay && toolProps.toolName == mountedToolName; + const mirroredPosition = get3DPosition({ x: toolProps.x, y: toolProps.y }); + const noMirrorPosition = get3DPositionNoMirror({ + x: toolProps.x, + y: toolProps.y, + }); const position = { - x: threeSpace(toolProps.x, bedLengthOuter) + bedXOffset, - y: threeSpace(toolProps.y, bedWidthOuter) + bedYOffset, + x: inToolbay ? mirroredPosition.x : noMirrorPosition.x, + y: inToolbay && !toolProps.gantryMounted + ? mirroredPosition.y + : noMirrorPosition.y, z: zZero - zDir * toolProps.z + (inToolbay ? 0 : (utmHeight / 2 - 15)), }; const common: ToolbaySlotProps = { @@ -336,14 +375,14 @@ export const Tools = (props: ToolsProps) => { return {toolProps.firstTrough ? @@ -351,7 +390,7 @@ export const Tools = (props: ToolsProps) => { : { return @@ -399,7 +438,10 @@ export const Tools = (props: ToolsProps) => { {tools.map((tool, i) => )} ; }; diff --git a/frontend/three_d_garden/bot/components/watering_animations.tsx b/frontend/three_d_garden/bot/components/watering_animations.tsx index 113fc0b581..7ed48c0791 100644 --- a/frontend/three_d_garden/bot/components/watering_animations.tsx +++ b/frontend/three_d_garden/bot/components/watering_animations.tsx @@ -4,7 +4,9 @@ import { Group } from "../../components"; import { ASSETS } from "../../constants"; import { Cloud, Clouds } from "@react-three/drei"; import { WaterStream } from "./water_stream"; -import { easyCubicBezierCurve3, threeSpace, zDir, zZero } from "../../helpers"; +import { + easyCubicBezierCurve3, get3DPositionNoMirrorFunc, zDir, zZero, +} from "../../helpers"; import { Config, PositionConfig } from "../../config"; import { utmHeight } from "../bot"; @@ -17,8 +19,8 @@ export interface WateringAnimationsProps { export const WateringAnimations = (props: WateringAnimationsProps) => { const { waterFlow, getZ, config } = props; - const { bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset } = config; const { x, y, z } = props.configPosition; + const get3DPosition = get3DPositionNoMirrorFunc(config); const utmZ = -zDir(config) * z + utmHeight / 2 - 15; const nozzleToSoil = getZ(x, y) - utmZ; const [visible, setVisible] = React.useState(false); @@ -28,11 +30,12 @@ export const WateringAnimations = (props: WateringAnimationsProps) => { }, 50); return () => clearTimeout(timer); }, []); + const position = get3DPosition({ x, y }); return {range(16).map(i => { diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index 3a25002b26..a4e7552b9f 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -100,6 +100,8 @@ export interface Config { promoSpread: boolean; cameraView: boolean; lastImageCapture: number; + mirrorX: boolean; + mirrorY: boolean; } export interface PositionConfig { @@ -114,6 +116,7 @@ export enum SurfaceDebugOption { none, normals, height, + blank, } export const INITIAL: ConfigWithPosition = { @@ -221,6 +224,8 @@ export const INITIAL: ConfigWithPosition = { promoSpread: false, cameraView: false, lastImageCapture: 0, + mirrorX: false, + mirrorY: false, }; export const INITIAL_POSITION: PositionConfig = { @@ -256,7 +261,7 @@ export const BOOLEAN_KEYS = [ "animate", "animateSeasons", "negativeZ", "waterFlow", "exaggeratedZ", "showSoilPoints", "urlParamAutoAdd", "light", "vacuum", "north", "desk", "interpolationUseNearest", "promoSpread", - "cameraView", + "cameraView", "mirrorX", "mirrorY", ]; export const PRESETS: Record = { @@ -476,6 +481,7 @@ const OTHER_CONFIG_KEYS: (keyof Config)[] = [ "imgScale", "imgRotation", "imgOffsetX", "imgOffsetY", "imgOrigin", "imgCalZ", "imgCenterX", "imgCenterY", "interpolationStepSize", "interpolationUseNearest", "interpolationPower", "promoSpread", "cameraView", "lastImageCapture", + "mirrorX", "mirrorY", ]; export const modifyConfig = diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index f55559ba50..23ee3537db 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -342,6 +342,8 @@ export const PrivateOverlay = (props: OverlayProps) => { + + diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx index bece391809..cd565c5b16 100644 --- a/frontend/three_d_garden/garden/__tests__/images_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx @@ -1,7 +1,10 @@ let mockDemo = false; import React from "react"; import { render, screen } from "@testing-library/react"; -import { extraRotation, ImageTexture, ImageTextureProps } from "../images"; +import { + extraRotation, getImagePosition, getImageScale, getMirrorTextureProps, + ImageTexture, ImageTextureProps, +} from "../images"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { @@ -168,3 +171,38 @@ describe("extraRotation()", () => { expect(extraRotation(config)).toEqual(result); }); }); + +describe("getImageScale()", () => { + it("returns mirrored image scale", () => { + const config = clone(INITIAL); + config.mirrorX = true; + config.mirrorY = true; + expect(getImageScale(config, 100, 200)).toEqual([-100, -200, 1000]); + }); +}); + +describe("getMirrorTextureProps()", () => { + it("returns mirrored repeat and offset", () => { + const config = clone(INITIAL); + config.mirrorX = true; + config.mirrorY = true; + expect(getMirrorTextureProps(config)).toEqual({ + repeat: [-1, -1], + offset: [1, 1], + }); + }); +}); + +describe("getImagePosition()", () => { + it("pre-mirrors image position while keeping offsets", () => { + const config = clone(INITIAL); + config.botSizeX = 1000; + config.botSizeY = 500; + config.imgOffsetX = 10; + config.imgOffsetY = 20; + config.mirrorX = true; + config.mirrorY = true; + expect(getImagePosition(config, 100, 200, 30, 40, 5)) + .toEqual([940, 360, 5]); + }); +}); diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 755d7fcac3..4b86b01ba5 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -15,6 +15,7 @@ let mockRefImpl = (): MockRef => ({ instanceMatrix: { needsUpdate: false }, } }); +let allRefs: MockRef[] = []; import React from "react"; import { fireEvent, render } from "@testing-library/react"; @@ -40,8 +41,21 @@ describe("", () => { let reactUseRefSpy: jest.SpyInstance; beforeEach(() => { + mockRefImpl = () => ({ + current: { + scale: { set: jest.fn() }, + position: { z: 0 }, + setMatrixAt: jest.fn(), + instanceMatrix: { needsUpdate: false }, + } + }); + allRefs = []; reactUseRefSpy = jest.spyOn(React, "useRef") - .mockImplementation(() => mockRefImpl() as never); + .mockImplementation(() => { + const ref = mockRefImpl(); + allRefs.push(ref); + return ref as never; + }); location.pathname = Path.mock(Path.designer()); (useFrame as jest.Mock).mockClear(); (useTexture as unknown as jest.Mock).mockClear(); @@ -164,6 +178,33 @@ describe("", () => { expect(container).toBeTruthy(); }); + it("uses garden coordinates for getZ", () => { + const getZ = jest.fn(() => 0); + const p = fakeProps(); + p.getZ = getZ; + p.plants = [p.plants[0]]; + render(); + expect(getZ).toHaveBeenCalledWith(100, 200); + }); + + it("uses mirrored world placement for plant icons", () => { + const p = fakeProps(); + p.config.mirrorX = true; + p.config.mirrorY = true; + p.config.botSizeX = 1000; + p.config.botSizeY = 500; + p.plants = [p.plants[0]]; + render(); + (useFrame as jest.Mock).mock.calls.forEach(([frameFn]) => + frameFn({ camera: { quaternion: new Quaternion() } })); + const instancedRef = allRefs.find(ref => !!ref.current?.setMatrixAt); + expect(instancedRef?.current?.setMatrixAt).toHaveBeenCalled(); + const matrix = (instancedRef?.current?.setMatrixAt as jest.Mock) + .mock.calls[0][1]; + expect(matrix.elements[12]).toBeCloseTo(1260); + expect(matrix.elements[13]).toBeCloseTo(460); + }); + it("updates material brightness when changed", () => { const setScalar = jest.fn(); const instancedRef = { diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 32e4154ce7..93f4f910cc 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -121,6 +121,12 @@ describe("", () => { render(); expect(screen.getByText("Beet")).toBeInTheDocument(); }); + + it("keeps plant coordinates in garden space", () => { + const p = fakeProps(); + expect(p.plant.x).toEqual(100); + expect(p.plant.y).toEqual(200); + }); }); describe("", () => { @@ -335,6 +341,10 @@ describe("outOfBoundsShaderModification", () => { } as unknown as WebGLProgramParametersWithUniforms; outOfBoundsShaderModification(shader, false); expect(shader.fragmentShader).toContain("uInside"); + expect(shader.fragmentShader).toContain("uMirrorX"); + expect(shader.fragmentShader).toContain("uMirrorY"); + expect(shader.fragmentShader).toContain("p.x *= uMirrorX"); + expect(shader.fragmentShader).toContain("p.y *= uMirrorY"); expect(shader.vertexShader).not.toContain("vInstanceColor"); }); }); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 56afab7ae2..7326c664bc 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -30,6 +30,18 @@ describe("", () => { expect(container).toContainHTML("opacity=\"1\""); }); + it("renders mirrored position", () => { + const p = fakeProps(); + p.config.mirrorX = true; + p.config.mirrorY = true; + p.config.botSizeX = 1000; + p.config.botSizeY = 500; + p.point.body.x = 100; + p.point.body.y = 200; + const { container } = render(); + expect(container).toContainHTML("position=\"1260,460,400\""); + }); + it("renders: unsaved", () => { const p = fakeProps(); p.point.specialStatus = SpecialStatus.DIRTY; diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index cf2124a918..d321f52d97 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -33,6 +33,18 @@ describe("", () => { expect(container).toContainHTML("weed"); }); + it("renders mirrored position", () => { + const p = fakeProps(); + p.config.mirrorX = true; + p.config.mirrorY = true; + p.config.botSizeX = 1000; + p.config.botSizeY = 500; + p.weed.body.x = 100; + p.weed.body.y = 200; + const { container } = render(); + expect(container).toContainHTML("position=\"1260,460,400\""); + }); + it("navigates to weed info", () => { const p = fakeProps(); const dispatch = jest.fn(); diff --git a/frontend/three_d_garden/garden/grid.tsx b/frontend/three_d_garden/garden/grid.tsx index 70ff6988d3..a853aace2e 100644 --- a/frontend/three_d_garden/garden/grid.tsx +++ b/frontend/three_d_garden/garden/grid.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Config } from "../config"; import { Group, Primitive } from "../components"; import { - zero as zeroFunc, extents as extentsFunc, getGardenPositionFunc, + get3DPositionFunc, zero as zeroFunc, } from "../helpers"; import { chain, floor, range } from "lodash"; import { useThree } from "@react-three/fiber"; @@ -29,13 +29,15 @@ const lineSegmentsFor = ( config: Config, ) => { const positions: number[] = []; - const getGardenPosition = getGardenPositionFunc(config, false); + const get3DPosition = get3DPositionFunc(config); let prev: { x: number, y: number, z: number } | undefined; range(101).forEach(i => { const t = i / 100; - const x = start.x + (end.x - start.x) * t; - const y = start.y + (end.y - start.y) * t; - const gardenPosition = getGardenPosition({ x, y }); + const gardenPosition = { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + }; + const { x, y } = get3DPosition(gardenPosition); const z = getZ(gardenPosition.x, gardenPosition.y); if (prev) { positions.push(prev.x, prev.y, prev.z, x, y, z); @@ -93,7 +95,6 @@ export interface GridProps { export const Grid = (props: GridProps) => { const { config } = props; const zero = zeroFunc(config); - const extents = extentsFunc(config); const { outerPositions, innerPositions } = React.useMemo(() => { const result = { outerPositions: [] as number[], @@ -102,11 +103,11 @@ export const Grid = (props: GridProps) => { gridLineOffsets(config.botSizeX).forEach(xOffset => { const isOuterLine = xOffset === 0 || xOffset === config.botSizeX; const positions = lineSegmentsFor({ - x: zero.x + xOffset, - y: zero.y, + x: xOffset, + y: 0, }, { - x: zero.x + xOffset, - y: extents.y, + x: xOffset, + y: config.botSizeY, }, props.getZ, config); if (isOuterLine) { result.outerPositions.push(...positions); @@ -117,11 +118,11 @@ export const Grid = (props: GridProps) => { gridLineOffsets(config.botSizeY).forEach(yOffset => { const isOuterLine = yOffset === 0 || yOffset === config.botSizeY; const positions = lineSegmentsFor({ - x: zero.x, - y: zero.y + yOffset, + x: 0, + y: yOffset, }, { - x: extents.x, - y: zero.y + yOffset, + x: config.botSizeX, + y: yOffset, }, props.getZ, config); if (isOuterLine) { result.outerPositions.push(...positions); @@ -130,7 +131,7 @@ export const Grid = (props: GridProps) => { } }); return result; - }, [config, extents.x, extents.y, props.getZ, zero.x, zero.y]); + }, [config, props.getZ]); return diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index f44c358e2f..75f71155e5 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -1,6 +1,6 @@ import React from "react"; import { TaggedImage, TaggedSensor, TaggedSensorReading } from "farmbot"; -import { Config } from "../config"; +import { Config, SurfaceDebugOption } from "../config"; import { isNumber } from "lodash"; import { Decal, OrthographicCamera, Plane, RenderTexture, useTexture, @@ -48,6 +48,47 @@ const PlaneWrapper = (props: PlaneWrapperProps) => {props.children} ; +export const getImageScale = ( + config: Pick, + width: number, + height: number, +): [number, number, number] => + [ + config.mirrorY ? -width : width, + config.mirrorX ? -height : height, + 1000, + ]; + +export const getMirrorTextureProps = + (config: Pick) => ({ + repeat: [ + config.mirrorX ? -1 : 1, + config.mirrorY ? -1 : 1, + ] as [number, number], + offset: [ + config.mirrorX ? 1 : 0, + config.mirrorY ? 1 : 0, + ] as [number, number], + }); + +export const getImagePosition = ( + config: Pick, + x: number, + y: number, + xOffset: number, + yOffset: number, + z: number, +): [number, number, number] => { + const baseX = config.mirrorX ? config.botSizeX - x : x; + const baseY = config.mirrorY ? config.botSizeY - y : y; + return [ + baseX + config.imgOffsetX + xOffset, + baseY + config.imgOffsetY + yOffset, + z, + ]; +}; + export interface ImageTextureProps extends BaseProps { images?: TaggedImage[]; addPlantProps?: AddPlantProps; @@ -79,7 +120,10 @@ export const ImageTexture = (props: ImageTextureProps) => { const lastImageArray = filteredImages.filter(img => img.highlighted); const highlightActive = lastImageArray[0]?.highlighted; const commonProps = { width, height, bedWallThickness }; - return + const mirrorTextureProps = getMirrorTextureProps(props.config); + return { position={[bedXOffset, bedYOffset, 4000]} rotation={[0, 0, 0]} zoom={1} - scale={[1, 1, 1]} up={[0, 0, 1]} /> - + {highlightActive && @@ -169,7 +215,7 @@ const ImageWrapper = (props: ImageWrapperProps) => { if (!props.image.highlighted && !imageSizeCheck({ width: i.width, height: i.height }, { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; } - const scale: [number, number, number] = [width, height, 1000]; + const scale = getImageScale(config, width, height); const alreadyRotated = isRotated(props.image.body.meta.name); const initialRotation = alreadyRotated ? 0 : config.imgRotation; @@ -178,11 +224,8 @@ const ImageWrapper = (props: ImageWrapperProps) => { return { : [0, 0, 0]}> {props.readings.map(reading => { const tempPosition = React.useMemo(() => new Vector3(), []); const tempScale = React.useMemo(() => new Vector3(), []); const tempQuaternion = React.useMemo(() => new Quaternion(), []); + const get3DPosition = React.useMemo(() => get3DPositionFunc(config), [config]); const getPlantZ = React.useCallback((size: number, plant: ThreeDGardenPlant) => zZeroFunc(config) - + getZ(plant.x - config.bedXOffset, plant.y - config.bedYOffset) + + getZ(plant.x, plant.y) + size / 2, [config, getZ]); useFrame(state => { @@ -84,9 +85,10 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { const scale = (config.animateSeasons && startTimeRef) ? plant.size * getSizeAtTime(plant, config.plants, t) : plant.size; + const position = get3DPosition({ x: plant.x, y: plant.y }); tempPosition.set( - threeSpace(plant.x, config.bedLengthOuter), - threeSpace(plant.y, config.bedWidthOuter), + position.x, + position.y, getPlantZ(scale, plant), ); tempScale.set(scale, scale, scale); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 4583a58106..fa5269c72d 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -14,9 +14,9 @@ import { } from "three"; import { getGardenPositionFunc, - threeSpace, zZero, zZero as zZeroFunc, + get3DPositionFunc, } from "../helpers"; import { Text } from "../elements"; import { isUndefined } from "lodash"; @@ -58,16 +58,18 @@ export const ThreeDPlantLabel = (props: ThreeDPlantLabelProps) => { const alwaysShowLabels = config.labels && !config.labelsOnHover; // eslint-disable-next-line no-null/no-null const billboardRef = React.useRef(null); + const get3DPosition = React.useMemo(() => get3DPositionFunc(config), [config]); const getPlantZ = (size: number) => zZeroFunc(config) - + props.getZ(plant.x - config.bedXOffset, plant.y - config.bedYOffset) + + props.getZ(plant.x, plant.y) + size / 2; + const position = get3DPosition({ x: plant.x, y: plant.y }); return { const tempScale = React.useMemo(() => new Vector3(), []); const tempQuaternion = React.useMemo(() => new Quaternion(), []); const tempColor = React.useMemo(() => new Color(), []); + const get3DPosition = React.useMemo(() => get3DPositionFunc(config), [config]); // eslint-disable-next-line react-hooks/exhaustive-deps const boundsCenter = React.useMemo(getBoundsCenter(config), []); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -121,7 +124,7 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { plants.map((_, index) => index), [plants]); const getPlantZ = React.useCallback((size: number, plant: ThreeDGardenPlant) => zZeroFunc(config) - + getZ(plant.x - config.bedXOffset, plant.y - config.bedYOffset) + + getZ(plant.x, plant.y) + size / 2, [config, getZ]); const editPlantMode = Path.getSlug(Path.designer()) == "plants" && Path.lastChunkIsNum(); @@ -171,8 +174,8 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { y: currentPlant?.y || -10000, } : { - x: activePointer.x + config.bedXOffset, - y: activePointer.y + config.bedYOffset, + x: activePointer.x, + y: activePointer.y, }; const clickToAddMode = getMode() == Mode.clickToAdd; plants.forEach((plant, index) => { @@ -184,9 +187,10 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { const scale = (spreadVisible || !plant.id || editPlantMode) ? spreadRadii.inactive : 0; + const position = get3DPosition({ x: plant.x, y: plant.y }); tempPosition.set( - threeSpace(plant.x, config.bedLengthOuter), - threeSpace(plant.y, config.bedWidthOuter), + position.x, + position.y, getPlantZ(plant.size, plant), ); tempScale.set(scale, scale, scale); @@ -246,6 +250,8 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { shader.uniforms.uBoundsCenter = { value: boundsCenter }; shader.uniforms.uHalfSize = { value: halfSize }; shader.uniforms.uOutside = { value: new Color("red") }; + shader.uniforms.uMirrorX = { value: config.mirrorX ? -1 : 1 }; + shader.uniforms.uMirrorY = { value: config.mirrorY ? -1 : 1 }; outOfBoundsShaderModification(shader, true); }} depthWrite={false} /> @@ -283,11 +289,15 @@ export const outOfBoundsShaderModification = ? `uniform vec3 uBoundsCenter; uniform vec3 uHalfSize; uniform vec3 uOutside; + uniform float uMirrorX; + uniform float uMirrorY; varying vec3 vInstanceColor;` : `uniform vec3 uBoundsCenter; uniform vec3 uHalfSize; uniform vec3 uInside; - uniform vec3 uOutside;`; + uniform vec3 uOutside; + uniform float uMirrorX; + uniform float uMirrorY;`; const insideColor = useInstanceColor ? "vInstanceColor" : "uInside"; shader.vertexShader = shader.vertexShader.replace( "#include ", @@ -308,6 +318,8 @@ export const outOfBoundsShaderModification = "#include ", `#include vec3 p = vWorldPosition - uBoundsCenter; + p.x *= uMirrorX; + p.y *= uMirrorY; bool inside = p.x > -uHalfSize.x && abs(p.y) <= uHalfSize.y && diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index 012e459fed..d60892698d 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -4,7 +4,7 @@ import { Config } from "../config"; import { Group, MeshPhongMaterial } from "../components"; import { Cylinder, Sphere, Torus } from "@react-three/drei"; import { DoubleSide } from "three"; -import { zero as zeroFunc, threeSpace } from "../helpers"; +import { getWorldPositionFunc } from "../helpers"; import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; import { isUndefined, round } from "lodash"; @@ -109,16 +109,13 @@ const PointBase = (props: PointBaseProps) => { const { pointName, position, onClick, color, alpha, config, radius, torusRef, } = props; + const getWorldPosition = getWorldPositionFunc(config); return diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index c346b91f91..fc8bc6b67a 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -4,7 +4,7 @@ import { Config } from "../config"; import { ASSETS, HOVER_OBJECT_MODES, RenderOrder } from "../constants"; import { Group, MeshPhongMaterial } from "../components"; import { Image, Billboard, Sphere } from "@react-three/drei"; -import { zero as zeroFunc, threeSpace } from "../helpers"; +import { getWorldPositionFunc } from "../helpers"; import { useNavigate } from "react-router"; import { Path } from "../../internal_urls"; import { isUndefined } from "lodash"; @@ -63,16 +63,13 @@ export const WeedBase = (props: WeedBaseProps) => { pointName, position, onClick, color, radius, alpha, config, radiusRef, billboardRef, imageRef, } = props; + const getWorldPosition = getWorldPositionFunc(config); const weedSize = radius == 0 ? 50 : radius; const iconSize = weedSize * WEED_IMG_SIZE_FRACTION; return { const { sortType, groupPoints, config, getZ, tryGroupSortType } = props; const sortedPoints = sortGroupBy(tryGroupSortType || sortType, groupPoints); + const getWorldPosition = getWorldPositionFunc(config); const positions: [number, number, number][] = sortedPoints .map(p => { - const x = threeSpace(p.body.x, config.bedLengthOuter) + config.bedXOffset; - const y = threeSpace(p.body.y, config.bedWidthOuter) + config.bedYOffset; - const zZero = zZeroFunc(config); if (p.body.pointer_type == "ToolSlot") { - return [x, y, zZero + p.body.z + 25]; + return getWorldPosition({ x: p.body.x, y: p.body.y, z: p.body.z + 25 }); } if (p.body.pointer_type == "GenericPointer") { - return [x, y, zZero + getZ(p.body.x, p.body.y) + 75]; + return getWorldPosition({ + x: p.body.x, + y: p.body.y, + z: getZ(p.body.x, p.body.y) + 75, + }); } - return [x, y, zZero + getZ(p.body.x, p.body.y) + p.body.radius + 10]; + return getWorldPosition({ + x: p.body.x, + y: p.body.y, + z: getZ(p.body.x, p.body.y) + p.body.radius + 10, + }); }); return (threeDPosition: XY): XY => { const { bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset } = config; + const unmirroredPosition = { + x: config.mirrorX ? -threeDPosition.x : threeDPosition.x, + y: config.mirrorY ? -threeDPosition.y : threeDPosition.y, + }; const position = { - x: threeSpace(threeDPosition.x, -bedLengthOuter) - bedXOffset, - y: threeSpace(threeDPosition.y, -bedWidthOuter) - bedYOffset, + x: threeSpace(unmirroredPosition.x, -bedLengthOuter) - bedXOffset, + y: threeSpace(unmirroredPosition.y, -bedWidthOuter) - bedYOffset, }; return snap ? { x: round(position.x), y: round(position.y) } @@ -71,6 +75,15 @@ export const getGardenPositionFunc = (config: Config, snap = true) => }; export const get3DPositionFunc = (config: Config) => + (gardenPosition: XY): XY => { + const position = get3DPositionNoMirrorFunc(config)(gardenPosition); + return { + x: config.mirrorX ? -position.x : position.x, + y: config.mirrorY ? -position.y : position.y, + }; + }; + +export const get3DPositionNoMirrorFunc = (config: Config) => (gardenPosition: XY): XY => { const { bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset } = config; return { @@ -78,3 +91,15 @@ export const get3DPositionFunc = (config: Config) => y: threeSpace(gardenPosition.y + bedYOffset, bedWidthOuter), }; }; + +type XYZ = Record<"x" | "y" | "z", number>; + +export const getWorldPositionFunc = (config: Config) => + (gardenPosition: XYZ): [number, number, number] => { + const position = get3DPositionFunc(config)(gardenPosition); + return [ + position.x, + position.y, + zZero(config) + gardenPosition.z, + ]; + }; diff --git a/frontend/three_d_garden/triangles.ts b/frontend/three_d_garden/triangles.ts index f0eea32f92..6a1298cefb 100644 --- a/frontend/three_d_garden/triangles.ts +++ b/frontend/three_d_garden/triangles.ts @@ -23,7 +23,7 @@ export const filterMoisturePoints = (props: FilterMoisturePointsProps) => { .filter(p => !isUndefined(p.body.x) && !isUndefined(p.body.y)) - .map(p => [p.body.x, p.body.y, p.body.value]) as [number, number, number][]; + .map(p => [p.body.x, p.body.y, p.body.value]); const params = boundaryPoints(props.config); const outerPoints = [ { x: params.outer.x.min, y: params.outer.y.min }, diff --git a/frontend/three_d_garden/visualization.tsx b/frontend/three_d_garden/visualization.tsx index e65da854ce..379d427661 100644 --- a/frontend/three_d_garden/visualization.tsx +++ b/frontend/three_d_garden/visualization.tsx @@ -4,7 +4,7 @@ import { collectDemoSequenceActions } from "../demo/lua_runner"; import { store } from "../redux/store"; import { findSequence } from "../resources/selectors_by_kind"; import { expandActions } from "../demo/lua_runner/actions"; -import { threeSpace, zZero as zZeroFunc } from "./helpers"; +import { getWorldPositionFunc } from "./helpers"; import { Config, PositionConfig } from "./config"; export interface VisualizationProps { @@ -15,9 +15,8 @@ export interface VisualizationProps { export const Visualization = (props: VisualizationProps) => { const { visualizedSequenceUUID, config } = props; - const { bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset } = config; const { x, y, z } = props.configPosition; - const zZero = zZeroFunc(config); + const getWorldPosition = getWorldPositionFunc(config); const visualizationPoints = React.useMemo(() => { if (!visualizedSequenceUUID) { return []; } const resources = store.getState().resources.index; @@ -26,20 +25,23 @@ export const Visualization = (props: VisualizationProps) => { const stashedPos = { x, y, z }; const actions = collectDemoSequenceActions(0, resources, sequence.body.id, []); - const points = [[stashedPos.x, stashedPos.y, stashedPos.z]] + const points = [[ + stashedPos.x + config.bedXOffset - config.bedLengthOuter / 2, + stashedPos.y + config.bedYOffset - config.bedWidthOuter / 2, + z + config.columnLength + 40 - config.zGantryOffset, + ] as [number, number, number]] .concat(expandActions(actions, [], stashedPos) .filter(action => action.type == "expanded_move_absolute") .map(action => action.args as [number, number, number])) - .map(coordinate => [ - threeSpace(coordinate[0], bedLengthOuter) + bedXOffset, - threeSpace(coordinate[1], bedWidthOuter) + bedYOffset, - zZero + coordinate[2], - ] as [number, number, number]); + .map(coordinate => getWorldPosition({ + x: coordinate[0], + y: coordinate[1], + z: coordinate[2], + })); return points; // eslint-disable-next-line react-hooks/exhaustive-deps }, [visualizedSequenceUUID, - bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, - zZero]); + config, getWorldPosition, x, y, z]); return visualizationPoints.length > 0 && [ - { - label: "What you can grow", - info: { - description:
-

- FarmBot is well suited to growing a polycrop of many common - garden veggies at the same time. Crops we've had success with include - Bok Choy, Lettuces, Radish, Beets, Chard, Arugula, Broccoli, and much - more. +export const FOCI = (config: Config, configPosition: PositionConfig): Focus[] => { + const get3DPosition = get3DPositionNoMirrorFunc(config); + const utmPosition = get3DPosition({ + x: configPosition.x, + y: configPosition.y + 150, + }); + const electronicsPosition = get3DPosition({ + x: configPosition.x - 50, + y: -200 - config.bedYOffset, + }); + return [ + { + label: "What you can grow", + info: { + description:

+ FarmBot is well suited to growing a polycrop of many common + garden veggies at the same time. Crops we've had success with include + Bok Choy, Lettuces, Radish, Beets, Chard, Arugula, Broccoli, and much + more. +

+

+ By placing vining and other indeterminate crops near the ends + of the bed and training them outwards, you can easily double or + triple the area your plants can utilize while still being maintained + by the FarmBot.

- By placing vining and other indeterminate crops near the ends - of the bed and training them outwards, you can easily double or - triple the area your plants can utilize while still being maintained - by the FarmBot. -

-
, - position: [ - 0, - config.bedWidthOuter * .8, - 300, - ], - scale: config.sizePreset == "Genesis XL" ? 6000 : 3000, - }, - position: [ - threeSpace(config.bedLengthOuter / 2, config.bedLengthOuter), - threeSpace(config.bedWidthOuter / 2, config.bedWidthOuter), - 150, - ], - camera: { - narrow: { +
, position: [ 0, - -1000, - config.sizePreset == "Genesis XL" ? 16000 : 8000, - ], - target: [ - 0, - -1000, - 0, + config.bedWidthOuter * .8, + 300, ], + scale: config.sizePreset == "Genesis XL" ? 6000 : 3000, }, - wide: { - position: [ - 0, - 0, - config.sizePreset == "Genesis XL" ? 10000 : 5000, - ], - target: [ - 0, - 0, - 0, - ], + position: [ + threeSpace(config.bedLengthOuter / 2, config.bedLengthOuter), + threeSpace(config.bedWidthOuter / 2, config.bedWidthOuter), + 150, + ], + camera: { + narrow: { + position: [ + 0, + -1000, + config.sizePreset == "Genesis XL" ? 16000 : 8000, + ], + target: [ + 0, + -1000, + 0, + ], + }, + wide: { + position: [ + 0, + 0, + config.sizePreset == "Genesis XL" ? 10000 : 5000, + ], + target: [ + 0, + 0, + 0, + ], + }, }, }, - }, - { - label: "Included tools", - info: { - description:
-

- FarmBot comes with four tools to cover the basics of food production: - the Seed Injector, Watering Nozzle, Soil Sensor, and Rotary Tool. -

-

- Also included are the Seed Bin, Seed Tray, Seed Troughs, and the Camera - which remains permanently mounted to the Z-axis. -

- -
, - position: [ - 0, - -300, - 175, - ], - scale: 550, - }, - position: [ - threeSpace(0, config.bedLengthOuter), - threeSpace(config.bedWidthOuter / 2, config.bedWidthOuter), - 100, - ], - camera: { - narrow: { + referrerPolicy="strict-origin-when-cross-origin" + allowFullScreen> + + , position: [ - 1100, - -1150, - 500, - ], - target: [ - 0, 0, - -325, + -300, + 175, ], + scale: 550, }, - wide: { - position: [ - 850, - -650, - 350, - ], - target: [ - 0, - 0, - 0, - ], + position: [ + threeSpace(0, config.bedLengthOuter), + threeSpace(config.bedWidthOuter / 2, config.bedWidthOuter), + 100, + ], + camera: { + narrow: { + position: [ + 1100, + -1150, + 500, + ], + target: [ + 0, + 0, + -325, + ], + }, + wide: { + position: [ + 850, + -650, + 350, + ], + target: [ + 0, + 0, + 0, + ], + }, }, }, - }, - { - label: "Universal Tool Mounting", - info: { - description:
-

The Universal Tool Mount (UTM) allows FarmBot to automatically - switch between a variety of lightweight - tools — whichever one is appropriate for the task at hand (seeding, - watering, weeding, etc). Using three neodymium ring magnets, tools - are magnetically held in place during operation, but can be - automatically dismounted in a toolbay when not in use.

+ { + label: "Universal Tool Mounting", + info: { + description:
+

The Universal Tool Mount (UTM) allows FarmBot to automatically + switch between a variety of lightweight + tools — whichever one is appropriate for the task at hand (seeding, + watering, weeding, etc). Using three neodymium ring magnets, tools + are magnetically held in place during operation, but can be + automatically dismounted in a toolbay when not in use.

-

Once a tool has been mounted, FarmBot can power it up and communicate - with it using the 12 gold-plated pogo pins inside the UTM. The stock - connections include ground, 5v, 24v, as well as analog and digital I/O. - Meanwhile, the remaining electrical connections are available for - custom tooling such as specialized sensors or low power motorized - implements. Additionally, the three liquid/gas ports provide water, - vacuum air, and an expansion port for custom applications.

+

Once a tool has been mounted, FarmBot can power it up and communicate + with it using the 12 gold-plated pogo pins inside the UTM. The stock + connections include ground, 5v, 24v, as well as analog and digital I/O. + Meanwhile, the remaining electrical connections are available for + custom tooling such as specialized sensors or low power motorized + implements. Additionally, the three liquid/gas ports provide water, + vacuum air, and an expansion port for custom applications.

-

Because FarmBot is 100% open-source, you can download our - CAD models to start designing your own compatible creations. - Tools can be 3D printed and wired up with common electrical hardware in - just an afternoon.

-
, - position: [ - 0, - 75, - 0, - ], - scale: 400, - }, - position: [ - threeSpace(configPosition.x, config.bedLengthOuter) + config.bedXOffset, - threeSpace(configPosition.y + 150, config.bedWidthOuter) + config.bedYOffset, - zZero(config) - zDir(config) * configPosition.z, - ], - camera: { - narrow: { +

Because FarmBot is 100% open-source, you can download our + CAD models to start designing your own compatible creations. + Tools can be 3D printed and wired up with common electrical hardware in + just an afternoon.

+
, position: [ - 500, - -300, - 225, - ], - target: [ 0, - -150, - -100, - ], - }, - wide: { - position: [ - 500, - -300, - 225, - ], - target: [ + 75, 0, - -75, - -25, ], + scale: 400, }, - }, - }, - { - label: "Electronics", - info: { - description:
-

- FarmBot is powered by the workhorses of the DIY movement: - the Raspberry Pi and a custom designed Arduino we call the - Farmduino. -

-

- This custom circuit board includes Trinamic TMC2130 stepper drivers - with built-in quiet mode, an STM32 co-processor for monitoring the - rotary encoders, five 24v peripheral outputs, and an H-bridge for - reversible DC motor control at the UTM. -

-
, position: [ - 0, - 200, - 200, + utmPosition.x, + utmPosition.y, + zZero(config) - zDir(config) * configPosition.z, ], - scale: 550, + camera: { + narrow: { + position: [ + 500, + -300, + 225, + ], + target: [ + 0, + -150, + -100, + ], + }, + wide: { + position: [ + 500, + -300, + 225, + ], + target: [ + 0, + -75, + -25, + ], + }, + }, }, - position: [ - threeSpace(configPosition.x, config.bedLengthOuter) + config.bedXOffset - 50, - threeSpace(-200, config.bedWidthOuter), - config.columnLength - 150, - ], - camera: { - narrow: { + { + label: "Electronics", + info: { + description:
+

+ FarmBot is powered by the workhorses of the DIY movement: + the Raspberry Pi and a custom designed Arduino we call the + Farmduino. +

+

+ This custom circuit board includes Trinamic TMC2130 stepper drivers + with built-in quiet mode, an STM32 co-processor for monitoring the + rotary encoders, five 24v peripheral outputs, and an H-bridge for + reversible DC motor control at the UTM. +

+
, position: [ - -200, - -550, - 400, - ], - target: [ 0, - 100, - -150, + 200, + 200, ], + scale: 550, }, - wide: { - position: [ - -200, - -550, - 400, - ], - target: [ - 0, - 100, - 100, - ], + position: [electronicsPosition.x, electronicsPosition.y, config.columnLength - 150], + camera: { + narrow: { + position: [ + -200, + -550, + 400, + ], + target: [ + 0, + 100, + -150, + ], + }, + wide: { + position: [ + -200, + -550, + 400, + ], + target: [ + 0, + 100, + 100, + ], + }, }, }, - }, - { - label: "What you need to provide", - info: { - description:
-

- FarmBot must be plugged into a standard 100-265V AC outlet. - The power cable comes with a standard US 3-prong plug (NEMA 5-15P), which - can be used with a plug adapter for installations outside the US. -

-

- FarmBot can optionally be powered by - a solar - system with the appropriate battery and inverter. These - components may be purchased from a 3rd party. -

-

- FarmBot's water system has a 3/4″ female Garden Hose Thread (GHT) - connection, meaning you can take a standard US garden hose and - screw it into your FarmBot. You will need to provide a hose of - the appropriate length. -

-

- FarmBot requires an internet connection and supports both WiFi - and Ethernet. You may need to reposition your WiFi router or - install a repeater to ensure a reliable connection. -

-
, - position: [ - 300, - -300, - 0, - ], - scale: 1000, - }, - position: [ - threeSpace(config.bedLengthOuter + 700, config.bedLengthOuter), - threeSpace(config.legSize / 2, config.bedWidthOuter), - 250 - config.bedZOffset - config.bedHeight, - ], - camera: { - narrow: { - position: [ - -1200, - -1000, - 450, - ], - target: [ - -150, - -150, - -150, - ], - }, - wide: { + { + label: "What you need to provide", + info: { + description:
+

+ FarmBot must be plugged into a standard 100-265V AC outlet. + The power cable comes with a standard US 3-prong plug (NEMA 5-15P), which + can be used with a plug adapter for installations outside the US. +

+

+ FarmBot can optionally be powered by + a solar + system with the appropriate battery and inverter. These + components may be purchased from a 3rd party. +

+

+ FarmBot's water system has a 3/4″ female Garden Hose Thread (GHT) + connection, meaning you can take a standard US garden hose and + screw it into your FarmBot. You will need to provide a hose of + the appropriate length. +

+

+ FarmBot requires an internet connection and supports both WiFi + and Ethernet. You may need to reposition your WiFi router or + install a repeater to ensure a reliable connection. +

+
, position: [ - -1000, - -800, - 600, - ], - target: [ - 0, - -150, + 300, + -300, 0, ], + scale: 1000, }, - }, - }, - { - label: "Planter bed", - info: { - description:
-

- All FarmBots must be mounted to a raised - bed or similar infrastructure. Neither materials for the bed nor - soil are included with the kits because every installation will be - different, and shipping lumber and soil would be prohibitively expensive. -

-
, position: [ - 0, - -config.bedWidthOuter / 2, - config.sizePreset == "Genesis XL" ? 1000 : 800, + threeSpace(config.bedLengthOuter + 700, config.bedLengthOuter), + threeSpace(config.legSize / 2, config.bedWidthOuter), + 250 - config.bedZOffset - config.bedHeight, ], - scale: 1500, + camera: { + narrow: { + position: [ + -1200, + -1000, + 450, + ], + target: [ + -150, + -150, + -150, + ], + }, + wide: { + position: [ + -1000, + -800, + 600, + ], + target: [ + 0, + -150, + 0, + ], + }, + }, }, - position: [ - threeSpace(config.bedLengthOuter + 50, config.bedLengthOuter), - 0, - -config.bedHeight / 2, - ], - camera: { - narrow: { + { + label: "Planter bed", + info: { + description:
+

+ All FarmBots must be mounted to a raised + bed or similar infrastructure. Neither materials for the bed nor + soil are included with the kits because every installation will be + different, and shipping lumber and soil would be prohibitively expensive. +

+
, position: [ - config.sizePreset == "Genesis XL" ? 9000 : 4500, - config.sizePreset == "Genesis XL" - ? -7000 - : -2500 - config.bedWidthOuter / 2, - 1500, - ], - target: [ 0, -config.bedWidthOuter / 2, - 0, + config.sizePreset == "Genesis XL" ? 1000 : 800, ], + scale: 1500, }, - wide: { - position: [ - 2000, - config.sizePreset == "Genesis XL" ? -3000 : -2000, - 800, - ], - target: [ - 0, - -config.bedWidthOuter / 2, - 500 - config.bedZOffset / 2, - ], + position: [ + threeSpace(config.bedLengthOuter + 50, config.bedLengthOuter), + 0, + -config.bedHeight / 2, + ], + camera: { + narrow: { + position: [ + config.sizePreset == "Genesis XL" ? 9000 : 4500, + config.sizePreset == "Genesis XL" + ? -7000 + : -2500 - config.bedWidthOuter / 2, + 1500, + ], + target: [ + 0, + -config.bedWidthOuter / 2, + 0, + ], + }, + wide: { + position: [ + 2000, + config.sizePreset == "Genesis XL" ? -3000 : -2000, + 800, + ], + target: [ + 0, + -config.bedWidthOuter / 2, + 500 - config.bedZOffset / 2, + ], + }, }, }, - }, -]; + ]; +}; export const getFocus = ( config: Config, From 83b2592de16be65dbcc45748b53fdb6c1ccdff6a Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 17 Apr 2026 14:54:59 -0700 Subject: [PATCH 04/11] fix 3D mirroring issues --- .../__tests__/three_d_garden_map_test.tsx | 8 +++++--- .../farm_designer/map/legend/garden_map_legend.tsx | 14 ++++++++------ frontend/farm_designer/three_d_garden_map.tsx | 9 ++------- .../garden/__tests__/images_test.tsx | 11 +---------- frontend/three_d_garden/garden/images.tsx | 14 +------------- 5 files changed, 17 insertions(+), 39 deletions(-) diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index 67f8c99d1b..098e0c1cc5 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -133,11 +133,13 @@ describe("", () => { expectedConfig.imgScale = 0.6; expectedConfig.imgCenterX = 0; expectedConfig.imgCenterY = 0; + expectedConfig.mirrorX = true; + expectedConfig.mirrorY = true; const call = lastThreeDGardenProps(); expect(call).toEqual(expect.objectContaining({ config: expectedConfig, - configPosition: { x: 1, y: 2, z: 3 }, + configPosition: { x: 2999, y: 1498, z: 3 }, threeDPlants: [{ id: expect.any(Number), icon: expect.any(String), @@ -161,7 +163,7 @@ describe("", () => { render(); const call = lastThreeDGardenProps(); expect(call).toEqual(expect.objectContaining({ - configPosition: { x: 0, y: 0, z: 0 }, + configPosition: { x: 3000, y: 1500, z: 0 }, threeDPlants: [], addPlantProps: expect.any(Object), ...EMPTY_PROPS, @@ -177,7 +179,7 @@ describe("", () => { const call = lastThreeDGardenProps(); expect(call).toEqual(expect.objectContaining({ config: expect.objectContaining({ negativeZ: true }), - configPosition: { x: 0, y: 0, z: -100 }, + configPosition: { x: 3000, y: 1500, z: -100 }, threeDPlants: [], addPlantProps: expect.any(Object), ...EMPTY_PROPS, diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index bc3b6f013d..9c461aedaf 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -215,8 +215,9 @@ const LayerToggles = (props: LayerTogglesProps) => { ; }; -export const MapSettingsContent = (props: SettingsSubMenuProps) => -
+export const MapSettingsContent = (props: SettingsSubMenuProps) => { + const is3D = props.getConfigValue(BooleanSetting.three_d_garden); + return
helpText={Content.MAP_SIZE}> - - } + {!is3D && - + }
; +}; const MapSettings = (props: SettingsSubMenuProps) =>
diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 6fade06eda..404e47dbdb 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -75,15 +75,10 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { config.negativeZ = props.negativeZ; config.exaggeratedZ = props.designer.threeDExaggeratedZ; - const quadrant = props.mapTransformProps.quadrant; - config.mirrorX = props.mapTransformProps.xySwap - ? [3, 4].includes(quadrant) - : [1, 4].includes(quadrant); - config.mirrorY = props.mapTransformProps.xySwap - ? [1, 4].includes(quadrant) - : [3, 4].includes(quadrant); const getValue = props.get3DConfigValue; + config.mirrorX = !!getValue("mirrorX"); + config.mirrorY = !!getValue("mirrorY"); config.bedXOffset = getValue("bedXOffset"); config.bedYOffset = getValue("bedYOffset"); config.bedZOffset = getValue("bedZOffset"); diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx index cd565c5b16..b09bb4a218 100644 --- a/frontend/three_d_garden/garden/__tests__/images_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx @@ -2,7 +2,7 @@ let mockDemo = false; import React from "react"; import { render, screen } from "@testing-library/react"; import { - extraRotation, getImagePosition, getImageScale, getMirrorTextureProps, + extraRotation, getImagePosition, getMirrorTextureProps, ImageTexture, ImageTextureProps, } from "../images"; import { INITIAL } from "../../config"; @@ -172,15 +172,6 @@ describe("extraRotation()", () => { }); }); -describe("getImageScale()", () => { - it("returns mirrored image scale", () => { - const config = clone(INITIAL); - config.mirrorX = true; - config.mirrorY = true; - expect(getImageScale(config, 100, 200)).toEqual([-100, -200, 1000]); - }); -}); - describe("getMirrorTextureProps()", () => { it("returns mirrored repeat and offset", () => { const config = clone(INITIAL); diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index 75f71155e5..3ee0b11341 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -48,17 +48,6 @@ const PlaneWrapper = (props: PlaneWrapperProps) => {props.children} ; -export const getImageScale = ( - config: Pick, - width: number, - height: number, -): [number, number, number] => - [ - config.mirrorY ? -width : width, - config.mirrorX ? -height : height, - 1000, - ]; - export const getMirrorTextureProps = (config: Pick) => ({ repeat: [ @@ -215,7 +204,6 @@ const ImageWrapper = (props: ImageWrapperProps) => { if (!props.image.highlighted && !imageSizeCheck({ width: i.width, height: i.height }, { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; } - const scale = getImageScale(config, width, height); const alreadyRotated = isRotated(props.image.body.meta.name); const initialRotation = alreadyRotated ? 0 : config.imgRotation; @@ -230,7 +218,7 @@ const ImageWrapper = (props: ImageWrapperProps) => { material-side={DoubleSide} depthTest={true} rotation={[0, 0, rotation]} - scale={scale} />; + scale={[width, height, 1000]} />; }; export const extraRotation = (config: Config) => { From 54db42e7804a776a6747f3a1c58a1759da7f355e Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 17 Apr 2026 14:55:15 -0700 Subject: [PATCH 05/11] add 3D camera selector --- ...d_new_3d_view_options_to_web_app_config.rb | 11 +++ .../__test_support__/fake_designer_state.ts | 3 +- frontend/constants.ts | 9 +++ .../farm_designer/__tests__/reducer_test.ts | 9 +++ .../__tests__/three_d_garden_map_test.tsx | 3 +- frontend/farm_designer/index.tsx | 2 + frontend/farm_designer/interfaces.ts | 3 +- frontend/farm_designer/map/interfaces.ts | 2 + .../__tests__/garden_map_legend_test.tsx | 53 +++++++++++- .../map/legend/garden_map_legend.tsx | 31 ++++++- frontend/farm_designer/reducer.ts | 7 +- frontend/farm_designer/three_d_garden_map.tsx | 11 ++- frontend/session_keys.ts | 6 +- frontend/settings/__tests__/index_test.tsx | 46 ++++++++--- frontend/settings/default_values.ts | 2 + frontend/settings/farm_designer_settings.tsx | 28 +++++++ .../__tests__/camera_selection_ui_test.tsx | 81 +++++++++++++++++++ .../three_d_garden/__tests__/camera_test.ts | 55 +++++++++++-- .../three_d_garden/__tests__/config_test.ts | 8 ++ .../__tests__/garden_model_test.tsx | 20 +++-- .../three_d_garden/__tests__/helpers_test.ts | 17 ++++ .../three_d_garden/__tests__/index_test.tsx | 13 +++ frontend/three_d_garden/camera.ts | 43 ++++++++-- .../three_d_garden/camera_selection_ui.tsx | 63 +++++++++++++++ frontend/three_d_garden/config.ts | 20 +++-- frontend/three_d_garden/config_overlays.tsx | 3 + frontend/three_d_garden/garden_model.tsx | 20 +++-- frontend/three_d_garden/helpers.ts | 12 +++ frontend/three_d_garden/index.tsx | 6 +- .../scenes/props/__tests__/people_test.tsx | 18 ++++- .../three_d_garden/scenes/props/people.tsx | 33 +++++--- 31 files changed, 573 insertions(+), 65 deletions(-) create mode 100644 db/migrate/20260417190743_add_new_3d_view_options_to_web_app_config.rb create mode 100644 frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx create mode 100644 frontend/three_d_garden/camera_selection_ui.tsx diff --git a/db/migrate/20260417190743_add_new_3d_view_options_to_web_app_config.rb b/db/migrate/20260417190743_add_new_3d_view_options_to_web_app_config.rb new file mode 100644 index 0000000000..4ccfc37cd1 --- /dev/null +++ b/db/migrate/20260417190743_add_new_3d_view_options_to_web_app_config.rb @@ -0,0 +1,11 @@ +class AddNew3dViewOptionsToWebAppConfig < ActiveRecord::Migration[8.1] + def up + add_column :web_app_configs, :top_down_view, :boolean, default: false + add_column :web_app_configs, :viewpoint_heading, :integer, default: 0 + end + + def down + remove_column :web_app_configs, :top_down_view + remove_column :web_app_configs, :viewpoint_heading + end +end diff --git a/frontend/__test_support__/fake_designer_state.ts b/frontend/__test_support__/fake_designer_state.ts index 3edbefd273..53f5c3f3ed 100644 --- a/frontend/__test_support__/fake_designer_state.ts +++ b/frontend/__test_support__/fake_designer_state.ts @@ -51,7 +51,8 @@ export const fakeDesignerState = (): DesignerState => ({ cropRadius: undefined, distanceIndicator: "", panelOpen: true, - threeDTopDownView: false, + threeDTopDownView: undefined, + threeDCameraSelection: false, threeDExaggeratedZ: false, threeDTime: undefined, }); diff --git a/frontend/constants.ts b/frontend/constants.ts index 3796bee82b..6c5855efde 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -1015,6 +1015,12 @@ export namespace Content { trim(`Select a map origin by clicking on one of the four quadrants to adjust the garden map to your viewing angle.`); + export const TOP_DOWN_VIEW = + trim(`Upon open, display the 3D garden map from a top-down perspective.`); + + export const VIEWPOINT_HEADING = + trim(`Heading of the camera when the 3D garden map is opened (in degrees).`); + export const CROP_MAP_IMAGES = trim(`Crop images displayed in the garden map to remove black borders from image rotation. Crop amount determined by CAMERA ROTATION value.`); @@ -2234,6 +2240,8 @@ export enum DeviceSetting { mapSize = `Map size`, rotateMap = `Rotate map`, mapOrigin = `Map origin`, + openInTopDownView = `Open in top-down view`, + cameraLocationUponOpen = `Camera location upon open`, cropMapImages = `Crop map images`, clipPhotosOutOfBounds = `Clip photos out of bounds`, cameraView = `Camera view`, @@ -2562,6 +2570,7 @@ export enum Actions { // 3D SET_DISTANCE_INDICATOR = "SET_DISTANCE_INDICATOR", TOGGLE_3D_TOP_DOWN_VIEW = "TOGGLE_3D_TOP_DOWN_VIEW", + TOGGLE_3D_CAMERA_SELECTION = "TOGGLE_3D_CAMERA_SELECTION", TOGGLE_3D_EXAGGERATED_Z = "TOGGLE_3D_EXAGGERATED_Z", SET_3D_TIME = "RESET_3D_TIME", diff --git a/frontend/farm_designer/__tests__/reducer_test.ts b/frontend/farm_designer/__tests__/reducer_test.ts index 9d449acf06..43dbcc1579 100644 --- a/frontend/farm_designer/__tests__/reducer_test.ts +++ b/frontend/farm_designer/__tests__/reducer_test.ts @@ -135,6 +135,15 @@ describe("designer reducer", () => { expect(newState.threeDTopDownView).toEqual(true); }); + it("sets camera", () => { + const action: ReduxAction = { + type: Actions.TOGGLE_3D_CAMERA_SELECTION, + payload: true, + }; + const newState = designer(oldState(), action); + expect(newState.threeDCameraSelection).toEqual(true); + }); + it("sets exaggerated z", () => { const action: ReduxAction = { type: Actions.TOGGLE_3D_EXAGGERATED_Z, diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index 098e0c1cc5..534e04d763 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -59,7 +59,7 @@ describe("", () => { designer: fakeDesignerState(), plants: [fakePlant()], dispatch: jest.fn(), - getWebAppConfigValue: jest.fn(), + getWebAppConfigValue: () => 0, curves: [], mapPoints: [], weeds: [], @@ -135,6 +135,7 @@ describe("", () => { expectedConfig.imgCenterY = 0; expectedConfig.mirrorX = true; expectedConfig.mirrorY = true; + expectedConfig.viewpointHeading = 0; const call = lastThreeDGardenProps(); expect(call).toEqual(expect.objectContaining({ diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index ea8cad8f6a..cea3dcfaf0 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -188,6 +188,7 @@ export class RawFarmDesigner showZones={show_zones} showSensorReadings={show_sensor_readings} showMoistureInterpolationMap={show_moisture_interpolation_map} + designer={this.props.designer} dispatch={this.props.dispatch} timeSettings={this.props.timeSettings} getConfigValue={this.props.getConfigValue} @@ -316,6 +317,7 @@ export class RawFarmDesigner dispatch={this.props.dispatch} device={this.props.device} designer={this.props.designer} + getConfigValue={this.props.getConfigValue} threeDGarden={threeDGarden} />
; } diff --git a/frontend/farm_designer/interfaces.ts b/frontend/farm_designer/interfaces.ts index 54ff2e0c34..e5b0a9dba4 100644 --- a/frontend/farm_designer/interfaces.ts +++ b/frontend/farm_designer/interfaces.ts @@ -179,7 +179,8 @@ export interface DesignerState { cropRadius: number | undefined; distanceIndicator: string; panelOpen: boolean; - threeDTopDownView: boolean; + threeDTopDownView: boolean | undefined; + threeDCameraSelection: boolean; threeDExaggeratedZ: boolean; threeDTime: string | undefined; } diff --git a/frontend/farm_designer/map/interfaces.ts b/frontend/farm_designer/map/interfaces.ts index b606eded99..ec528b425a 100644 --- a/frontend/farm_designer/map/interfaces.ts +++ b/frontend/farm_designer/map/interfaces.ts @@ -9,6 +9,7 @@ import type { } from "farmbot"; import type { State, BotOriginQuadrant, MountedToolInfo, CameraCalibrationData, + DesignerState, } from "../interfaces"; import type { BotPosition, BotLocationData, SourceFbosConfig, @@ -64,6 +65,7 @@ export interface GardenMapLegendProps { firmwareConfig: McuParams; botLocationData: BotLocationData; botSize: BotSize; + designer: DesignerState; } export type MapTransformProps = { diff --git a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx index c229ecd091..c9ca3b9000 100644 --- a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx +++ b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx @@ -9,7 +9,7 @@ import { ZoomControlsProps, } from "../garden_map_legend"; import { GardenMapLegendProps } from "../../interfaces"; -import { BooleanSetting } from "../../../../session_keys"; +import { BooleanSetting, NumericSetting } from "../../../../session_keys"; import * as zoom from "../../zoom"; import { fakeTimeSettings, @@ -21,6 +21,8 @@ import { import { fakeFirmwareConfig, } from "../../../../__test_support__/fake_state/resources"; +import { fakeDesignerState } from "../../../../__test_support__/fake_designer_state"; +import { Actions } from "../../../../constants"; let atMaxZoomSpy: jest.SpyInstance; let atMinZoomSpy: jest.SpyInstance; @@ -67,6 +69,7 @@ describe("", () => { firmwareConfig: fakeFirmwareConfig().body, botLocationData: fakeBotLocationData(), botSize: fakeBotSize(), + designer: fakeDesignerState(), }); it("renders", () => { @@ -137,6 +140,7 @@ const fakeProps = (): SettingsSubMenuProps => ({ dispatch: jest.fn(), getConfigValue: () => true, firmwareConfig: fakeFirmwareConfig().body, + designer: fakeDesignerState(), }); describe("", () => { @@ -185,4 +189,51 @@ describe("", () => { expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( BooleanSetting.dynamic_map, false); }); + + it("shows 2D-only controls", () => { + const p = fakeProps(); + p.getConfigValue = key => key != BooleanSetting.three_d_garden; + const { container } = render(); + expect(container.textContent).toContain("Rotate map"); + expect(container.textContent).toContain("Map origin"); + expect(container.textContent).not.toContain("Camera location upon open"); + }); + + it("shows 3D-only controls", () => { + const p = fakeProps(); + p.getConfigValue = key => key == BooleanSetting.three_d_garden; + const { container } = render(); + expect(container.textContent).toContain("Open in top-down view"); + expect(container.textContent).toContain("Camera location upon open"); + expect(container.textContent).toContain("Enable camera heading selection view"); + expect(container.textContent).not.toContain("Rotate map"); + }); + + it("changes viewpoint heading in 3D settings", () => { + const p = fakeProps(); + p.getConfigValue = key => { + if (key == BooleanSetting.three_d_garden) { return true; } + if (key == NumericSetting.viewpoint_heading) { return 0; } + return false; + }; + const { container } = render(); + const quadrants = container.querySelectorAll(".quadrant"); + fireEvent.click(quadrants[quadrants.length - 1]); + expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( + NumericSetting.viewpoint_heading, 270); + }); + + it("toggles camera selection view", () => { + const p = fakeProps(); + p.getConfigValue = key => key == BooleanSetting.three_d_garden; + const { container } = render(); + const toggleBtn = + container.querySelector("button[title='Enable camera heading selection view']"); + if (!toggleBtn) { throw new Error("Missing camera selection toggle"); } + fireEvent.click(toggleBtn); + expect(p.dispatch).toHaveBeenCalledWith({ + type: Actions.TOGGLE_3D_CAMERA_SELECTION, + payload: undefined, + }); + }); }); diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index 9c461aedaf..c3d1f8c6e7 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -13,7 +13,7 @@ import { import { BooleanSetting } from "../../../session_keys"; import { t } from "../../../i18next_wrapper"; import { SelectModeLink } from "../../../plants/select_plants"; -import { DeviceSetting, Content } from "../../../constants"; +import { DeviceSetting, Content, Actions } from "../../../constants"; import { Help, Popover, ToggleButton } from "../../../ui"; import { BooleanConfigKey as WebAppBooleanConfigKey, @@ -22,8 +22,9 @@ import { ZDisplay, ZDisplayToggle } from "./z_display"; import { getModifiedClassName } from "../../../settings/default_values"; import { Position } from "@blueprintjs/core"; import { MapSizeInputs } from "../../map_size_setting"; -import { OriginSelector } from "../../../settings/farm_designer_settings"; +import { HeadingSelector, OriginSelector } from "../../../settings/farm_designer_settings"; import { McuParams } from "farmbot"; +import { DesignerState } from "../../interfaces"; export interface ZoomControlsProps { zoom(value: number): () => void; @@ -89,6 +90,7 @@ export interface SettingsSubMenuProps { dispatch: Function; getConfigValue: GetWebAppConfigValue; firmwareConfig: McuParams; + designer: DesignerState; } export const PointsSubMenu = (props: SettingsSubMenuProps) => @@ -127,8 +129,8 @@ export const FarmbotSubMenu = (props: SettingsSubMenuProps) => interface LayerTogglesProps extends GardenMapLegendProps { } const LayerToggles = (props: LayerTogglesProps) => { - const { toggle, getConfigValue, dispatch, firmwareConfig } = props; - const subMenuProps = { dispatch, getConfigValue, firmwareConfig }; + const { toggle, getConfigValue, dispatch, firmwareConfig, designer } = props; + const subMenuProps = { dispatch, getConfigValue, firmwareConfig, designer }; const is3D = getConfigValue(BooleanSetting.three_d_garden); const only2DClass = is3D ? "disabled" : ""; return
@@ -236,6 +238,26 @@ export const MapSettingsContent = (props: SettingsSubMenuProps) => { helpText={Content.MAP_ORIGIN}> } + {is3D && } + {is3D && + + } + {is3D &&
+ + props.dispatch({ + type: Actions.TOGGLE_3D_CAMERA_SELECTION, + payload: undefined, + })} + toggleValue={props.designer.threeDCameraSelection} /> +
}
; }; @@ -272,6 +294,7 @@ export function GardenMapLegend(props: GardenMapLegendProps) { diff --git a/frontend/farm_designer/reducer.ts b/frontend/farm_designer/reducer.ts index ffb0d1c413..b3e1ceb29c 100644 --- a/frontend/farm_designer/reducer.ts +++ b/frontend/farm_designer/reducer.ts @@ -59,7 +59,8 @@ export const initialState: DesignerState = { cropRadius: undefined, distanceIndicator: "", panelOpen: true, - threeDTopDownView: false, + threeDTopDownView: undefined, + threeDCameraSelection: false, threeDExaggeratedZ: false, threeDTime: undefined, }; @@ -246,6 +247,10 @@ export const designer = generateReducer(initialState) s.threeDTopDownView = payload; return s; }) + .add(Actions.TOGGLE_3D_CAMERA_SELECTION, (s) => { + s.threeDCameraSelection = !s.threeDCameraSelection; + return s; + }) .add(Actions.TOGGLE_3D_EXAGGERATED_Z, (s, { payload }) => { s.threeDExaggeratedZ = payload; return s; diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 404e47dbdb..43fee1869d 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -13,7 +13,7 @@ import { } from "farmbot"; import { CameraCalibrationData, DesignerState } from "./interfaces"; import { GetWebAppConfigValue } from "../config_storage/actions"; -import { BooleanSetting } from "../session_keys"; +import { BooleanSetting, NumericSetting } from "../session_keys"; import { SlotWithTool } from "../resources/interfaces"; import { calcSunCoordinate, ThreeDGardenPlant } from "../three_d_garden/garden"; import { findCrop, findIcon } from "../crops/find"; @@ -25,6 +25,7 @@ import { get3DTime, latLng } from "../three_d_garden/time_travel"; import { parseCalibrationData } from "./map/layers/images/map_image"; import { fetchInterpolationOptions } from "./map/layers/points/interpolation_map"; import { unpackUUID } from "../util"; +import { isTopDown } from "../three_d_garden/helpers"; export interface ThreeDGardenMapProps { botSize: BotSize; @@ -180,10 +181,14 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { config.interpolationUseNearest = options.useNearest; config.interpolationPower = options.power; + config.topDown = isTopDown(props.designer, props.getWebAppConfigValue); config.zoom = true; config.pan = true; - config.rotate = !props.designer.threeDTopDownView; - config.perspective = !props.designer.threeDTopDownView; + config.rotate = !config.topDown; + config.perspective = !config.topDown; + config.viewpointHeading = + parseInt("" + props.getWebAppConfigValue(NumericSetting.viewpoint_heading)); + config.cameraSelectionView = props.designer.threeDCameraSelection; const lastCaptureTime = React.useMemo(() => { const localIds = props.logs diff --git a/frontend/session_keys.ts b/frontend/session_keys.ts index e87b666d11..f1b3cb2150 100644 --- a/frontend/session_keys.ts +++ b/frontend/session_keys.ts @@ -4,8 +4,8 @@ import { StringConfigKey as WebAppStringConfigKey, } from "farmbot/dist/resources/configs/web_app"; -type WebAppBooleanConfigKeyAll = WebAppBooleanConfigKey; -type WebAppNumberConfigKeyAll = WebAppNumberConfigKey; +type WebAppBooleanConfigKeyAll = WebAppBooleanConfigKey | "top_down_view"; +type WebAppNumberConfigKeyAll = WebAppNumberConfigKey | "viewpoint_heading"; type WebAppStringConfigKeyAll = WebAppStringConfigKey; type BooleanSettings = Record; @@ -50,6 +50,7 @@ export const BooleanSetting: BooleanSettings = { clip_image_layer: "clip_image_layer", highlight_modified_settings: "highlight_modified_settings", three_d_garden: "three_d_garden", + top_down_view: "top_down_view" as WebAppBooleanConfigKey, /** Sequence settings */ confirm_step_deletion: "confirm_step_deletion", @@ -96,6 +97,7 @@ export const NumericSetting: NumericSettings = { map_size_y: "map_size_y", bot_origin_quadrant: "bot_origin_quadrant", default_plant_depth: "default_plant_depth", + viewpoint_heading: "viewpoint_heading" as WebAppNumberConfigKey, /** App settings */ beep_verbosity: "beep_verbosity", diff --git a/frontend/settings/__tests__/index_test.tsx b/frontend/settings/__tests__/index_test.tsx index 345a1d88f8..3872ae2508 100644 --- a/frontend/settings/__tests__/index_test.tsx +++ b/frontend/settings/__tests__/index_test.tsx @@ -1,5 +1,6 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; +import { changeBlurableInputRTL } from "../../__test_support__/helpers"; import { RawDesignerSettings as DesignerSettings } from "../index"; import { DesignerSettingsProps } from "../interfaces"; import { BooleanSetting, NumericSetting } from "../../session_keys"; @@ -25,12 +26,14 @@ import * as bootSequenceSelector from "../fbos_settings/boot_sequence_selector"; const EMPTY_RESOURCE_INDEX = buildResourceIndex([]).index; -const getSetting = - (container: HTMLElement, position: number, containsString: string) => { - const setting = container.querySelectorAll(".designer-setting")[position] as HTMLElement; - expect(setting.textContent?.toLowerCase()) - .toContain(containsString.toLowerCase()); - return setting; +const getSettingByText = + (container: HTMLElement, containsString: string) => { + const settings = Array.from(container.querySelectorAll(".designer-setting")) + .filter((setting): setting is HTMLElement => setting instanceof HTMLElement); + const setting = settings.find(setting => + setting.textContent?.toLowerCase().includes(containsString.toLowerCase())); + expect(setting).toBeTruthy(); + return setting as HTMLElement; }; describe("", () => { @@ -171,7 +174,7 @@ describe("", () => { config.body.confirm_plant_deletion = undefined as never; p.getConfigValue = key => config.body[key]; const { container } = render(); - const confirmDeletion = getSetting(container, 12, "confirm plant"); + const confirmDeletion = getSettingByText(container, "confirm plant"); expect(confirmDeletion.querySelector("button")?.textContent).toEqual("on"); }); @@ -179,7 +182,7 @@ describe("", () => { const p = fakeProps(); p.settingsPanelState.farm_designer = true; const { container } = render(); - const trailSetting = getSetting(container, 1, "trail"); + const trailSetting = getSettingByText(container, "trail"); const button = trailSetting.querySelector("button"); if (!button) { throw new Error("Expected trail toggle button"); } fireEvent.click(button); @@ -192,13 +195,38 @@ describe("", () => { p.settingsPanelState.farm_designer = true; p.getConfigValue = () => 2; const { container } = render(); - const originSetting = getSetting(container, 6, "origin"); + const originSetting = getSettingByText(container, "origin"); const quadrants = originSetting.querySelectorAll(".quadrant"); fireEvent.click(quadrants[quadrants.length - 1]); expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( NumericSetting.bot_origin_quadrant, 4); }); + it("toggles top down view", () => { + const p = fakeProps(); + p.settingsPanelState.farm_designer = true; + const { container } = render(); + const topDownSetting = getSettingByText(container, "open in top-down view"); + const button = topDownSetting.querySelector("button"); + if (!button) { throw new Error("Expected top down toggle button"); } + fireEvent.click(button); + expect(setWebAppConfigValueSpy) + .toHaveBeenCalledWith(BooleanSetting.top_down_view, true); + }); + + it("changes viewpoint heading", () => { + const p = fakeProps(); + p.settingsPanelState.farm_designer = true; + p.getConfigValue = key => key == NumericSetting.viewpoint_heading ? 0 : 2; + const { container } = render(); + const headingSetting = getSettingByText(container, "camera location upon open"); + const input = headingSetting.querySelector("input"); + if (!input) { throw new Error("Expected viewpoint heading input"); } + changeBlurableInputRTL(input, "270"); + expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( + NumericSetting.viewpoint_heading, 270); + }); + it("renders env editor", () => { const p = fakeProps(); p.searchTerm = "env"; diff --git a/frontend/settings/default_values.ts b/frontend/settings/default_values.ts index 0f3f874c7d..6fd01aa839 100644 --- a/frontend/settings/default_values.ts +++ b/frontend/settings/default_values.ts @@ -89,6 +89,8 @@ const DEFAULT_WEB_APP_CONFIG_VALUES: Record = { show_advanced_settings: false, three_d_garden: false, dark_mode: true, + ["top_down_view" as Key]: false, + ["viewpoint_heading" as Key]: 0, }; const DEFAULT_EXPRESS_WEB_APP_CONFIG_VALUES = diff --git a/frontend/settings/farm_designer_settings.tsx b/frontend/settings/farm_designer_settings.tsx index 1db46429ba..a4791ade59 100644 --- a/frontend/settings/farm_designer_settings.tsx +++ b/frontend/settings/farm_designer_settings.tsx @@ -128,6 +128,16 @@ const DESIGNER_SETTINGS = description: Content.MAP_ORIGIN, children: }, + { + title: DeviceSetting.openInTopDownView, + description: Content.TOP_DOWN_VIEW, + setting: BooleanSetting.top_down_view, + }, + { + title: DeviceSetting.cameraLocationUponOpen, + description: Content.VIEWPOINT_HEADING, + numberSetting: NumericSetting.viewpoint_heading, + }, { title: DeviceSetting.cropMapImages, description: Content.CROP_MAP_IMAGES, @@ -180,3 +190,21 @@ export const OriginSelector = (props: DesignerSettingsPropsBase) => {
; }; + +export const HeadingSelector = (props: DesignerSettingsPropsBase) => { + const settingKey = NumericSetting.viewpoint_heading; + const heading = props.getConfigValue(settingKey); + const update = (value: number) => () => + props.dispatch(setWebAppConfigValue(settingKey, value)); + return
+
+ {[180, 0, 90, 270].map(angle => +
)} +
+
; +}; diff --git a/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx new file mode 100644 index 0000000000..a97a678677 --- /dev/null +++ b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { CameraSelectionUI } from "../camera_selection_ui"; +import { clone } from "lodash"; +import { INITIAL } from "../config"; +import * as configStorageActions from "../../config_storage/actions"; +import { NumericSetting } from "../../session_keys"; +import { + actRenderer, + createRenderer, + unmountRenderer, +} from "../../__test_support__/test_renderer"; + +describe("", () => { + let setWebAppConfigValueSpy: jest.SpyInstance; + const mountedWrappers: ReturnType[] = []; + + beforeEach(() => { + setWebAppConfigValueSpy = jest.spyOn(configStorageActions, "setWebAppConfigValue") + .mockImplementation(jest.fn()); + }); + + afterEach(() => { + mountedWrappers.splice(0).forEach(wrapper => + unmountRenderer(wrapper)); + setWebAppConfigValueSpy.mockRestore(); + }); + + const fakeConfig = () => { + const config = clone(INITIAL); + config.bedHeight = 100; + return config; + }; + + it("renders hidden by default", () => { + const wrapper = createRenderer( + ); + mountedWrappers.push(wrapper); + const group = wrapper.root.findAll(node => + node.props.name == "camera-selection")[0]; + expect(group?.props.visible).toEqual(false); + }); + + it("renders unique heading marker", () => { + const config = fakeConfig(); + config.cameraSelectionView = true; + config.viewpointHeading = 45; + const { container } = render( + ); + expect(container.querySelectorAll(".spherehead").length).toEqual(5); + }); + + it("dispatches heading update", () => { + const config = fakeConfig(); + config.cameraSelectionView = true; + const dispatch = jest.fn(); + const wrapper = createRenderer( + ); + mountedWrappers.push(wrapper); + const groups = wrapper.root.findAll(node => node.props.onClick); + actRenderer(() => { + groups[0]?.props.onClick(); + }); + expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( + NumericSetting.viewpoint_heading, 0); + expect(dispatch).toHaveBeenCalled(); + }); + + it("handles missing dispatch", () => { + const config = fakeConfig(); + config.cameraSelectionView = true; + const wrapper = createRenderer( + ); + mountedWrappers.push(wrapper); + const groups = wrapper.root.findAll(node => node.props.onClick); + actRenderer(() => { + groups[0]?.props.onClick(); + }); + expect(setWebAppConfigValueSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/three_d_garden/__tests__/camera_test.ts b/frontend/three_d_garden/__tests__/camera_test.ts index 0b49df5267..1e2fe6d0f6 100644 --- a/frontend/three_d_garden/__tests__/camera_test.ts +++ b/frontend/three_d_garden/__tests__/camera_test.ts @@ -1,7 +1,9 @@ let mockDev: string | undefined = undefined; let mockIsDesktop = true; -import { cameraInit } from "../camera"; +import { + cameraInit, CameraInitProps, getDefaultCameraPosition, +} from "../camera"; import * as devSupport from "../../settings/dev/dev_support"; import * as screenSize from "../../screen_size"; @@ -10,7 +12,8 @@ let isDesktopSpy: jest.SpyInstance; beforeEach(() => { get3dCameraSpy = jest.spyOn(devSupport.DevSettings, "get3dCamera") - .mockImplementation((() => mockDev || "") as typeof devSupport.DevSettings.get3dCamera); + .mockImplementation((() => + mockDev || "") as typeof devSupport.DevSettings.get3dCamera); isDesktopSpy = jest.spyOn(screenSize, "isDesktop") .mockImplementation(() => mockIsDesktop); }); @@ -21,10 +24,15 @@ afterEach(() => { }); describe("cameraInit()", () => { + const fakeProps = (): CameraInitProps => ({ + topDown: false, + viewpointHeading: 0, + }); + it("initializes camera", () => { mockDev = undefined; mockIsDesktop = true; - expect(cameraInit(false)).toEqual({ + expect(cameraInit(fakeProps())).toEqual({ position: [2000, -4000, 2500], target: [0, 0, 0], }); @@ -33,13 +41,16 @@ describe("cameraInit()", () => { it("initializes camera: dev", () => { mockDev = JSON.stringify({ position: [0, 0, 0], target: [0, 0, 0] }); mockIsDesktop = true; - expect(cameraInit(false)).toEqual({ position: [0, 0, 0], target: [0, 0, 0] }); + expect(cameraInit(fakeProps())).toEqual({ + position: [0, 0, 0], + target: [0, 0, 0], + }); }); it("handles invalid dev camera setting", () => { mockDev = "{"; mockIsDesktop = true; - expect(cameraInit(false)).toEqual({ + expect(cameraInit(fakeProps())).toEqual({ position: [2000, -4000, 2500], target: [0, 0, 0], }); @@ -48,7 +59,7 @@ describe("cameraInit()", () => { it("initializes camera: mobile", () => { mockDev = undefined; mockIsDesktop = false; - expect(cameraInit(false)).toEqual({ + expect(cameraInit(fakeProps())).toEqual({ position: [5400, -2500, 3400], target: [0, 0, 0], }); @@ -57,9 +68,39 @@ describe("cameraInit()", () => { it("initializes camera: top-down", () => { mockDev = undefined; mockIsDesktop = false; - expect(cameraInit(true)).toEqual({ + const p = fakeProps(); + p.topDown = true; + expect(cameraInit(p)).toEqual({ position: [0, 0, 5000], target: [0, 0, 0], }); }); + + it("initializes camera from heading", () => { + mockDev = undefined; + mockIsDesktop = true; + const p = fakeProps(); + p.viewpointHeading = 90; + expect(cameraInit(p)).toEqual({ + position: [2000, 4000, 2500], + target: [0, 0, 0], + }); + }); +}); + +describe("getDefaultCameraPosition()", () => { + it("returns desktop position", () => { + mockIsDesktop = true; + expect(getDefaultCameraPosition(180)).toEqual([-2000, 4000, 2500]); + }); + + it("returns mobile position", () => { + mockIsDesktop = false; + expect(getDefaultCameraPosition(270)).toEqual([-5400, -2500, 3400]); + }); + + it("returns top down position", () => { + mockIsDesktop = true; + expect(getDefaultCameraPosition(90, true)).toEqual([5657, 0, 5000]); + }); }); diff --git a/frontend/three_d_garden/__tests__/config_test.ts b/frontend/three_d_garden/__tests__/config_test.ts index fffb3c9f78..acc38bbc81 100644 --- a/frontend/three_d_garden/__tests__/config_test.ts +++ b/frontend/three_d_garden/__tests__/config_test.ts @@ -54,6 +54,14 @@ describe("modifyConfig()", () => { const result = modifyConfig(initial, { otherPreset: "Initial" }); expect(result.bedHeight).toEqual(300); }); + + it("modifies config: top down", () => { + const initial = clone(INITIAL); + const result = modifyConfig(initial, { topDown: true }); + expect(result.topDown).toEqual(true); + expect(result.perspective).toEqual(false); + expect(result.rotate).toEqual(false); + }); }); describe("modifyConfigsFromUrlParams()", () => { diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 0c1020b74a..59cf406a63 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -2,7 +2,7 @@ let mockIsDesktop = false; let mockIsMobile = false; import React from "react"; -import { useTexture } from "@react-three/drei"; +import { OrbitControls, useTexture } from "@react-three/drei"; import { GardenModelProps, GardenModel } from "../garden_model"; import { clone } from "lodash"; import { INITIAL, INITIAL_POSITION, SurfaceDebugOption } from "../config"; @@ -87,11 +87,21 @@ describe("", () => { it("renders top down view", () => { mockIsMobile = true; const p = fakeProps(); - const addPlantProps = fakeAddPlantProps(); - addPlantProps.designer.threeDTopDownView = true; - p.addPlantProps = addPlantProps; + p.config.topDown = true; + p.config.viewpointHeading = 90; + const wrapper = createWrapper(p); + const orbitControls = wrapper.root.findByType(OrbitControls); + expect(orbitControls.props.minAzimuthAngle).toEqual(Math.PI / 2); + expect(orbitControls.props.maxAzimuthAngle).toEqual(Math.PI / 2); + }); + + it("renders camera selection view", () => { + const p = fakeProps(); + p.config.cameraSelectionView = true; + p.config.viewpointHeading = 45; const { container } = render(); - expect(container.innerHTML).toContain("darkgreen"); + expect(container.innerHTML).toContain("camera-selection"); + expect(container.innerHTML).toContain("person_2.avif"); }); it("renders no user plants", () => { diff --git a/frontend/three_d_garden/__tests__/helpers_test.ts b/frontend/three_d_garden/__tests__/helpers_test.ts index 211fd3fc2e..c5a00da49d 100644 --- a/frontend/three_d_garden/__tests__/helpers_test.ts +++ b/frontend/three_d_garden/__tests__/helpers_test.ts @@ -4,6 +4,7 @@ import { getColorFromBrightness, get3DPositionFunc, getGardenPositionFunc, + isTopDown, getWorldPositionFunc, threeSpace, zDir, @@ -11,6 +12,7 @@ import { } from "../helpers"; import { INITIAL } from "../config"; import * as THREE from "three"; +import { fakeDesignerState } from "../../__test_support__/fake_designer_state"; describe("threeSpace()", () => { it("returns adjusted position", () => { @@ -98,3 +100,18 @@ describe("mirror-aware position helpers", () => { expect(getWorldPosition({ x: 125, y: 250, z: 10 })).toEqual([375, 50, 150]); }); }); + +describe("isTopDown()", () => { + it("returns local state when defined", () => { + const designer = fakeDesignerState(); + designer.threeDTopDownView = false; + expect(isTopDown(designer, () => true)).toEqual(false); + }); + + it("falls back to saved config when local state is undefined", () => { + const designer = fakeDesignerState(); + designer.threeDTopDownView = undefined; + expect(isTopDown(designer, () => true)).toEqual(true); + expect(isTopDown(designer, () => false)).toEqual(false); + }); +}); diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index b5235f1610..2937660acc 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -43,6 +43,7 @@ describe("", () => { device: fakeDevice().body, designer: fakeDesignerState(), threeDGarden: true, + getConfigValue: jest.fn(), }); it("renders off", () => { @@ -75,6 +76,18 @@ describe("", () => { }); }); + it("uses saved top down setting", () => { + const p = fakeProps(); + p.getConfigValue = () => true; + render(); + const isoViewButton = screen.getByTitle("3D View"); + fireEvent.click(isoViewButton); + expect(p.dispatch).toHaveBeenCalledWith({ + type: Actions.TOGGLE_3D_TOP_DOWN_VIEW, + payload: false, + }); + }); + it("enables top down view", () => { const p = fakeProps(); render(); diff --git a/frontend/three_d_garden/camera.ts b/frontend/three_d_garden/camera.ts index 0b176c9bb4..3cc54a69b0 100644 --- a/frontend/three_d_garden/camera.ts +++ b/frontend/three_d_garden/camera.ts @@ -1,8 +1,15 @@ +import { round } from "lodash"; import { isDesktop } from "../screen_size"; import { DevSettings } from "../settings/dev/dev_support"; import { Camera } from "./zoom_beacons_constants"; -export const cameraInit = (topDown: boolean): Camera => { +export interface CameraInitProps { + topDown: boolean; + viewpointHeading: number; +} + +export const cameraInit = (props: CameraInitProps): Camera => { + const { topDown, viewpointHeading } = props; const devCameraString = DevSettings.get3dCamera(); let devCamera; try { @@ -11,13 +18,10 @@ export const cameraInit = (topDown: boolean): Camera => { devCamera = undefined; } - const defaultCameraPosition = isDesktop() - ? [2000, -4000, 2500] - : [5400, -2500, 3400]; const topDownCameraPosition = topDown ? [0, 0, 5000] : undefined; const cameraPositionInit = topDownCameraPosition || devCamera?.position - || defaultCameraPosition; + || getDefaultCameraPosition(viewpointHeading); const defaultCameraTarget = [0, 0, 0]; const topDownCameraTarget = topDown ? [0, 0, 0] : undefined; @@ -31,3 +35,32 @@ export const cameraInit = (topDown: boolean): Camera => { }; return initCamera; }; + +export const getDefaultCameraPosition = ( + heading: number, + topDown = false, +): [number, number, number] => { + const radians = heading * Math.PI / 180; + + if (topDown) { + const phase = Math.PI / 2; + return [ + round(4000 * Math.SQRT2 * Math.cos(radians - phase)), + round(2000 * Math.SQRT2 * Math.sin(radians - phase)), + 5000, + ]; + } + + const phase = Math.PI / 4; + return isDesktop() + ? [ + round(2000 * Math.SQRT2 * Math.cos(radians - phase)), + round(4000 * Math.SQRT2 * Math.sin(radians - phase)), + 2500, + ] + : [ + round(5400 * Math.SQRT2 * Math.cos(radians - phase)), + round(2500 * Math.SQRT2 * Math.sin(radians - phase)), + 3400, + ]; +}; diff --git a/frontend/three_d_garden/camera_selection_ui.tsx b/frontend/three_d_garden/camera_selection_ui.tsx new file mode 100644 index 0000000000..5f4a2fd2aa --- /dev/null +++ b/frontend/three_d_garden/camera_selection_ui.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { Config } from "./config"; +import { getDefaultCameraPosition } from "./camera"; +import { Cylinder, Sphere } from "@react-three/drei"; +import { Group, MeshPhongMaterial } from "./components"; +import { uniq } from "lodash"; +import { setWebAppConfigValue } from "../config_storage/actions"; +import { NumericSetting } from "../session_keys"; +import { Person } from "./scenes/props"; +import { ASSETS } from "./constants"; + +export interface CameraSelectionUIProps { + config: Config; + dispatch: Function | undefined; +} + +export const CameraSelectionUI = (props: CameraSelectionUIProps) => { + const { config } = props; + const [hovered, setHovered] = React.useState(undefined); + return + {uniq([0, 90, 180, 270, config.viewpointHeading]).map(angle => { + const selected = angle == config.viewpointHeading; + const baseColor = selected ? "blue" : "orange"; + const color = hovered == angle ? "cyan" : baseColor; + const position = getDefaultCameraPosition(angle, config.topDown); + const scaledPosition = position.map(p => p * 0.33) as [number, number, number]; + const height = scaledPosition[2] + config.bedHeight; + return setHovered(angle)} + onPointerOut={() => setHovered(undefined)} + onClick={() => props.dispatch && + props.dispatch(setWebAppConfigValue( + NumericSetting.viewpoint_heading, angle))}> + + + + + + + + ; + })} + ; +}; diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index a4e7552b9f..c8de3d920b 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -42,6 +42,9 @@ export interface Config { sunAzimuth: number; heading: number; perspective: boolean; + topDown: boolean; + viewpointHeading: number; + cameraSelectionView: boolean; bot: boolean; laser: boolean; tool: string; @@ -166,6 +169,9 @@ export const INITIAL: ConfigWithPosition = { sunAzimuth: 230, heading: 0, perspective: true, + topDown: false, + viewpointHeading: 0, + cameraSelectionView: false, bot: true, laser: false, tool: "rotaryTool", @@ -248,12 +254,12 @@ export const NUMBER_KEYS = [ "soilSurfacePointCount", "soilSurfaceVariance", "sun", "ambient", "rotary", "imgScale", "imgRotation", "imgOffsetX", "imgOffsetY", "imgCalZ", "imgCenterX", "imgCenterY", "surfaceDebug", "interpolationStepSize", - "interpolationPower", "lastImageCapture", + "interpolationPower", "lastImageCapture", "viewpointHeading", ]; export const BOOLEAN_KEYS = [ "legsFlush", "labels", "labelsOnHover", "ground", "grid", "axes", "trail", - "tracks", "clouds", "perspective", "bot", "laser", "cableCarriers", + "tracks", "clouds", "perspective", "topDown", "bot", "laser", "cableCarriers", "viewCube", "stats", "config", "zoom", "pan", "rotate", "bounds", "threeAxes", "xyDimensions", "zDimension", "promoInfo", "settingsBar", "zoomBeacons", "solar", "utilitiesPost", "packaging", "people", "lowDetail", @@ -261,7 +267,7 @@ export const BOOLEAN_KEYS = [ "animate", "animateSeasons", "negativeZ", "waterFlow", "exaggeratedZ", "showSoilPoints", "urlParamAutoAdd", "light", "vacuum", "north", "desk", "interpolationUseNearest", "promoSpread", - "cameraView", "mirrorX", "mirrorY", + "cameraView", "mirrorX", "mirrorY", "cameraSelectionView", ]; export const PRESETS: Record = { @@ -467,7 +473,7 @@ const OTHER_CONFIG_KEYS: (keyof Config)[] = [ "ccSupportSize", "legSize", "legsFlush", "bedBrightness", "soilBrightness", "plants", "labels", "ground", "grid", "axes", "trail", "clouds", "sunInclination", "sunAzimuth", "heading", - "perspective", "bot", "laser", + "perspective", "topDown", "bot", "laser", "viewpointHeading", "tool", "cableCarriers", "viewCube", "stats", "config", "zoom", "bounds", "threeAxes", "xyDimensions", "zDimension", "labelsOnHover", "promoInfo", "settingsBar", "zoomBeacons", "pan", "rotate", @@ -481,7 +487,7 @@ const OTHER_CONFIG_KEYS: (keyof Config)[] = [ "imgScale", "imgRotation", "imgOffsetX", "imgOffsetY", "imgOrigin", "imgCalZ", "imgCenterX", "imgCenterY", "interpolationStepSize", "interpolationUseNearest", "interpolationPower", "promoSpread", "cameraView", "lastImageCapture", - "mirrorX", "mirrorY", + "mirrorX", "mirrorY", "cameraSelectionView", ]; export const modifyConfig = @@ -508,6 +514,10 @@ export const modifyConfig = newConfig.bedZOffset = newConfig.bedType == "Mobile" ? 500 : 0; newConfig.legsFlush = newConfig.bedType != "Mobile"; } + if (Object.keys(update).includes("topDown")) { + newConfig.perspective = !update.topDown; + newConfig.rotate = !update.topDown; + } if (update.otherPreset) { if (update.otherPreset == "Reset all") { Object.keys(config).map(key => { diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 23ee3537db..9c19cfd961 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -405,6 +405,9 @@ export const PrivateOverlay = (props: OverlayProps) => { + + + { }, }); - const topDown = addPlantProps?.designer.threeDTopDownView; - const topDownMobile = topDown && isMobile(); + const baseAngle = 0; + const topDownCameraAngle = config.topDown + ? baseAngle + config.viewpointHeading * Math.PI / 180 + : undefined; const camera = getCamera( config, props.configPosition, props.activeFocus, - cameraInit(!!topDown)); + cameraInit({ + topDown: config.topDown, + viewpointHeading: config.viewpointHeading, + })); const [controlsCamera, setControlsCamera] = // eslint-disable-next-line no-null/no-null React.useState(null); @@ -232,15 +237,15 @@ export const GardenModel = (props: GardenModelProps) => { fov={40} near={10} far={BigDistance.far} position={camera.position} rotation={[0, 0, 0]} - zoom={topDown ? 0.25 : 1} + zoom={config.topDown ? 0.25 : 1} up={[0, 0, 1]} /> {controlsCamera && { + ; }; diff --git a/frontend/three_d_garden/helpers.ts b/frontend/three_d_garden/helpers.ts index e084518d03..2009b98c83 100644 --- a/frontend/three_d_garden/helpers.ts +++ b/frontend/three_d_garden/helpers.ts @@ -2,6 +2,9 @@ import { Config } from "./config"; import * as THREE from "three"; import { AxisNumberProperty } from "../farm_designer/map/interfaces"; import { round } from "../farm_designer/map/util"; +import { DesignerState } from "../farm_designer/interfaces"; +import { BooleanSetting } from "../session_keys"; +import { GetWebAppConfigValue } from "../config_storage/actions"; export const threeSpace = (position: number, max: number): number => position - max / 2; @@ -103,3 +106,12 @@ export const getWorldPositionFunc = (config: Config) => zZero(config) + gardenPosition.z, ]; }; + +export const isTopDown = ( + designer: DesignerState, + getWebAppConfigValue: GetWebAppConfigValue, +) => { + const state = designer.threeDTopDownView; + const db = !!getWebAppConfigValue(BooleanSetting.top_down_view); + return state ?? db; +}; diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx index 3985486bfd..722dec76cd 100644 --- a/frontend/three_d_garden/index.tsx +++ b/frontend/three_d_garden/index.tsx @@ -19,11 +19,12 @@ import { isMobile } from "../screen_size"; import { Help } from "../ui"; import { BooleanSetting } from "../session_keys"; import { LayerToggle } from "../farm_designer/map/legend/layer_toggle"; -import { setWebAppConfigValue } from "../config_storage/actions"; +import { GetWebAppConfigValue, setWebAppConfigValue } from "../config_storage/actions"; import { DesignerState } from "../farm_designer/interfaces"; import { setPanelOpen } from "../farm_designer/panel_header"; import { ThreeDGardenPlant } from "./garden"; import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; +import { isTopDown } from "./helpers"; export interface ThreeDGardenProps { config: Config; @@ -87,12 +88,13 @@ export interface ThreeDGardenToggleProps { designer: DesignerState; threeDGarden: boolean; device: DeviceAccountSettings; + getConfigValue: GetWebAppConfigValue; } // eslint-disable-next-line complexity export const ThreeDGardenToggle = (props: ThreeDGardenToggleProps) => { const { navigate, dispatch, threeDGarden } = props; - const topDown = props.designer.threeDTopDownView; + const topDown = isTopDown(props.designer, props.getConfigValue); const exaggeratedZ = props.designer.threeDExaggeratedZ; const description = isMobile() ? Content.SHOW_3D_VIEW_DESCRIPTION_MOBILE diff --git a/frontend/three_d_garden/scenes/props/__tests__/people_test.tsx b/frontend/three_d_garden/scenes/props/__tests__/people_test.tsx index 0494efaba6..8b345f8113 100644 --- a/frontend/three_d_garden/scenes/props/__tests__/people_test.tsx +++ b/frontend/three_d_garden/scenes/props/__tests__/people_test.tsx @@ -1,8 +1,9 @@ import React from "react"; import { render } from "@testing-library/react"; -import { People, PeopleProps } from "../people"; -import { INITIAL } from "../../../config"; +import { People, PeopleProps, Person } from "../people"; +import { ASSETS } from "../../../constants"; import { clone } from "lodash"; +import { INITIAL } from "../../../config"; describe("", () => { const fakeProps = (): PeopleProps => ({ @@ -18,3 +19,16 @@ describe("", () => { expect(container).toContainHTML("people"); }); }); + +describe("", () => { + it("renders image with transform props", () => { + const { container } = render( + ); + expect(container.innerHTML).toContain(ASSETS.people.person2); + expect(container.innerHTML).toContain("1,2,3"); + expect(container.innerHTML).toContain("4,5,6"); + }); +}); diff --git a/frontend/three_d_garden/scenes/props/people.tsx b/frontend/three_d_garden/scenes/props/people.tsx index 3a1ce34f42..fca581d33c 100644 --- a/frontend/three_d_garden/scenes/props/people.tsx +++ b/frontend/three_d_garden/scenes/props/people.tsx @@ -3,7 +3,7 @@ import { Billboard, Image } from "@react-three/drei"; import { Group } from "../../components"; import { Config } from "../../config"; import { threeSpace } from "../../helpers"; -import { Vector3 } from "three"; +import { Vector3, DoubleSide } from "three"; import { ASSETS, RenderOrder } from "../../constants"; export interface PeopleProps { @@ -18,7 +18,6 @@ export const People = (props: PeopleProps) => { return {people.map((person, i) => { - const scalingData = SCALING_DATA[person.url]; const offset = new Vector3(...person.offset); return { threeSpace(offset.y, config.bedWidthOuter), groundZ, ]}> - + ; })} ; @@ -53,3 +46,25 @@ const SCALING_DATA: Record = { [ASSETS.people.person4]: { scale: [580, 1700], position: [0, 850, 0] }, [ASSETS.people.person4Flipped]: { scale: [580, 1700], position: [0, 850, 0] }, }; + +export interface PersonProps { + url: string; + position?: [number, number, number]; + rotation?: [number, number, number]; +} + +export const Person = (props: PersonProps) => { + const scalingData = SCALING_DATA[props.url]; + return + + ; +}; From c70de930ae2c4f7205019ffc6d912ecfbc8f1d6b Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 21 Apr 2026 17:47:19 -0700 Subject: [PATCH 06/11] improve 3D camera selection UI --- frontend/__test_support__/bun_test_setup.ts | 4 + frontend/__test_support__/three_d_mocks.tsx | 8 + frontend/constants.ts | 12 +- .../farm_designer/map/__tests__/util_test.ts | 12 ++ frontend/farm_designer/map/interfaces.ts | 1 + .../__tests__/garden_map_legend_test.tsx | 24 +-- .../map/legend/garden_map_legend.tsx | 25 +-- frontend/farm_designer/map/util.ts | 3 + frontend/settings/__tests__/index_test.tsx | 26 --- frontend/settings/farm_designer_settings.tsx | 49 ++--- .../__tests__/camera_selection_ui_test.tsx | 204 ++++++++++++++++-- .../three_d_garden/__tests__/camera_test.ts | 40 +++- .../__tests__/garden_model_test.tsx | 29 +++ frontend/three_d_garden/camera.ts | 77 ++++--- .../three_d_garden/camera_selection_ui.tsx | 191 +++++++++++++--- frontend/three_d_garden/fps_probe.tsx | 5 +- .../garden/__tests__/plant_instances_test.tsx | 18 ++ .../garden/__tests__/plants_test.tsx | 14 ++ .../three_d_garden/garden/plant_instances.tsx | 3 +- frontend/three_d_garden/garden/plants.tsx | 2 +- frontend/three_d_garden/garden_model.tsx | 15 +- .../three_d_garden/scenes/props/people.tsx | 1 + frontend/wizard/__tests__/checks_test.tsx | 22 ++ frontend/wizard/__tests__/data_test.ts | 23 ++ frontend/wizard/checks.tsx | 17 +- frontend/wizard/data.ts | 37 ++-- 26 files changed, 653 insertions(+), 209 deletions(-) diff --git a/frontend/__test_support__/bun_test_setup.ts b/frontend/__test_support__/bun_test_setup.ts index 7bbc62a2e2..040e58b2a9 100644 --- a/frontend/__test_support__/bun_test_setup.ts +++ b/frontend/__test_support__/bun_test_setup.ts @@ -229,6 +229,10 @@ const defaultThreeFiberState = () => ({ scene: { traverse: jest.fn() }, size: { width: 800, height: 600 }, pointer: { x: 0, y: 0 }, + raycaster: { + setFromCamera: jest.fn(), + intersectObjects: jest.fn(() => []), + }, }); type MockLike = { diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 38079a3e97..9eee54e122 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -203,6 +203,10 @@ jest.mock("@react-three/fiber", () => ({ scene: { traverse: jest.fn() }, size: { width: 800, height: 600 }, pointer: { x: 0, y: 0 }, + raycaster: { + setFromCamera: jest.fn(), + intersectObjects: jest.fn(() => []), + }, })), useThree: jest.fn(() => ({ gl: { @@ -214,6 +218,10 @@ jest.mock("@react-three/fiber", () => ({ scene: { traverse: jest.fn() }, pointer: { x: 0, y: 0 }, camera: new THREE.PerspectiveCamera(), + raycaster: { + setFromCamera: jest.fn(), + intersectObjects: jest.fn(() => []), + }, size: { width: 800, height: 600 }, })), extend: jest.fn(), diff --git a/frontend/constants.ts b/frontend/constants.ts index 6c5855efde..c4b9f8c3e8 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -1018,8 +1018,8 @@ export namespace Content { export const TOP_DOWN_VIEW = trim(`Upon open, display the 3D garden map from a top-down perspective.`); - export const VIEWPOINT_HEADING = - trim(`Heading of the camera when the 3D garden map is opened (in degrees).`); + export const CAMERA_STARTING_LOCATION = + trim(`Location of the camera when the 3D garden map is opened.`); export const CROP_MAP_IMAGES = trim(`Crop images displayed in the garden map to remove black borders @@ -1779,6 +1779,10 @@ export namespace SetupWizardContent { map to your real life FarmBot. The relevant controls are available below the video for your convenience.`); + export const SET_CAMERA_LOCATION = + trim(`Press the "SET" button to show camera location options. + Select the correct camera angle by clicking on the camera location.`); + export const PRESS_RIGHT_JOG_BUTTON = trim(`Standing from where you will normally view the FarmBot, **press the right arrow button**.`); @@ -2240,8 +2244,8 @@ export enum DeviceSetting { mapSize = `Map size`, rotateMap = `Rotate map`, mapOrigin = `Map origin`, - openInTopDownView = `Open in top-down view`, - cameraLocationUponOpen = `Camera location upon open`, + topDownView = `Top down view`, + setCameraStartingLocation = `Set camera starting location`, cropMapImages = `Crop map images`, clipPhotosOutOfBounds = `Clip photos out of bounds`, cameraView = `Camera view`, diff --git a/frontend/farm_designer/map/__tests__/util_test.ts b/frontend/farm_designer/map/__tests__/util_test.ts index a3b2f47ba9..010c7e7fcd 100644 --- a/frontend/farm_designer/map/__tests__/util_test.ts +++ b/frontend/farm_designer/map/__tests__/util_test.ts @@ -448,6 +448,18 @@ describe("getMode()", () => { location.pathname = Path.mock(Path.app()); expect(getMode()).toEqual(Mode.none); }); + + it("returns camera selection mode", () => { + const state = fakeState(); + state.resources.consumers.farm_designer = fakeDesignerState(); + state.resources.consumers.farm_designer.threeDCameraSelection = true; + const originalGetState = store.getState; + (store as unknown as { getState: () => typeof state }).getState = () => state; + location.pathname = Path.mock(Path.app()); + expect(getMode()).toEqual(Mode.cameraSelection); + (store as unknown as { getState: typeof originalGetState }).getState = + originalGetState; + }); }); describe("savedGardenOpen", () => { diff --git a/frontend/farm_designer/map/interfaces.ts b/frontend/farm_designer/map/interfaces.ts index ec528b425a..64ff0e8b57 100644 --- a/frontend/farm_designer/map/interfaces.ts +++ b/frontend/farm_designer/map/interfaces.ts @@ -202,4 +202,5 @@ export enum Mode { templateView = "templateView", editGroup = "editGroup", profile = "profile", + cameraSelection = "cameraSelection", } diff --git a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx index c9ca3b9000..97944428e8 100644 --- a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx +++ b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx @@ -9,7 +9,7 @@ import { ZoomControlsProps, } from "../garden_map_legend"; import { GardenMapLegendProps } from "../../interfaces"; -import { BooleanSetting, NumericSetting } from "../../../../session_keys"; +import { BooleanSetting } from "../../../../session_keys"; import * as zoom from "../../zoom"; import { fakeTimeSettings, @@ -196,39 +196,23 @@ describe("", () => { const { container } = render(); expect(container.textContent).toContain("Rotate map"); expect(container.textContent).toContain("Map origin"); - expect(container.textContent).not.toContain("Camera location upon open"); + expect(container.textContent).not.toContain("Set camera starting location"); }); it("shows 3D-only controls", () => { const p = fakeProps(); p.getConfigValue = key => key == BooleanSetting.three_d_garden; const { container } = render(); - expect(container.textContent).toContain("Open in top-down view"); - expect(container.textContent).toContain("Camera location upon open"); - expect(container.textContent).toContain("Enable camera heading selection view"); + expect(container.textContent).toContain("Set camera starting location"); expect(container.textContent).not.toContain("Rotate map"); }); - it("changes viewpoint heading in 3D settings", () => { - const p = fakeProps(); - p.getConfigValue = key => { - if (key == BooleanSetting.three_d_garden) { return true; } - if (key == NumericSetting.viewpoint_heading) { return 0; } - return false; - }; - const { container } = render(); - const quadrants = container.querySelectorAll(".quadrant"); - fireEvent.click(quadrants[quadrants.length - 1]); - expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( - NumericSetting.viewpoint_heading, 270); - }); - it("toggles camera selection view", () => { const p = fakeProps(); p.getConfigValue = key => key == BooleanSetting.three_d_garden; const { container } = render(); const toggleBtn = - container.querySelector("button[title='Enable camera heading selection view']"); + container.querySelector("button[title='Set camera starting location']"); if (!toggleBtn) { throw new Error("Missing camera selection toggle"); } fireEvent.click(toggleBtn); expect(p.dispatch).toHaveBeenCalledWith({ diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index c3d1f8c6e7..ba1caab37a 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -13,7 +13,7 @@ import { import { BooleanSetting } from "../../../session_keys"; import { t } from "../../../i18next_wrapper"; import { SelectModeLink } from "../../../plants/select_plants"; -import { DeviceSetting, Content, Actions } from "../../../constants"; +import { DeviceSetting, Content } from "../../../constants"; import { Help, Popover, ToggleButton } from "../../../ui"; import { BooleanConfigKey as WebAppBooleanConfigKey, @@ -22,7 +22,9 @@ import { ZDisplay, ZDisplayToggle } from "./z_display"; import { getModifiedClassName } from "../../../settings/default_values"; import { Position } from "@blueprintjs/core"; import { MapSizeInputs } from "../../map_size_setting"; -import { HeadingSelector, OriginSelector } from "../../../settings/farm_designer_settings"; +import { + CameraStartingLocationButton, OriginSelector, +} from "../../../settings/farm_designer_settings"; import { McuParams } from "farmbot"; import { DesignerState } from "../../interfaces"; @@ -238,25 +240,10 @@ export const MapSettingsContent = (props: SettingsSubMenuProps) => { helpText={Content.MAP_ORIGIN}> } - {is3D && } - {is3D && - - } {is3D &&
- - props.dispatch({ - type: Actions.TOGGLE_3D_CAMERA_SELECTION, - payload: undefined, - })} - toggleValue={props.designer.threeDCameraSelection} /> + +
}
; }; diff --git a/frontend/farm_designer/map/util.ts b/frontend/farm_designer/map/util.ts index a8f5d827bf..95030f196c 100644 --- a/frontend/farm_designer/map/util.ts +++ b/frontend/farm_designer/map/util.ts @@ -305,6 +305,9 @@ export const getMode = (): Mode => { if (store.getState().resources.consumers.farm_designer.profileOpen) { return Mode.profile; } + if (store.getState().resources.consumers.farm_designer.threeDCameraSelection) { + return Mode.cameraSelection; + } const panelSlug = Path.getSlug(Path.designer()); if ((panelSlug === "groups" || panelSlug === "zones") && Path.getSlug(Path.groups())) { return Mode.editGroup; } diff --git a/frontend/settings/__tests__/index_test.tsx b/frontend/settings/__tests__/index_test.tsx index 3872ae2508..7c2f81faf2 100644 --- a/frontend/settings/__tests__/index_test.tsx +++ b/frontend/settings/__tests__/index_test.tsx @@ -1,6 +1,5 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; -import { changeBlurableInputRTL } from "../../__test_support__/helpers"; import { RawDesignerSettings as DesignerSettings } from "../index"; import { DesignerSettingsProps } from "../interfaces"; import { BooleanSetting, NumericSetting } from "../../session_keys"; @@ -202,31 +201,6 @@ describe("", () => { NumericSetting.bot_origin_quadrant, 4); }); - it("toggles top down view", () => { - const p = fakeProps(); - p.settingsPanelState.farm_designer = true; - const { container } = render(); - const topDownSetting = getSettingByText(container, "open in top-down view"); - const button = topDownSetting.querySelector("button"); - if (!button) { throw new Error("Expected top down toggle button"); } - fireEvent.click(button); - expect(setWebAppConfigValueSpy) - .toHaveBeenCalledWith(BooleanSetting.top_down_view, true); - }); - - it("changes viewpoint heading", () => { - const p = fakeProps(); - p.settingsPanelState.farm_designer = true; - p.getConfigValue = key => key == NumericSetting.viewpoint_heading ? 0 : 2; - const { container } = render(); - const headingSetting = getSettingByText(container, "camera location upon open"); - const input = headingSetting.querySelector("input"); - if (!input) { throw new Error("Expected viewpoint heading input"); } - changeBlurableInputRTL(input, "270"); - expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( - NumericSetting.viewpoint_heading, 270); - }); - it("renders env editor", () => { const p = fakeProps(); p.searchTerm = "env"; diff --git a/frontend/settings/farm_designer_settings.tsx b/frontend/settings/farm_designer_settings.tsx index a4791ade59..a117f6cbde 100644 --- a/frontend/settings/farm_designer_settings.tsx +++ b/frontend/settings/farm_designer_settings.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Content, DeviceSetting } from "../constants"; +import { Actions, Content, DeviceSetting } from "../constants"; import { t } from "../i18next_wrapper"; import { setWebAppConfigValue } from "../config_storage/actions"; import { Help, ToggleButton, BlurableInput } from "../ui"; @@ -123,20 +123,20 @@ const DESIGNER_SETTINGS = description: Content.MAP_SWAP_XY, setting: BooleanSetting.xy_swap, }, + { + title: DeviceSetting.topDownView, + description: Content.TOP_DOWN_VIEW, + setting: BooleanSetting.top_down_view, + }, { title: DeviceSetting.mapOrigin, description: Content.MAP_ORIGIN, children: }, { - title: DeviceSetting.openInTopDownView, - description: Content.TOP_DOWN_VIEW, - setting: BooleanSetting.top_down_view, - }, - { - title: DeviceSetting.cameraLocationUponOpen, - description: Content.VIEWPOINT_HEADING, - numberSetting: NumericSetting.viewpoint_heading, + title: DeviceSetting.setCameraStartingLocation, + description: Content.CAMERA_STARTING_LOCATION, + children: , }, { title: DeviceSetting.cropMapImages, @@ -191,20 +191,17 @@ export const OriginSelector = (props: DesignerSettingsPropsBase) => { ; }; -export const HeadingSelector = (props: DesignerSettingsPropsBase) => { - const settingKey = NumericSetting.viewpoint_heading; - const heading = props.getConfigValue(settingKey); - const update = (value: number) => () => - props.dispatch(setWebAppConfigValue(settingKey, value)); - return
-
- {[180, 0, 90, 270].map(angle => -
)} -
-
; -}; +export interface CameraStartingLocationButtonProps { + dispatch: Function; +} + +export const CameraStartingLocationButton = + (props: CameraStartingLocationButtonProps) => ; diff --git a/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx index a97a678677..5ab1b13ba7 100644 --- a/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx +++ b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx @@ -1,29 +1,72 @@ import React from "react"; import { render } from "@testing-library/react"; -import { CameraSelectionUI } from "../camera_selection_ui"; +import { CameraSelectionUI, CameraSelectionUIProps } from "../camera_selection_ui"; import { clone } from "lodash"; +import * as lodash from "lodash"; import { INITIAL } from "../config"; import * as configStorageActions from "../../config_storage/actions"; -import { NumericSetting } from "../../session_keys"; +import { BooleanSetting, NumericSetting } from "../../session_keys"; import { actRenderer, createRenderer, unmountRenderer, } from "../../__test_support__/test_renderer"; +import * as threeFiber from "@react-three/fiber"; +import { Object3D, PerspectiveCamera } from "three"; describe("", () => { let setWebAppConfigValueSpy: jest.SpyInstance; + let debounceSpy: jest.SpyInstance; + let useFrameSpy: jest.SpyInstance; + let useThreeSpy: jest.SpyInstance; + let useStateSpy: jest.SpyInstance; + let frameHandler: threeFiber.RenderCallback | undefined; + let intersectObjects: jest.Mock; + let setFromCamera: jest.Mock; const mountedWrappers: ReturnType[] = []; beforeEach(() => { setWebAppConfigValueSpy = jest.spyOn(configStorageActions, "setWebAppConfigValue") .mockImplementation(jest.fn()); + debounceSpy = jest.spyOn(lodash, "debounce") + .mockImplementation((fn => fn) as typeof lodash.debounce); + frameHandler = undefined; + intersectObjects = jest.fn(() => []); + setFromCamera = jest.fn(); + useFrameSpy = jest.spyOn(threeFiber, "useFrame") + .mockImplementation((callback: threeFiber.RenderCallback) => { + frameHandler = callback; + // eslint-disable-next-line no-null/no-null + return null; + }); + useThreeSpy = jest.spyOn(threeFiber, "useThree") + .mockReturnValue({ + camera: new PerspectiveCamera(), + gl: { + info: { + render: { calls: 0, triangles: 0, points: 0, lines: 0 }, + memory: { geometries: 0, textures: 0 }, + }, + }, + pointer: { x: 0, y: 0 }, + raycaster: { + setFromCamera, + intersectObjects, + }, + scene: { traverse: jest.fn() }, + size: { width: 800, height: 600 }, + }); + useStateSpy = jest.spyOn(React, "useState"); }); afterEach(() => { mountedWrappers.splice(0).forEach(wrapper => unmountRenderer(wrapper)); setWebAppConfigValueSpy.mockRestore(); + debounceSpy.mockRestore(); + useFrameSpy.mockRestore(); + useThreeSpy.mockRestore(); + useStateSpy.mockRestore(); }); const fakeConfig = () => { @@ -32,50 +75,167 @@ describe("", () => { return config; }; + const fakeProps = (): CameraSelectionUIProps => ({ + config: fakeConfig(), + dispatch: jest.fn(), + topDownAtStart: false, + }); + + const attachMeshRefs = (wrapper: ReturnType) => { + wrapper.root.findAll(node => node.props.ref) + .forEach((node, index) => { + node.props.ref({ + userData: node.props.userData, + uuid: `mesh-${index}`, + } as Object3D); + }); + }; + it("renders hidden by default", () => { - const wrapper = createRenderer( - ); + const wrapper = createRenderer(); mountedWrappers.push(wrapper); const group = wrapper.root.findAll(node => node.props.name == "camera-selection")[0]; expect(group?.props.visible).toEqual(false); }); + it("doesn't raycast when camera selection is hidden", () => { + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + actRenderer(() => { + frameHandler?.({} as never, 0); + }); + expect(setFromCamera).not.toHaveBeenCalled(); + expect(intersectObjects).not.toHaveBeenCalled(); + }); + it("renders unique heading marker", () => { - const config = fakeConfig(); - config.cameraSelectionView = true; - config.viewpointHeading = 45; - const { container } = render( - ); - expect(container.querySelectorAll(".spherehead").length).toEqual(5); + const p = fakeProps(); + p.config.cameraSelectionView = true; + p.config.viewpointHeading = 45; + const { container } = render(); + expect(container.querySelectorAll(".spherehead").length).toEqual(12); }); it("dispatches heading update", () => { - const config = fakeConfig(); - config.cameraSelectionView = true; - const dispatch = jest.fn(); - const wrapper = createRenderer( - ); + const p = fakeProps(); + p.config.cameraSelectionView = true; + const wrapper = createRenderer(); mountedWrappers.push(wrapper); const groups = wrapper.root.findAll(node => node.props.onClick); actRenderer(() => { - groups[0]?.props.onClick(); + groups[0]?.props.onClick({ stopPropagation: jest.fn() }); }); expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( NumericSetting.viewpoint_heading, 0); - expect(dispatch).toHaveBeenCalled(); + expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( + BooleanSetting.top_down_view, true); + expect(p.dispatch).toHaveBeenCalled(); }); it("handles missing dispatch", () => { - const config = fakeConfig(); - config.cameraSelectionView = true; - const wrapper = createRenderer( - ); + const p = fakeProps(); + p.config.cameraSelectionView = true; + p.dispatch = undefined; + const wrapper = createRenderer(); mountedWrappers.push(wrapper); const groups = wrapper.root.findAll(node => node.props.onClick); actRenderer(() => { - groups[0]?.props.onClick(); + groups[0]?.props.onClick({ stopPropagation: jest.fn() }); }); expect(setWebAppConfigValueSpy).not.toHaveBeenCalled(); }); + + it("updates marker color from raycast hover state", () => { + const p = fakeProps(); + p.config.cameraSelectionView = true; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + attachMeshRefs(wrapper); + intersectObjects.mockReturnValue([{ + object: { userData: { hovered: { angle: 45, topDown: false } } }, + }]); + + actRenderer(() => { + frameHandler?.({} as never, 0); + }); + + const hoveredSphere = wrapper.root.findAll(node => + node.props.name == "head" + && node.props.userData?.hovered?.angle == 45 + && node.props.userData?.hovered?.topDown === false)[0]; + expect(hoveredSphere).toBeTruthy(); + const material = hoveredSphere?.findAll(node => + node.props.color !== undefined)[0]; + expect(material?.props.color).toEqual("cyan"); + }); + + it("keeps default color when raycast finds no hovered marker", () => { + const p = fakeProps(); + p.config.cameraSelectionView = true; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + attachMeshRefs(wrapper); + intersectObjects.mockReturnValue([]); + + actRenderer(() => { + frameHandler?.({} as never, 0); + }); + + const sphere = wrapper.root.findAll(node => + node.props.name == "head" + && node.props.userData?.hovered?.angle == 45 + && node.props.userData?.hovered?.topDown === false)[0]; + const material = sphere?.findAll(node => + node.props.color !== undefined)[0]; + expect(material?.props.color).toEqual("orange"); + }); + + it("avoids repeated hover state updates for the same marker", () => { + const setHovered = jest.fn(); + useStateSpy.mockImplementationOnce(initial => + [initial as unknown, setHovered]); + const p = fakeProps(); + p.config.cameraSelectionView = true; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + attachMeshRefs(wrapper); + intersectObjects.mockReturnValue([{ + object: { userData: { hovered: { angle: 45, topDown: false } } }, + }]); + + actRenderer(() => { + frameHandler?.({} as never, 0); + frameHandler?.({} as never, 0); + }); + + expect(setHovered).toHaveBeenCalledTimes(1); + expect(setHovered).toHaveBeenCalledWith({ angle: 45, topDown: false }); + }); + + it("renders debug camera markers", () => { + const p = fakeProps(); + p.config.cameraSelectionView = true; + p.config.lightsDebug = true; + const { container } = render(); + expect(container.querySelectorAll(".line").length).toEqual(8); + }); + + it("dispatches non-top-down heading update", () => { + const p = fakeProps(); + p.config.cameraSelectionView = true; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const body = wrapper.root.findAll(node => + node.props.name == "body" + && node.props.userData?.hovered?.angle == 45 + && node.props.userData?.hovered?.topDown === false)[0]; + actRenderer(() => { + body?.props.onClick({ stopPropagation: jest.fn() }); + }); + expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( + NumericSetting.viewpoint_heading, 45); + expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( + BooleanSetting.top_down_view, false); + }); }); diff --git a/frontend/three_d_garden/__tests__/camera_test.ts b/frontend/three_d_garden/__tests__/camera_test.ts index 1e2fe6d0f6..8024241316 100644 --- a/frontend/three_d_garden/__tests__/camera_test.ts +++ b/frontend/three_d_garden/__tests__/camera_test.ts @@ -3,6 +3,7 @@ let mockIsDesktop = true; import { cameraInit, CameraInitProps, getDefaultCameraPosition, + GetDefaultCameraPositionProps, } from "../camera"; import * as devSupport from "../../settings/dev/dev_support"; import * as screenSize from "../../screen_size"; @@ -26,14 +27,15 @@ afterEach(() => { describe("cameraInit()", () => { const fakeProps = (): CameraInitProps => ({ topDown: false, - viewpointHeading: 0, + viewpointHeading: 45, + bedSize: { x: 3000, y: 1500 }, }); it("initializes camera", () => { mockDev = undefined; mockIsDesktop = true; expect(cameraInit(fakeProps())).toEqual({ - position: [2000, -4000, 2500], + position: [2192, -2192, 2500], target: [0, 0, 0], }); }); @@ -51,7 +53,7 @@ describe("cameraInit()", () => { mockDev = "{"; mockIsDesktop = true; expect(cameraInit(fakeProps())).toEqual({ - position: [2000, -4000, 2500], + position: [2192, -2192, 2500], target: [0, 0, 0], }); }); @@ -60,7 +62,7 @@ describe("cameraInit()", () => { mockDev = undefined; mockIsDesktop = false; expect(cameraInit(fakeProps())).toEqual({ - position: [5400, -2500, 3400], + position: [2475, -2475, 3400], target: [0, 0, 0], }); }); @@ -82,25 +84,47 @@ describe("cameraInit()", () => { const p = fakeProps(); p.viewpointHeading = 90; expect(cameraInit(p)).toEqual({ - position: [2000, 4000, 2500], + position: [3100, 0, 2500], target: [0, 0, 0], }); }); }); describe("getDefaultCameraPosition()", () => { + const fakeProps = (): GetDefaultCameraPositionProps => ({ + heading: 0, + bedSize: { x: 3000, y: 1500 }, + topDown: false, + visual: false, + }); + it("returns desktop position", () => { mockIsDesktop = true; - expect(getDefaultCameraPosition(180)).toEqual([-2000, 4000, 2500]); + const p = fakeProps(); + p.heading = 180; + expect(getDefaultCameraPosition(p)).toEqual([0, 3100, 2500]); }); it("returns mobile position", () => { mockIsDesktop = false; - expect(getDefaultCameraPosition(270)).toEqual([-5400, -2500, 3400]); + const p = fakeProps(); + p.heading = 270; + expect(getDefaultCameraPosition(p)).toEqual([-3500, 0, 3400]); }); it("returns top down position", () => { mockIsDesktop = true; - expect(getDefaultCameraPosition(90, true)).toEqual([5657, 0, 5000]); + const p = fakeProps(); + p.heading = 90; + p.topDown = true; + expect(getDefaultCameraPosition(p)).toEqual([3100, 0, 5000]); + }); + + it("returns camera location visual location", () => { + mockIsDesktop = true; + const p = fakeProps(); + p.heading = 180; + p.visual = true; + expect(getDefaultCameraPosition(p)).toEqual([0, 1600, 2500]); }); }); diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 59cf406a63..fa19452233 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -95,6 +95,35 @@ describe("", () => { expect(orbitControls.props.maxAzimuthAngle).toEqual(Math.PI / 2); }); + it("rounds top down heading up to the nearest 90 degrees", () => { + mockIsMobile = true; + const p = fakeProps(); + p.config.topDown = true; + p.config.viewpointHeading = 1; + const wrapper = createWrapper(p); + const orbitControls = wrapper.root.findByType(OrbitControls); + expect(orbitControls.props.minAzimuthAngle).toEqual(Math.PI / 2); + expect(orbitControls.props.maxAzimuthAngle).toEqual(Math.PI / 2); + }); + + it("scales top down zoom by bed length", () => { + const p = fakeProps(); + p.config.topDown = true; + p.config.bedLengthOuter = 6000; + const wrapper = createWrapper(p); + const camera = wrapper.root.findAll(node => node.props.name == "camera")[0]; + expect(camera?.props.zoom).toEqual(0.125); + }); + + it("increases top down zoom for shorter beds", () => { + const p = fakeProps(); + p.config.topDown = true; + p.config.bedLengthOuter = 1500; + const wrapper = createWrapper(p); + const camera = wrapper.root.findAll(node => node.props.name == "camera")[0]; + expect(camera?.props.zoom).toEqual(0.5); + }); + it("renders camera selection view", () => { const p = fakeProps(); p.config.cameraSelectionView = true; diff --git a/frontend/three_d_garden/camera.ts b/frontend/three_d_garden/camera.ts index 3cc54a69b0..91e0d11c4b 100644 --- a/frontend/three_d_garden/camera.ts +++ b/frontend/three_d_garden/camera.ts @@ -2,14 +2,16 @@ import { round } from "lodash"; import { isDesktop } from "../screen_size"; import { DevSettings } from "../settings/dev/dev_support"; import { Camera } from "./zoom_beacons_constants"; +import { AxisNumberProperty } from "../farm_designer/map/interfaces"; export interface CameraInitProps { topDown: boolean; viewpointHeading: number; + bedSize: AxisNumberProperty; } export const cameraInit = (props: CameraInitProps): Camera => { - const { topDown, viewpointHeading } = props; + const { topDown, viewpointHeading, bedSize } = props; const devCameraString = DevSettings.get3dCamera(); let devCamera; try { @@ -21,7 +23,12 @@ export const cameraInit = (props: CameraInitProps): Camera => { const topDownCameraPosition = topDown ? [0, 0, 5000] : undefined; const cameraPositionInit = topDownCameraPosition || devCamera?.position - || getDefaultCameraPosition(viewpointHeading); + || getDefaultCameraPosition({ + heading: viewpointHeading, + bedSize, + topDown: false, + visual: false, + }); const defaultCameraTarget = [0, 0, 0]; const topDownCameraTarget = topDown ? [0, 0, 0] : undefined; @@ -36,31 +43,45 @@ export const cameraInit = (props: CameraInitProps): Camera => { return initCamera; }; -export const getDefaultCameraPosition = ( - heading: number, - topDown = false, -): [number, number, number] => { - const radians = heading * Math.PI / 180; +const SMALL_FACTOR = 100; +const BIG_FACTOR = 500; - if (topDown) { - const phase = Math.PI / 2; - return [ - round(4000 * Math.SQRT2 * Math.cos(radians - phase)), - round(2000 * Math.SQRT2 * Math.sin(radians - phase)), - 5000, - ]; - } +export interface GetDefaultCameraPositionProps { + heading: number; + bedSize: AxisNumberProperty; + topDown: boolean; + visual: boolean; +} - const phase = Math.PI / 4; - return isDesktop() - ? [ - round(2000 * Math.SQRT2 * Math.cos(radians - phase)), - round(4000 * Math.SQRT2 * Math.sin(radians - phase)), - 2500, - ] - : [ - round(5400 * Math.SQRT2 * Math.cos(radians - phase)), - round(2500 * Math.SQRT2 * Math.sin(radians - phase)), - 3400, - ]; -}; +export const getDefaultCameraPosition = + (props: GetDefaultCameraPositionProps): [number, number, number] => { + const { heading, bedSize, topDown, visual } = props; + const angle = topDown ? heading : (heading - 45) % 360; + const radians = angle * Math.PI / 180; + const smallX = bedSize.x + SMALL_FACTOR; + const smallY = visual ? bedSize.y + SMALL_FACTOR : smallX; + const bigX = bedSize.x + BIG_FACTOR; + const bigY = visual ? bedSize.y + BIG_FACTOR : bigX; + + if (topDown) { + const phase = Math.PI / 2; + return [ + round(smallX * Math.cos(radians - phase)), + round(smallY * Math.sin(radians - phase)), + 5000, + ]; + } + + const phase = Math.PI / 4; + return isDesktop() + ? [ + round(smallX * Math.cos(radians - phase)), + round(smallY * Math.sin(radians - phase)), + 2500, + ] + : [ + round(bigX * Math.cos(radians - phase)), + round(bigY * Math.sin(radians - phase)), + 3400, + ]; + }; diff --git a/frontend/three_d_garden/camera_selection_ui.tsx b/frontend/three_d_garden/camera_selection_ui.tsx index 5f4a2fd2aa..2e85981a80 100644 --- a/frontend/three_d_garden/camera_selection_ui.tsx +++ b/frontend/three_d_garden/camera_selection_ui.tsx @@ -1,63 +1,190 @@ import React from "react"; import { Config } from "./config"; import { getDefaultCameraPosition } from "./camera"; -import { Cylinder, Sphere } from "@react-three/drei"; +import { ThreeEvent, useFrame, useThree } from "@react-three/fiber"; +import { Cylinder, Line, Sphere } from "@react-three/drei"; import { Group, MeshPhongMaterial } from "./components"; -import { uniq } from "lodash"; +import { debounce, uniq } from "lodash"; import { setWebAppConfigValue } from "../config_storage/actions"; -import { NumericSetting } from "../session_keys"; +import { BooleanSetting, NumericSetting } from "../session_keys"; import { Person } from "./scenes/props"; import { ASSETS } from "./constants"; +import { Actions } from "../constants"; +import { Object3D } from "three"; export interface CameraSelectionUIProps { config: Config; dispatch: Function | undefined; + topDownAtStart: boolean; +} + +interface Hovered { + angle: number; + topDown: boolean; } export const CameraSelectionUI = (props: CameraSelectionUIProps) => { const { config } = props; - const [hovered, setHovered] = React.useState(undefined); + const [hovered, setHovered] = React.useState(undefined); + const hoveredRef = React.useRef(undefined); + const markerRefs = + React.useRef>({}); + const markerRefCallbacks = + // eslint-disable-next-line func-call-spacing + React.useRef void>>({}); + const { camera, pointer, raycaster } = useThree(); + const setMarkerRef = React.useCallback( + (markerId: string) => { + markerRefCallbacks.current[markerId] ||= (node: Object3D | null) => { + markerRefs.current[markerId] = node; + }; + return markerRefCallbacks.current[markerId]; + }, + []); + useFrame(() => { + if (!config.cameraSelectionView) { return; } + raycaster.setFromCamera(pointer, camera); + const markerNodes = Object.values(markerRefs.current) + .filter((node): node is Object3D => !!node); + const intersection = raycaster + .intersectObjects(markerNodes, false) + .find(hit => !!hit.object.userData.hovered); + const nextHovered = intersection?.object.userData.hovered as + Hovered | undefined; + if (hoveredRef.current?.angle == nextHovered?.angle + && hoveredRef.current?.topDown == nextHovered?.topDown) { + return; + } + hoveredRef.current = nextHovered; + setHovered(nextHovered); + }); + const topDownSelected = props.topDownAtStart; + const common = { + config: props.config, + dispatch: props.dispatch, + topDownAtStart: props.topDownAtStart, + hovered, + setMarkerRef, + }; return - {uniq([0, 90, 180, 270, config.viewpointHeading]).map(angle => { - const selected = angle == config.viewpointHeading; - const baseColor = selected ? "blue" : "orange"; - const color = hovered == angle ? "cyan" : baseColor; - const position = getDefaultCameraPosition(angle, config.topDown); - const scaledPosition = position.map(p => p * 0.33) as [number, number, number]; - const height = scaledPosition[2] + config.bedHeight; - return setHovered(angle)} - onPointerOut={() => setHovered(undefined)} - onClick={() => props.dispatch && - props.dispatch(setWebAppConfigValue( - NumericSetting.viewpoint_heading, angle))}> - - - + {uniq([0, 90, 180, 270, + topDownSelected ? config.viewpointHeading : 0]) + .map(angle => + )} + {uniq([0, 90, 180, 270, 45, 135, 225, 315, + topDownSelected ? 0 : config.viewpointHeading]) + .map(angle => + )} + {config.lightsDebug && + uniq([0, 90, 180, 270, 45, 135, 225, 315]) + .map(angle => + )} + ; +}; + +interface CameraLocationProps extends Hovered { + config: Config; + dispatch: Function | undefined; + topDownAtStart: boolean; + hovered: Hovered | undefined; + setMarkerRef(markerId: string): (node: Object3D | null) => void; + debug: boolean; +} + +const CameraLocation = (props: CameraLocationProps) => { + const { + config, dispatch, topDownAtStart, hovered, + setMarkerRef, angle, topDown, debug, + } = props; + const markerId = `${topDown}-${debug}-${angle}`; + const isSelected = (topDownAtStart == topDown) + && angle == (config.viewpointHeading); + const isHovered = hovered?.angle == angle && hovered?.topDown == topDown; + const baseColor = isSelected ? "blue" : "orange"; + const color = isHovered ? "cyan" : baseColor; + const bedSize = { x: config.bedLengthOuter, y: config.bedWidthOuter }; + const position = getDefaultCameraPosition({ + heading: angle, + bedSize, + topDown, + visual: !debug, + }); + const baseScaleXY = debug ? 1 : 0.8; + const scale = topDown ? 0.1 : baseScaleXY; + const baseScaleZ = debug ? 1 : 0.8 * 0.25; + const zScale = topDown ? 0 : baseScaleZ; + const scaledPosition: [number, number, number] = [ + position[0] * scale, + position[1] * scale, + position[2] * zScale, + ]; + const height = config.bedHeight + scaledPosition[2]; + const click = debounce(() => { + if (dispatch) { + dispatch(setWebAppConfigValue( + NumericSetting.viewpoint_heading, angle)); + dispatch(setWebAppConfigValue( + BooleanSetting.top_down_view, topDown)); + dispatch({ + type: Actions.TOGGLE_3D_CAMERA_SELECTION, + payload: undefined, + }); + dispatch({ + type: Actions.TOGGLE_3D_TOP_DOWN_VIEW, + payload: topDown, + }); + } + }); + const hoveredData = { angle, topDown }; + const onClick = (e: ThreeEvent) => { + e.stopPropagation(); + click(); + }; + return + + + + + {!topDown && + rotation={[Math.PI / 2, 0, 0]} + onClick={onClick}> - + } + {!topDown && - ; - })} + rotation={[Math.PI / 2, 0, 0]} />} + + {debug && + } ; }; diff --git a/frontend/three_d_garden/fps_probe.tsx b/frontend/three_d_garden/fps_probe.tsx index c3e9949da9..3a9960b586 100644 --- a/frontend/three_d_garden/fps_probe.tsx +++ b/frontend/three_d_garden/fps_probe.tsx @@ -107,7 +107,8 @@ export const FPSProbe = () => { const linesToLog = Object.entries(linesToLogObj) .map(([key, value]) => `${key}: ${value}`) .join("\n"); - console.log(linesToLog); + const doLog = (localStorage.getItem("FPS_LOGS") ?? "true") == "true"; + doLog && console.log(linesToLog); reportCount.current += 1; const doReport = !(reportCount.current % REPORT_EVERY_N); const average = Math.round(samples.current @@ -119,7 +120,7 @@ export const FPSProbe = () => { total: samples.current.length, }; doReport && window.logStore?.log("3D Garden FPS", report, "info"); - console.log(report); + doLog && console.log(report); frameCount.current = 0; lastTime.current = now; } diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 4b86b01ba5..f3dcde67e1 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -36,9 +36,12 @@ import { convertPlants } from "../../../farm_designer/three_d_garden_map"; import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import { setMockInstanceId } from "../../../__test_support__/three_d_mocks"; import { PLANT_ICON_ATLAS } from "../plant_icon_atlas"; +import { Mode } from "../../../farm_designer/map/interfaces"; +import * as mapUtil from "../../../farm_designer/map/util"; describe("", () => { let reactUseRefSpy: jest.SpyInstance; + let getModeSpy: jest.SpyInstance; beforeEach(() => { mockRefImpl = () => ({ @@ -63,10 +66,12 @@ describe("", () => { clock: { getElapsedTime: jest.fn(() => 0) }, camera: { quaternion: new Quaternion() }, })); + getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); }); afterEach(() => { reactUseRefSpy.mockRestore(); + getModeSpy.mockRestore(); delete PLANT_ICON_ATLAS["/crops/icons/beet.avif"]; }); @@ -139,6 +144,19 @@ describe("", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + it("doesn't navigate in camera selection mode", () => { + getModeSpy.mockReturnValue(Mode.cameraSelection); + setMockInstanceId(0); + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + mesh && fireEvent.click(mesh, { instanceId: 0 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("doesn't navigate with missing instanceId", () => { setMockInstanceId(undefined); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 93f4f910cc..202ff91d9e 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -223,6 +223,20 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); }); + it("doesn't navigate on spread click in camera selection mode", () => { + setMockInstanceId(0); + getModeSpy.mockReturnValue(Mode.cameraSelection); + queueMeshRef(); + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + mesh && fireEvent.click(mesh, { instanceId: 0 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it("updates instance colors on frame", () => { queueMeshRef(); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index eed8a07c8d..0bb4dfedbc 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -23,6 +23,7 @@ import { getPlantIconTexture, getPlantIconTextureUrl, } from "./plant_icon_atlas"; +import { Mode } from "../../farm_designer/map/interfaces"; export interface PlantInstancesProps { plants: ThreeDGardenPlant[]; @@ -103,7 +104,7 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => { if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; if (plant?.id && dispatch && visible && - !HOVER_OBJECT_MODES.includes(getMode())) { + ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { dispatch(setPanelOpen(true)); navigate(Path.plants(plant.id)); } diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index fa5269c72d..0b95b0639b 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -228,7 +228,7 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => { if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; if (plant?.id && dispatch && visible && - !HOVER_OBJECT_MODES.includes(getMode())) { + ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { dispatch(setPanelOpen(true)); navigate(Path.plants(plant.id)); } diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 25834d299f..8144b365d5 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -118,8 +118,9 @@ export const GardenModel = (props: GardenModelProps) => { }); const baseAngle = 0; + const heading = Math.ceil(config.viewpointHeading / 90) * 90; const topDownCameraAngle = config.topDown - ? baseAngle + config.viewpointHeading * Math.PI / 180 + ? baseAngle + heading * Math.PI / 180 : undefined; const camera = getCamera( config, @@ -128,6 +129,7 @@ export const GardenModel = (props: GardenModelProps) => { cameraInit({ topDown: config.topDown, viewpointHeading: config.viewpointHeading, + bedSize: { x: config.bedLengthOuter, y: config.bedWidthOuter }, })); const [controlsCamera, setControlsCamera] = // eslint-disable-next-line no-null/no-null @@ -161,6 +163,10 @@ export const GardenModel = (props: GardenModelProps) => { const showMoistureReadings = !!props.addPlantProps?.getConfigValue( BooleanSetting.show_sensor_readings); + const topDownAtStart = !!props.addPlantProps?.getConfigValue( + BooleanSetting.top_down_view); + const topDownZoomLevel = 0.25 * 3000 / config.bedLengthOuter; + // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); const sunFactorRef = React.useRef(1); @@ -237,7 +243,7 @@ export const GardenModel = (props: GardenModelProps) => { fov={40} near={10} far={BigDistance.far} position={camera.position} rotation={[0, 0, 0]} - zoom={config.topDown ? 0.25 : 1} + zoom={config.topDown ? topDownZoomLevel : 1} up={[0, 0, 1]} /> {controlsCamera && @@ -343,6 +349,9 @@ export const GardenModel = (props: GardenModelProps) => { - + ; }; diff --git a/frontend/three_d_garden/scenes/props/people.tsx b/frontend/three_d_garden/scenes/props/people.tsx index fca581d33c..fbdafbccf1 100644 --- a/frontend/three_d_garden/scenes/props/people.tsx +++ b/frontend/three_d_garden/scenes/props/people.tsx @@ -65,6 +65,7 @@ export const Person = (props: PersonProps) => { transparent={true} side={DoubleSide} opacity={0.4} + raycast={() => undefined} renderOrder={RenderOrder.one} /> ; }; diff --git a/frontend/wizard/__tests__/checks_test.tsx b/frontend/wizard/__tests__/checks_test.tsx index eb9efa00bd..c1ed3c0353 100644 --- a/frontend/wizard/__tests__/checks_test.tsx +++ b/frontend/wizard/__tests__/checks_test.tsx @@ -779,6 +779,28 @@ describe("", () => { const { container } = render(); expect(container.innerHTML).toContain("map-orientation"); }); + + it("renders 3D camera selection controls", () => { + const p = fakeProps(); + p.getConfigValue = key => key == "three_d_garden"; + const { container } = render(); + expect(container.textContent).toContain("Set camera starting location"); + expect(container.textContent).not.toContain("Map origin"); + }); + + it("opens camera selection from 3D wizard orientation", () => { + const p = fakeProps(); + p.getConfigValue = key => key == "three_d_garden"; + const { container } = render(); + const button = + container.querySelector("button[title='Set camera starting location']"); + if (!button) { throw new Error("Expected camera starting location button"); } + fireEvent.click(button); + expect(p.dispatch).toHaveBeenCalledWith({ + type: Actions.TOGGLE_3D_CAMERA_SELECTION, + payload: undefined, + }); + }); }); describe("", () => { diff --git a/frontend/wizard/__tests__/data_test.ts b/frontend/wizard/__tests__/data_test.ts index fdef9d6917..a28a7a79a8 100644 --- a/frontend/wizard/__tests__/data_test.ts +++ b/frontend/wizard/__tests__/data_test.ts @@ -1,5 +1,6 @@ import { uniq } from "lodash"; import { fakeWizardStepResult } from "../../__test_support__/fake_state/resources"; +import { BooleanSetting } from "../../session_keys"; import { setupProgressString, WizardStepSlug, WIZARD_SECTIONS, WIZARD_STEPS, WIZARD_STEP_SLUGS, @@ -70,4 +71,26 @@ describe("data check", () => { }); expect(expressSections.length).toEqual(sections.length - 1); }); + + it("uses camera selection outcome for 3D map orientation", () => { + const steps = WIZARD_STEPS({ + firmwareHardware: "farmduino_k18", + getConfigValue: key => key == BooleanSetting.three_d_garden, + }); + const mapOrientation = steps.find(step => + step.slug == WizardStepSlug.mapOrientation); + expect(mapOrientation?.outcomes.map(outcome => outcome.slug)) + .toEqual(["cameraSelection"]); + }); + + it("keeps 2D map orientation outcomes when 3D is disabled", () => { + const steps = WIZARD_STEPS({ + firmwareHardware: "farmduino_k18", + getConfigValue: () => false, + }); + const mapOrientation = steps.find(step => + step.slug == WizardStepSlug.mapOrientation); + expect(mapOrientation?.outcomes.map(outcome => outcome.slug)) + .toEqual(["rotated", "incorrectOrigin"]); + }); }); diff --git a/frontend/wizard/checks.tsx b/frontend/wizard/checks.tsx index 04471fda85..d94087803c 100644 --- a/frontend/wizard/checks.tsx +++ b/frontend/wizard/checks.tsx @@ -69,7 +69,7 @@ import { } from "farmbot/dist/resources/configs/web_app"; import { GetWebAppConfigValue, toggleWebAppBool } from "../config_storage/actions"; import { PLACEHOLDER_FARMBOT } from "../photos/images/image_flipper"; -import { OriginSelector } from "../settings/farm_designer_settings"; +import { CameraStartingLocationButton, OriginSelector } from "../settings/farm_designer_settings"; import { Sensors } from "../sensors"; import { DropdownConfig, @@ -568,11 +568,18 @@ export const SelectMapOrigin = (props: WizardOutcomeComponentProps) => getConfigValue={props.getConfigValue} /> ; -export const MapOrientation = (props: WizardOutcomeComponentProps) => -
- - +export const MapOrientation = (props: WizardOutcomeComponentProps) => { + const is3D = !!props.getConfigValue(BooleanSetting.three_d_garden); + return
+ {!is3D && } + {!is3D && } + {is3D &&
+ + +
}
; +}; export const PeripheralsCheck = (props: WizardStepComponentProps) => { const peripherals = uniq(selectAllPeripherals(props.resources)); diff --git a/frontend/wizard/data.ts b/frontend/wizard/data.ts index 9ce087a278..45777e1f52 100644 --- a/frontend/wizard/data.ts +++ b/frontend/wizard/data.ts @@ -190,6 +190,7 @@ export enum WizardStepSlug { // eslint-disable-next-line complexity export const WIZARD_STEPS = (props: WizardStepDataProps): WizardSteps => { const { firmwareHardware } = props; + const is3D = !!props.getConfigValue?.(BooleanSetting.three_d_garden); const xySwap = !!props.getConfigValue?.(BooleanSetting.xy_swap); const positiveMovementInstruction = (swap: boolean) => swap @@ -511,18 +512,30 @@ export const WIZARD_STEPS = (props: WizardStepDataProps): WizardSteps => { component: MapOrientation, question: t("Does the virtual FarmBot match your real life FarmBot?"), outcomes: [ - { - slug: "rotated", - description: t("The map is rotated incorrectly"), - tips: "", - component: RotateMapToggle, - }, - { - slug: "incorrectOrigin", - description: t("The map origin is in a different corner"), - tips: t("Select the correct map origin."), - component: SelectMapOrigin, - }, + ...(is3D + ? [] + : [{ + slug: "rotated", + description: t("The map is rotated incorrectly"), + tips: "", + component: RotateMapToggle, + }]), + ...(is3D + ? [] + : [{ + slug: "incorrectOrigin", + description: t("The map origin is in a different corner"), + tips: t("Select the correct map origin."), + component: SelectMapOrigin, + }]), + ...(is3D + ? [{ + slug: "cameraSelection", + description: t("The camera location is incorrect"), + tips: SetupWizardContent.SET_CAMERA_LOCATION, + component: MapOrientation, + }] + : []), ], }, { From 3b1ab82832a4fdd49557943b06caad748a0b47d9 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 21 Apr 2026 19:24:45 -0700 Subject: [PATCH 07/11] improve 3D camera angles --- ...22013033_change_viewpoint_heading_default.rb | 5 +++++ db/structure.sql | 6 +++++- frontend/settings/default_values.ts | 2 +- .../__tests__/camera_selection_ui_test.tsx | 13 +++++++------ .../three_d_garden/__tests__/camera_test.ts | 16 ++++++++-------- frontend/three_d_garden/camera.ts | 14 ++++++++------ frontend/three_d_garden/camera_selection_ui.tsx | 17 ++++++++++------- frontend/three_d_garden/config.ts | 2 +- frontend/three_d_garden/scenes/props/people.tsx | 2 +- 9 files changed, 46 insertions(+), 31 deletions(-) create mode 100644 db/migrate/20260422013033_change_viewpoint_heading_default.rb diff --git a/db/migrate/20260422013033_change_viewpoint_heading_default.rb b/db/migrate/20260422013033_change_viewpoint_heading_default.rb new file mode 100644 index 0000000000..de72f4efc1 --- /dev/null +++ b/db/migrate/20260422013033_change_viewpoint_heading_default.rb @@ -0,0 +1,5 @@ +class ChangeViewpointHeadingDefault < ActiveRecord::Migration[8.1] + def change + change_column_default(:web_app_configs, :viewpoint_heading, from: 0, to: 30) + end +end diff --git a/db/structure.sql b/db/structure.sql index 7e7751df36..d9cef71e9b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2037,7 +2037,9 @@ CREATE TABLE public.web_app_configs ( show_missed_step_plot boolean DEFAULT false, enable_3d_electronics_box_top boolean DEFAULT true, three_d_garden boolean DEFAULT false, - dark_mode boolean DEFAULT true + dark_mode boolean DEFAULT true, + top_down_view boolean DEFAULT false, + viewpoint_heading integer DEFAULT 30 ); @@ -3763,6 +3765,8 @@ ALTER TABLE ONLY public.users SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260422013033'), +('20260417190743'), ('20260305192457'), ('20250930204600'), ('20250925195004'), diff --git a/frontend/settings/default_values.ts b/frontend/settings/default_values.ts index 6fd01aa839..21efd070bb 100644 --- a/frontend/settings/default_values.ts +++ b/frontend/settings/default_values.ts @@ -90,7 +90,7 @@ const DEFAULT_WEB_APP_CONFIG_VALUES: Record = { three_d_garden: false, dark_mode: true, ["top_down_view" as Key]: false, - ["viewpoint_heading" as Key]: 0, + ["viewpoint_heading" as Key]: 30, }; const DEFAULT_EXPRESS_WEB_APP_CONFIG_VALUES = diff --git a/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx index 5ab1b13ba7..7abfd088c8 100644 --- a/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx +++ b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx @@ -112,7 +112,7 @@ describe("", () => { it("renders unique heading marker", () => { const p = fakeProps(); p.config.cameraSelectionView = true; - p.config.viewpointHeading = 45; + p.config.viewpointHeading = 30; const { container } = render(); expect(container.querySelectorAll(".spherehead").length).toEqual(12); }); @@ -153,7 +153,7 @@ describe("", () => { mountedWrappers.push(wrapper); attachMeshRefs(wrapper); intersectObjects.mockReturnValue([{ - object: { userData: { hovered: { angle: 45, topDown: false } } }, + object: { userData: { hovered: { angle: 30, topDown: false } } }, }]); actRenderer(() => { @@ -162,7 +162,7 @@ describe("", () => { const hoveredSphere = wrapper.root.findAll(node => node.props.name == "head" - && node.props.userData?.hovered?.angle == 45 + && node.props.userData?.hovered?.angle == 30 && node.props.userData?.hovered?.topDown === false)[0]; expect(hoveredSphere).toBeTruthy(); const material = hoveredSphere?.findAll(node => @@ -173,6 +173,7 @@ describe("", () => { it("keeps default color when raycast finds no hovered marker", () => { const p = fakeProps(); p.config.cameraSelectionView = true; + p.config.viewpointHeading = 0; const wrapper = createRenderer(); mountedWrappers.push(wrapper); attachMeshRefs(wrapper); @@ -184,7 +185,7 @@ describe("", () => { const sphere = wrapper.root.findAll(node => node.props.name == "head" - && node.props.userData?.hovered?.angle == 45 + && node.props.userData?.hovered?.angle == 30 && node.props.userData?.hovered?.topDown === false)[0]; const material = sphere?.findAll(node => node.props.color !== undefined)[0]; @@ -228,13 +229,13 @@ describe("", () => { mountedWrappers.push(wrapper); const body = wrapper.root.findAll(node => node.props.name == "body" - && node.props.userData?.hovered?.angle == 45 + && node.props.userData?.hovered?.angle == 30 && node.props.userData?.hovered?.topDown === false)[0]; actRenderer(() => { body?.props.onClick({ stopPropagation: jest.fn() }); }); expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( - NumericSetting.viewpoint_heading, 45); + NumericSetting.viewpoint_heading, 30); expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( BooleanSetting.top_down_view, false); }); diff --git a/frontend/three_d_garden/__tests__/camera_test.ts b/frontend/three_d_garden/__tests__/camera_test.ts index 8024241316..399192f681 100644 --- a/frontend/three_d_garden/__tests__/camera_test.ts +++ b/frontend/three_d_garden/__tests__/camera_test.ts @@ -35,7 +35,7 @@ describe("cameraInit()", () => { mockDev = undefined; mockIsDesktop = true; expect(cameraInit(fakeProps())).toEqual({ - position: [2192, -2192, 2500], + position: [2475, -2475, 2500], target: [0, 0, 0], }); }); @@ -53,7 +53,7 @@ describe("cameraInit()", () => { mockDev = "{"; mockIsDesktop = true; expect(cameraInit(fakeProps())).toEqual({ - position: [2192, -2192, 2500], + position: [2475, -2475, 2500], target: [0, 0, 0], }); }); @@ -62,7 +62,7 @@ describe("cameraInit()", () => { mockDev = undefined; mockIsDesktop = false; expect(cameraInit(fakeProps())).toEqual({ - position: [2475, -2475, 3400], + position: [4596, -4596, 3400], target: [0, 0, 0], }); }); @@ -84,7 +84,7 @@ describe("cameraInit()", () => { const p = fakeProps(); p.viewpointHeading = 90; expect(cameraInit(p)).toEqual({ - position: [3100, 0, 2500], + position: [3500, 0, 2500], target: [0, 0, 0], }); }); @@ -102,14 +102,14 @@ describe("getDefaultCameraPosition()", () => { mockIsDesktop = true; const p = fakeProps(); p.heading = 180; - expect(getDefaultCameraPosition(p)).toEqual([0, 3100, 2500]); + expect(getDefaultCameraPosition(p)).toEqual([0, 3500, 2500]); }); it("returns mobile position", () => { mockIsDesktop = false; const p = fakeProps(); p.heading = 270; - expect(getDefaultCameraPosition(p)).toEqual([-3500, 0, 3400]); + expect(getDefaultCameraPosition(p)).toEqual([-6500, 0, 3400]); }); it("returns top down position", () => { @@ -117,7 +117,7 @@ describe("getDefaultCameraPosition()", () => { const p = fakeProps(); p.heading = 90; p.topDown = true; - expect(getDefaultCameraPosition(p)).toEqual([3100, 0, 5000]); + expect(getDefaultCameraPosition(p)).toEqual([3500, 0, 5000]); }); it("returns camera location visual location", () => { @@ -125,6 +125,6 @@ describe("getDefaultCameraPosition()", () => { const p = fakeProps(); p.heading = 180; p.visual = true; - expect(getDefaultCameraPosition(p)).toEqual([0, 1600, 2500]); + expect(getDefaultCameraPosition(p)).toEqual([0, 2750, 2500]); }); }); diff --git a/frontend/three_d_garden/camera.ts b/frontend/three_d_garden/camera.ts index 91e0d11c4b..801f17ffa5 100644 --- a/frontend/three_d_garden/camera.ts +++ b/frontend/three_d_garden/camera.ts @@ -43,8 +43,8 @@ export const cameraInit = (props: CameraInitProps): Camera => { return initCamera; }; -const SMALL_FACTOR = 100; -const BIG_FACTOR = 500; +const SMALL_FACTOR = 2000; +const BIG_FACTOR = 5000; export interface GetDefaultCameraPositionProps { heading: number; @@ -58,10 +58,12 @@ export const getDefaultCameraPosition = const { heading, bedSize, topDown, visual } = props; const angle = topDown ? heading : (heading - 45) % 360; const radians = angle * Math.PI / 180; - const smallX = bedSize.x + SMALL_FACTOR; - const smallY = visual ? bedSize.y + SMALL_FACTOR : smallX; - const bigX = bedSize.x + BIG_FACTOR; - const bigY = visual ? bedSize.y + BIG_FACTOR : bigX; + const smallF = Math.min(SMALL_FACTOR, SMALL_FACTOR * (3000 / bedSize.x) ** 2); + const bigF = Math.min(BIG_FACTOR, BIG_FACTOR * (3000 / bedSize.x) ** 2); + const smallX = bedSize.x / 2 + smallF; + const smallY = visual ? bedSize.y / 2 + smallF : smallX; + const bigX = bedSize.x / 2 + bigF; + const bigY = visual ? bedSize.y / 2 + BIG_FACTOR : bigX; if (topDown) { const phase = Math.PI / 2; diff --git a/frontend/three_d_garden/camera_selection_ui.tsx b/frontend/three_d_garden/camera_selection_ui.tsx index 2e85981a80..d92258ac26 100644 --- a/frontend/three_d_garden/camera_selection_ui.tsx +++ b/frontend/three_d_garden/camera_selection_ui.tsx @@ -23,6 +23,9 @@ interface Hovered { topDown: boolean; } +const ORTHOGONAL_ANGLES = [0, 90, 180, 270]; +const ISO_ANGLES = [30, 150, 210, 330]; + export const CameraSelectionUI = (props: CameraSelectionUIProps) => { const { config } = props; const [hovered, setHovered] = React.useState(undefined); @@ -69,22 +72,22 @@ export const CameraSelectionUI = (props: CameraSelectionUIProps) => { return - {uniq([0, 90, 180, 270, - topDownSelected ? config.viewpointHeading : 0]) + {uniq(ORTHOGONAL_ANGLES.concat( + topDownSelected ? config.viewpointHeading : 0)) .map(angle => )} - {uniq([0, 90, 180, 270, 45, 135, 225, 315, - topDownSelected ? 0 : config.viewpointHeading]) + {uniq(ORTHOGONAL_ANGLES.concat(ISO_ANGLES).concat( + topDownSelected ? 0 : config.viewpointHeading)) .map(angle => )} {config.lightsDebug && - uniq([0, 90, 180, 270, 45, 135, 225, 315]) + uniq(ORTHOGONAL_ANGLES.concat(ISO_ANGLES)) .map(angle => { topDown, visual: !debug, }); - const baseScaleXY = debug ? 1 : 0.8; + const baseScaleXY = debug ? 1 : 0.5; const scale = topDown ? 0.1 : baseScaleXY; - const baseScaleZ = debug ? 1 : 0.8 * 0.25; + const baseScaleZ = debug ? 1 : 0.5 * 0.25; const zScale = topDown ? 0 : baseScaleZ; const scaledPosition: [number, number, number] = [ position[0] * scale, diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index c8de3d920b..84b538c40d 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -170,7 +170,7 @@ export const INITIAL: ConfigWithPosition = { heading: 0, perspective: true, topDown: false, - viewpointHeading: 0, + viewpointHeading: 30, cameraSelectionView: false, bot: true, laser: false, diff --git a/frontend/three_d_garden/scenes/props/people.tsx b/frontend/three_d_garden/scenes/props/people.tsx index fbdafbccf1..eb4fd22cde 100644 --- a/frontend/three_d_garden/scenes/props/people.tsx +++ b/frontend/three_d_garden/scenes/props/people.tsx @@ -66,6 +66,6 @@ export const Person = (props: PersonProps) => { side={DoubleSide} opacity={0.4} raycast={() => undefined} - renderOrder={RenderOrder.one} /> + renderOrder={RenderOrder.clouds} /> ; }; From b9b81e9e3465122b51f396d70c1f7320309912b5 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 21 Apr 2026 20:19:13 -0700 Subject: [PATCH 08/11] fix 3D camera selection issues --- .../__tests__/camera_selection_ui_test.tsx | 6 +++--- .../three_d_garden/__tests__/garden_model_test.tsx | 1 - frontend/three_d_garden/camera_selection_ui.tsx | 11 ++--------- frontend/three_d_garden/garden_model.tsx | 11 +++++++---- 4 files changed, 12 insertions(+), 17 deletions(-) diff --git a/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx index 7abfd088c8..8b1ad11bb3 100644 --- a/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx +++ b/frontend/three_d_garden/__tests__/camera_selection_ui_test.tsx @@ -227,12 +227,12 @@ describe("", () => { p.config.cameraSelectionView = true; const wrapper = createRenderer(); mountedWrappers.push(wrapper); - const body = wrapper.root.findAll(node => - node.props.name == "body" + const head = wrapper.root.findAll(node => + node.props.name == "head" && node.props.userData?.hovered?.angle == 30 && node.props.userData?.hovered?.topDown === false)[0]; actRenderer(() => { - body?.props.onClick({ stopPropagation: jest.fn() }); + head?.props.onClick({ stopPropagation: jest.fn() }); }); expect(setWebAppConfigValueSpy).toHaveBeenCalledWith( NumericSetting.viewpoint_heading, 30); diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index fa19452233..366979870f 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -130,7 +130,6 @@ describe("", () => { p.config.viewpointHeading = 45; const { container } = render(); expect(container.innerHTML).toContain("camera-selection"); - expect(container.innerHTML).toContain("person_2.avif"); }); it("renders no user plants", () => { diff --git a/frontend/three_d_garden/camera_selection_ui.tsx b/frontend/three_d_garden/camera_selection_ui.tsx index d92258ac26..5e7fdd923c 100644 --- a/frontend/three_d_garden/camera_selection_ui.tsx +++ b/frontend/three_d_garden/camera_selection_ui.tsx @@ -7,8 +7,6 @@ import { Group, MeshPhongMaterial } from "./components"; import { debounce, uniq } from "lodash"; import { setWebAppConfigValue } from "../config_storage/actions"; import { BooleanSetting, NumericSetting } from "../session_keys"; -import { Person } from "./scenes/props"; -import { ASSETS } from "./constants"; import { Actions } from "../constants"; import { Object3D } from "three"; @@ -132,7 +130,7 @@ const CameraLocation = (props: CameraLocationProps) => { position[1] * scale, position[2] * zScale, ]; - const height = config.bedHeight + scaledPosition[2]; + const height = config.bedZOffset + config.bedHeight + scaledPosition[2]; const click = debounce(() => { if (dispatch) { dispatch(setWebAppConfigValue( @@ -167,7 +165,7 @@ const CameraLocation = (props: CameraLocationProps) => { opacity={1} color={color} /> - {!topDown && + {!topDown && config.lightsDebug && { opacity={0.9} color={color} /> } - {!topDown && - } {debug && } diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 8144b365d5..93a673fa35 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -257,6 +257,8 @@ export const GardenModel = (props: GardenModelProps) => { enablePan={config.pan} dampingFactor={0.2} target={camera.target} + minZoom={config.lightsDebug ? 0 : 0.05} + maxZoom={10} minDistance={config.lightsDebug ? 50 : 500} maxDistance={config.lightsDebug ? BigDistance.devZoom : BigDistance.zoom} />} @@ -349,9 +351,10 @@ export const GardenModel = (props: GardenModelProps) => { - + {config.cameraSelectionView && + } ; }; From d0814a03d1424c7e331305a27fac2590b93fb62c Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 22 Apr 2026 11:59:44 -0700 Subject: [PATCH 09/11] rotate promo more on mobile and fix init --- frontend/promo/promo.tsx | 98 +++++++++++++---------- frontend/three_d_garden/garden/images.tsx | 17 +++- 2 files changed, 72 insertions(+), 43 deletions(-) diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index 3f204fbbf3..487a256432 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -16,6 +16,7 @@ import { ThreeDGardenPlant } from "../three_d_garden/garden"; import { TaggedGenericPointer } from "farmbot"; import { calculatePointPositions } from "./points"; import { SEASON_TIMINGS, SEASONS } from "./constants"; +import { isMobile } from "../screen_size"; const PROMO_BED_SIZES = [ { @@ -29,6 +30,7 @@ const PROMO_BED_SIZES = [ ]; type ThreeDPlantsCache = Record; +const PLANTS_CACHE: ThreeDPlantsCache = {}; const calcCacheKey = (config: Config): string => `${config.bedLengthOuter}x${config.bedWidthOuter}: ${config.plants}`; @@ -41,11 +43,38 @@ const calcPlantsCache = ( if (cache[cacheKey]) { return cache; } - const positions = calculatePlantPositions(config); - cache[cacheKey] = positions; - return cache; + return { + ...cache, + [cacheKey]: calculatePlantPositions(config), + }; +}; + +const prewarmPlantsCache = () => { + let next = PLANTS_CACHE; + PROMO_BED_SIZES.map(({ length, width }) => { + SEASONS.map(season => { + next = calcPlantsCache(next, { + ...INITIAL, + bedLengthOuter: length, + bedWidthOuter: width, + plants: season, + }); + }); + }); + Object.assign(PLANTS_CACHE, next); +}; + +const getCachedPlants = (config: Config) => { + const cacheKey = calcCacheKey(config); + const cachedPlants = PLANTS_CACHE[cacheKey]; + if (cachedPlants) { return cachedPlants; } + + Object.assign(PLANTS_CACHE, calcPlantsCache(PLANTS_CACHE, config)); + return PLANTS_CACHE[cacheKey] || []; }; +prewarmPlantsCache(); + export const getSeasonTimings = (currentSeason: string, step = 0) => { const seasons = SEASON_TIMINGS.map(s => s.season); const seasonIndex = seasons.indexOf(currentSeason); @@ -56,50 +85,32 @@ export const getSeasonTimings = (currentSeason: string, step = 0) => { }; export const Promo = () => { - const [config, setConfig] = React.useState(INITIAL); + const [config, setConfig] = React.useState(() => { + let next = INITIAL; + if (isMobile()) { + next = { ...next, viewpointHeading: 80 }; + } + next = modifyConfigsFromUrlParams(next); + return next; + }); const [toolTip, setToolTip] = React.useState({ timeoutId: 0, text: "" }); - const [activeFocus, setActiveFocus] = React.useState(""); + const [activeFocus, setActiveFocus] = React.useState(() => + getFocusFromUrlParams()); const common = { config, setConfig, toolTip, setToolTip, activeFocus, setActiveFocus, }; - React.useEffect(() => { - setConfig(modifyConfigsFromUrlParams(config)); - setActiveFocus(getFocusFromUrlParams()); - PROMO_BED_SIZES.map(({ length, width }) => { - SEASONS.map(season => { - const tmpConfig = { - ...INITIAL, - bedLengthOuter: length, - bedWidthOuter: width, - plants: season, - }; - setPlantsCache(calcPlantsCache(plantsCache, tmpConfig)); - }); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // intentionally empty dependency array - - const [plantsCache, setPlantsCache] = React.useState({}); - const [mapPoints, setMapPoints] = React.useState([]); - - React.useEffect(() => { - setPlantsCache(calcPlantsCache(plantsCache, config)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [config.plants, config.bedLengthOuter, config.bedWidthOuter]); - - React.useEffect(() => { - setMapPoints(calculatePointPositions(config)); + const mapPoints = React.useMemo(() => // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ + calculatePointPositions(config), [ config.soilSurface, config.soilHeight, config.soilSurfacePointCount, config.soilSurfaceVariance, config.bedXOffset, config.bedYOffset, config.bedWallThickness, config.bedLengthOuter, config.bedWidthOuter, ]); - const startTimeRef = React.useRef(performance.now() / 1000); + const startTimeRef = React.useRef(0); React.useEffect(() => { if (!config.animateSeasons) { return; } @@ -121,13 +132,16 @@ export const Promo = () => { startTimeRef.current = performance.now() / 1000; }, []); - const getPlants = () => { - const plants = plantsCache[calcCacheKey(config)] || []; - if (config.promoSpread) { - return plants.map(plant => ({ ...plant, id: 0 })); - } - return plants; - }; + const plants = React.useMemo(() => { + return getCachedPlants(config); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.plants, config.bedLengthOuter, config.bedWidthOuter]); + + const threeDPlants = React.useMemo(() => { + return config.promoSpread + ? plants.map(plant => ({ ...plant, id: 0 })) + : plants; + }, [plants, config.promoSpread]); return
@@ -140,7 +154,7 @@ export const Promo = () => { diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index 3ee0b11341..f33acd3a3e 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -91,6 +91,17 @@ export const ImageTexture = (props: ImageTextureProps) => { const extents = soilSurfaceExtents(props.config); const width = extents.x.max - extents.x.min; const height = extents.y.max - extents.y.min; + const textureSize = 1024; + const textureWidth = width >= height + ? textureSize + : Math.max(1, Math.round(textureSize * width / height)); + const textureHeight = height >= width + ? textureSize + : Math.max(1, Math.round(textureSize * height / width)); + const textureKey = [ + extents.x.min, extents.x.max, + extents.y.min, extents.y.max, + ].join(":"); const { bedXOffset, bedYOffset, bedWallThickness } = props.config; const soilTexture = useTexture(ASSETS.textures.soil + "?=soilT"); const color = getColorFromBrightness(props.config.soilBrightness); @@ -110,7 +121,11 @@ export const ImageTexture = (props: ImageTextureProps) => { const highlightActive = lastImageArray[0]?.highlighted; const commonProps = { width, height, bedWallThickness }; const mirrorTextureProps = getMirrorTextureProps(props.config); - return Date: Wed, 22 Apr 2026 13:50:23 -0700 Subject: [PATCH 10/11] upgrade deps and fix eslint errors --- Gemfile.lock | 22 +- bun.lock | 154 ++-- frontend/__test_support__/bun_test_setup.ts | 12 +- frontend/__test_support__/three_d_mocks.tsx | 2 +- frontend/__tests__/loading_plant_test.tsx | 2 +- frontend/__tests__/revert_to_english_test.ts | 2 +- .../api/__tests__/crud_data_tracking_test.ts | 8 +- frontend/api/__tests__/crud_destroy_test.ts | 8 +- frontend/config/__tests__/actions_test.ts | 4 +- .../__tests__/connect_device/index_test.ts | 2 +- .../__tests__/pin_form_fields_test.tsx | 3 +- .../move/__tests__/take_photo_button_test.tsx | 2 +- frontend/demo/lua_runner/index.ts | 4 +- frontend/demo/lua_runner/run.ts | 6 +- frontend/devices/__tests__/actions_test.ts | 8 +- frontend/devices/actions.ts | 3 +- .../farm_designer/__tests__/index_test.tsx | 2 +- .../__tests__/location_info_test.tsx | 2 +- .../__tests__/three_d_garden_map_test.tsx | 2 +- .../map/__tests__/garden_map_test.tsx | 24 +- .../__tests__/selection_box_actions_test.ts | 9 +- .../plants/__tests__/plant_actions_test.ts | 9 +- .../map/profile/__tests__/options_test.tsx | 2 +- .../map/profile/__tests__/viewer_test.tsx | 2 +- .../__tests__/add_farm_event_test.tsx | 5 +- .../__tests__/edit_farm_event_test.tsx | 7 +- .../__tests__/edit_fe_form_test.tsx | 2 +- .../__tests__/farm_event_repeat_form_test.tsx | 4 +- .../__tests__/farm_events_test.tsx | 4 +- frontend/farmware/__tests__/actions_test.ts | 4 +- .../farmware/panel/__tests__/info_test.tsx | 6 +- frontend/folders/__tests__/actions_test.ts | 4 +- frontend/folders/__tests__/component_test.tsx | 6 +- .../__tests__/demo_login_option_test.tsx | 4 +- .../front_page/__tests__/front_page_test.tsx | 16 +- frontend/front_page/__tests__/login_test.tsx | 4 +- .../__tests__/resend_verification_test.tsx | 2 +- frontend/help/documentation.tsx | 8 +- frontend/logs/__tests__/index_test.tsx | 2 +- .../__tests__/settings_menu_test.tsx | 2 +- frontend/messages/__tests__/actions_test.ts | 6 +- frontend/messages/__tests__/cards_test.tsx | 10 +- frontend/messages/__tests__/reducer_test.ts | 3 +- .../__tests__/password_reset_test.tsx | 2 +- .../photos/images/__tests__/photos_test.tsx | 2 +- .../__tests__/image_filter_menu_test.tsx | 4 +- frontend/plants/__tests__/crop_info_test.tsx | 2 +- frontend/plants/crop_info.tsx | 8 +- .../point_groups/__tests__/actions_test.ts | 2 +- .../criteria/__tests__/component_test.tsx | 10 +- .../criteria/__tests__/show_test.tsx | 8 +- frontend/points/__tests__/point_info_test.tsx | 2 +- frontend/promo/__tests__/plants_test.ts | 2 +- frontend/promo/plants.ts | 4 +- .../__tests__/create_refresh_trigger_test.ts | 2 +- frontend/redux/__tests__/root_reducer_test.ts | 2 +- .../editor/__tests__/state_to_props_test.ts | 2 +- .../regimens/list/__tests__/list_test.tsx | 6 +- frontend/resources/reducer_support.ts | 2 +- frontend/resources/selectors_by_id.ts | 2 +- frontend/resources/sequence_meta.ts | 2 +- .../__tests__/garden_snapshot_test.tsx | 4 +- .../sensors/__tests__/sensor_list_test.tsx | 8 +- frontend/sequences/__tests__/actions_test.ts | 2 +- .../__tests__/request_auto_generation_test.ts | 4 +- .../sequences/__tests__/sequences_test.tsx | 2 +- .../__tests__/default_value_form_test.tsx | 2 +- .../__tests__/variable_form_test.tsx | 2 +- .../sequences/panel/__tests__/list_test.tsx | 2 +- .../panel/__tests__/preview_test.tsx | 4 +- .../__tests__/step_title_bar_test.tsx | 3 +- .../__tests__/tile_execute_test.tsx | 2 +- .../__tests__/tile_lua_support_test.tsx | 4 +- .../__tests__/tile_send_message_test.tsx | 2 +- .../pin_support/__tests__/mode_test.tsx | 4 +- .../__tests__/component_test.tsx | 2 +- .../tile_mark_as/__tests__/component_test.tsx | 2 +- .../step_ui/__tests__/step_header_test.tsx | 4 +- .../step_ui/__tests__/step_radio_test.tsx | 6 +- .../step_ui/__tests__/step_wrapper_test.tsx | 2 +- .../__tests__/farm_designer_settings_test.tsx | 4 +- .../__tests__/account_settings_test.tsx | 2 +- .../account/__tests__/actions_test.ts | 4 +- .../__tests__/change_password_test.tsx | 4 +- .../dangerous_delete_widget_test.tsx | 2 +- frontend/settings/dev/dev_settings.tsx | 15 +- .../settings/fbos_settings/farmbot_os_row.tsx | 19 +- .../__tests__/axis_settings_test.tsx | 3 +- .../__tests__/parameter_management_test.tsx | 2 +- frontend/settings/maybe_highlight.tsx | 94 +- .../pin_bindings/__tests__/model_test.tsx | 6 +- .../__tests__/change_ownership_form_test.tsx | 2 +- .../__tests__/create_transfer_cert_test.ts | 2 +- .../__tests__/transfer_ownership_test.ts | 2 +- frontend/sync/__tests__/actions_test.ts | 2 +- .../__tests__/camera_selection_ui_test.tsx | 4 +- .../three_d_garden/__tests__/camera_test.ts | 2 +- .../__tests__/components_test.tsx | 2 +- .../__tests__/garden_model_test.tsx | 10 +- .../__tests__/group_order_visual_test.tsx | 2 +- .../three_d_garden/bed/__tests__/bed_test.tsx | 3 +- frontend/three_d_garden/bed/bed.tsx | 40 +- .../__tests__/pointer_objects_test.tsx | 5 +- .../bed/objects/pointer_objects.tsx | 4 +- frontend/three_d_garden/bot/bot.tsx | 25 +- .../components/__tests__/gantry_beam_test.tsx | 2 +- .../__tests__/suction_animation_test.tsx | 11 +- .../bot/components/__tests__/tools_test.tsx | 10 +- .../__tests__/water_stream_test.tsx | 2 +- .../three_d_garden/bot/components/tools.tsx | 576 +++++++------ .../three_d_garden/bot/parts/cross_slide.tsx | 802 +++++++++--------- .../bot/parts/seed_trough_assembly.tsx | 44 +- .../bot/parts/seed_trough_holder.tsx | 38 +- .../three_d_garden/bot/parts/soil_sensor.tsx | 292 +++---- .../bot/parts/vacuum_pump_cover.tsx | 36 +- frontend/three_d_garden/fps_probe.tsx | 2 +- .../garden/__tests__/plant_instances_test.tsx | 10 +- .../garden/__tests__/plants_test.tsx | 6 +- .../three_d_garden/garden/plant_instances.tsx | 14 +- frontend/three_d_garden/garden/plants.tsx | 4 +- frontend/three_d_garden/garden/sun.tsx | 20 +- frontend/three_d_garden/garden_model.tsx | 10 +- .../tool_slot_edit_components_test.tsx | 6 +- .../tos_update/__tests__/component_test.tsx | 2 +- frontend/ui/__tests__/color_picker_test.tsx | 2 +- frontend/ui/__tests__/delete_button_test.tsx | 2 +- frontend/ui/__tests__/filter_search_test.tsx | 6 +- frontend/util/__tests__/errors_test.ts | 2 +- frontend/util/__tests__/page_test.tsx | 6 +- .../weeds/__tests__/weeds_inventory_test.tsx | 2 +- frontend/wizard/__tests__/checks_test.tsx | 6 +- .../wizard/__tests__/prerequisites_test.tsx | 2 +- package.json | 54 +- 133 files changed, 1399 insertions(+), 1348 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f567c01167..9df3375765 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -87,7 +87,7 @@ GEM base64 (0.3.0) bcrypt (3.1.22) benchmark (0.5.0) - bigdecimal (4.1.1) + bigdecimal (4.1.2) brakeman (8.0.4) racc builder (3.3.0) @@ -133,14 +133,14 @@ GEM docile (1.4.1) drb (2.2.3) e2mmap (0.1.0) - erb (6.0.2) + erb (6.0.4) erubi (1.13.1) factory_bot (6.5.6) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faker (3.6.1) + faker (3.8.0) i18n (>= 1.8.11, < 2) faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) @@ -200,7 +200,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.19.3) + json (2.19.4) jsonapi-renderer (0.2.2) jwt (3.1.2) base64 @@ -236,10 +236,10 @@ GEM marcel (1.1.0) method_source (1.1.0) mini_mime (1.1.5) - minitest (6.0.3) + minitest (6.0.5) drb (~> 2.0) prism (~> 1.5) - multi_json (1.19.1) + multi_json (1.20.1) mutations (0.9.2) activesupport mutex_m (0.3.0) @@ -262,7 +262,7 @@ GEM orm_adapter (0.5.0) os (1.1.4) ostruct (0.6.3) - parallel (1.28.0) + parallel (2.0.1) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -300,7 +300,7 @@ GEM rack-cors (3.0.0) logger rack (>= 3.0.14) - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -338,7 +338,7 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rbtree (0.4.6) rdoc (7.2.0) erb @@ -383,11 +383,11 @@ GEM rspec-support (3.13.7) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.86.0) + rubocop (1.86.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (~> 1.10) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) diff --git a/bun.lock b/bun.lock index ee15fb8f44..edd6ff89b2 100644 --- a/bun.lock +++ b/bun.lock @@ -5,27 +5,27 @@ "": { "name": "farmbot-web-frontend", "dependencies": { - "@blueprintjs/core": "6.11.3", - "@blueprintjs/select": "6.1.8", + "@blueprintjs/core": "6.12.0", + "@blueprintjs/select": "6.1.9", "@monaco-editor/react": "4.7.0", "@react-spring/three": "10.0.3", "@react-three/drei": "10.7.7", - "@react-three/fiber": "9.5.0", + "@react-three/fiber": "9.6.0", "@rollbar/react": "1.0.0", - "@types/bun": "^1.3.11", + "@types/bun": "1.3.13", "@types/lodash": "4.17.24", "@types/markdown-it": "14.1.2", - "@types/markdown-it-emoji": "^3.0.1", + "@types/markdown-it-emoji": "3.0.1", "@types/promise-timeout": "1.3.3", "@types/react": "19.2.14", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", - "@types/react-test-renderer": "^19.1.0", - "@types/redux-immutable-state-invariant": "^2.1.4", - "@types/three": "0.183.1", + "@types/react-test-renderer": "19.1.0", + "@types/redux-immutable-state-invariant": "2.1.4", + "@types/three": "0.184.0", "@types/ws": "8.18.1", "@xterm/xterm": "6.0.0", - "axios": "1.14.0", + "axios": "1.15.2", "bowser": "2.14.1", "browser-speech": "1.1.1", "delaunator": "5.1.0", @@ -33,7 +33,7 @@ "farmbot": "15.9.3", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.0.3", + "i18next": "26.0.6", "lodash": "4.18.1", "markdown-it": "14.1.1", "markdown-it-emoji": "3.0.0", @@ -44,24 +44,24 @@ "promise-timeout": "1.3.0", "punycode": "2.3.1", "querystring-es3": "0.2.1", - "react": "19.2.4", + "react": "19.2.5", "react-color": "2.19.3", - "react-dom": "19.2.4", + "react-dom": "19.2.5", "react-redux": "9.2.0", - "react-router": "7.14.0", + "react-router": "7.14.2", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", "rollbar": "3.1.0", "suncalc": "1.9.0", "takeme": "0.12.0", - "three": "0.183.2", - "typescript": "6.0.2", + "three": "0.184.0", + "typescript": "6.0.3", "url": "0.11.4", }, "devDependencies": { "@eslint/js": "10.0.1", - "@happy-dom/global-registrator": "20.8.9", + "@happy-dom/global-registrator": "20.9.0", "@react-three/eslint-plugin": "0.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.9.1", @@ -71,17 +71,17 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.58.0", - "@typescript-eslint/parser": "8.58.0", - "eslint": "10.2.0", + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "eslint": "10.2.1", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jest": "29.15.1", + "eslint-plugin-jest": "29.15.2", "eslint-plugin-no-null": "1.0.2", "eslint-plugin-promise": "7.2.1", "eslint-plugin-react": "7.37.5", - "eslint-plugin-react-hooks": "7.0.1", - "happy-dom": "20.8.9", + "eslint-plugin-react-hooks": "7.1.1", + "happy-dom": "20.9.0", "jest": "30.3.0", "jest-canvas-mock": "2.5.2", "jest-cli": "30.3.0", @@ -92,14 +92,14 @@ "madge": "8.0.0", "path-browserify": "1.0.1", "playwright": "1.59.1", - "postcss": "^8.5.9", - "postcss-scss": "^4.0.9", + "postcss": "8.5.10", + "postcss-scss": "4.0.9", "raf": "3.4.1", - "react-test-renderer": "19.2.4", + "react-test-renderer": "19.2.5", "sass": "1.99.0", "sass-lint": "1.13.1", - "stylelint": "^17.6.0", - "stylelint-config-standard-scss": "^17.0.0", + "stylelint": "17.8.0", + "stylelint-config-standard-scss": "17.0.0", "ts-jest": "29.4.9", "tslint": "6.1.3", }, @@ -186,11 +186,11 @@ "@blueprintjs/colors": ["@blueprintjs/colors@5.1.16", "", { "dependencies": { "tslib": "~2.6.2" } }, "sha512-P9uX0Aj2TP9+6aUcori1iPl4snxM/Vgq0LZbhUl1l5bHTgNxxwm/0+IoS/SlQg93HBRl8KTAM1evEqtPbwV10A=="], - "@blueprintjs/core": ["@blueprintjs/core@6.11.3", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.8.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-pHApk7prF8aQeTUjcuVhoT7gA89BbDO2Qwn/XdD1/ewFawjK53YbYGuerLv2vQ6BmIMeFf2Cs6cM34XCtT0LWw=="], + "@blueprintjs/core": ["@blueprintjs/core@6.12.0", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/icons": "^6.9.0", "@floating-ui/react": "^0.27.13", "@popperjs/core": "^2.11.8", "classnames": "^2.3.1", "normalize.css": "^8.0.1", "react-popper": "^2.3.0", "react-transition-group": "^4.4.5", "tslib": "~2.6.2", "use-sync-external-store": "^1.2.0" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"], "bin": { "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" } }, "sha512-huBwfAU0/n4XG33C1xl4cWd/f+POtU11AL1wjTG/zWqyV4HRd57TE92z+SdioBbvCm6fmwEjNfjKAu1P1ojWxw=="], - "@blueprintjs/icons": ["@blueprintjs/icons@6.8.0", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-a281z8VTNesqVunBO2OigrPFxMRWHpYc84fN+xWbdN5hCiqQNv8ha6sqxeXHUsaTdnemiTH07thiZ8u8Uhfj4A=="], + "@blueprintjs/icons": ["@blueprintjs/icons@6.9.0", "", { "dependencies": { "change-case": "^4.1.2", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-pnwnw6dPARk7Q4CZ9ZKeVe9C8PO1OE9cv9DO4mPgNqJBIOrI9aVAWbHsfjqpbOPXx82/O65kNOQweP8foAzPRA=="], - "@blueprintjs/select": ["@blueprintjs/select@6.1.8", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.11.3", "@blueprintjs/icons": "^6.8.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-6cSoUDN5bYery4TBVEsHFeoZcQGUB26c8x+HJ+zrrjL1yyE1iK+0VTcW+kB0PW4J/fFN0vhVkveLKlrJQIcJcA=="], + "@blueprintjs/select": ["@blueprintjs/select@6.1.9", "", { "dependencies": { "@blueprintjs/colors": "^5.1.16", "@blueprintjs/core": "^6.12.0", "@blueprintjs/icons": "^6.9.0", "classnames": "^2.3.1", "tslib": "~2.6.2" }, "peerDependencies": { "@types/react": "18", "react": "18", "react-dom": "18" }, "optionalPeers": ["@types/react"] }, "sha512-PtHQl0MWr606/PGxvN3xjYYvMc+gLyeHy/FdzK0/SXK4s9dBCBXGbdJEo8yVtDo71jyrJXZfjUkJlf+KpqRibQ=="], "@cacheable/memory": ["@cacheable/memory@2.0.8", "", { "dependencies": { "@cacheable/utils": "^2.4.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw=="], @@ -228,19 +228,19 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@eslint/config-array": ["@eslint/config-array@0.23.4", "", { "dependencies": { "@eslint/object-schema": "^3.0.4", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow=="], + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.5.4", "", { "dependencies": { "@eslint/core": "^1.2.0" } }, "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], - "@eslint/core": ["@eslint/core@1.2.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A=="], + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], - "@eslint/object-schema": ["@eslint/object-schema@3.0.4", "", {}, "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw=="], + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.0", "levn": "^0.4.1" } }, "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], @@ -252,7 +252,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.8.9" } }, "sha512-DtZeRRHY9A/bisTJziUBBPrdnPui7+R185G/hzi6/Boymhqh7/wi53AY+IvQHS1+7OPaqfO/1XNpngNwthLz+A=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -394,7 +394,7 @@ "@react-three/eslint-plugin": ["@react-three/eslint-plugin@0.1.2", "", { "dependencies": { "@babel/runtime": "^7.17.8", "eslint": "^8.12.0" } }, "sha512-jenNIhvt+/1fb3NDr3M5vwF06U9euX6kI2SuAFltVKdQP2nzUPY+zdati2Rd67ewAKn0jfljQWT7DWIe6siChg=="], - "@react-three/fiber": ["@react-three/fiber@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA=="], + "@react-three/fiber": ["@react-three/fiber@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", "its-fine": "^2.0.0", "react-use-measure": "^2.1.7", "scheduler": "^0.27.0", "suspend-react": "^0.1.3", "use-sync-external-store": "^1.4.0", "zustand": "^5.0.3" }, "peerDependencies": { "expo": ">=43.0", "expo-asset": ">=8.4", "expo-file-system": ">=11.0", "expo-gl": ">=11.0", "react": ">=19 <19.3", "react-dom": ">=19 <19.3", "react-native": ">=0.78", "three": ">=0.156" }, "optionalPeers": ["expo", "expo-asset", "expo-file-system", "expo-gl", "react-dom", "react-native"] }, "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA=="], "@rollbar/react": ["@rollbar/react@1.0.0", "", { "dependencies": { "tiny-invariant": "^1.1.0" }, "peerDependencies": { "prop-types": "^15.7.2", "react": "16.x || 17.x || 18.x || 19.x", "rollbar": "^2.26.4 || ^3.0.0-alpha.3" } }, "sha512-e3S9K9k1BLNuqAFA/AD8XH4kcypClYWoMBuW5LO7fnu5Jy1/qiRFIgS5cFZpAFWQqJaSPQAqrLP6n41sH08X9A=="], @@ -444,7 +444,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/css-font-loading-module": ["@types/css-font-loading-module@0.0.7", "", {}, "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q=="], @@ -508,7 +508,7 @@ "@types/suncalc": ["@types/suncalc@1.9.2", "", {}, "sha512-ATAGBHHfA1TlE2tjfidLyTcysjoT2JHHEAmWRULh73SU9UTn++j5fqHEW16X6Y/2Li87jEQXzgu4R/OOdlDqzw=="], - "@types/three": ["@types/three@0.183.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~1.0.1" } }, "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw=="], + "@types/three": ["@types/three@0.184.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA=="], "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], @@ -526,25 +526,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/type-utils": "8.58.0", "@typescript-eslint/utils": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.0", "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0" } }, "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -676,7 +676,7 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="], + "axios": ["axios@1.15.2", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], "babel-jest": ["babel-jest@30.3.0", "", { "dependencies": { "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ=="], @@ -722,7 +722,7 @@ "builtin-modules": ["builtin-modules@1.1.1", "", {}, "sha512-wxXCdllwGhI2kCC0MnvTGYTMvnVZTvqgypkiTI8Pa5tcz2i6VqsqwYGgqwXji+4RgCzms6EajE4IxiUH6HH8nQ=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "cache-base": ["cache-base@1.0.1", "", { "dependencies": { "collection-visit": "^1.0.0", "component-emitter": "^1.2.1", "get-value": "^2.0.6", "has-value": "^1.0.0", "isobject": "^3.0.1", "set-value": "^2.0.0", "to-object-path": "^0.3.0", "union-value": "^1.0.0", "unset-value": "^1.0.0" } }, "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ=="], @@ -984,7 +984,7 @@ "escope": ["escope@3.6.0", "", { "dependencies": { "es6-map": "^0.1.3", "es6-weak-map": "^2.0.1", "esrecurse": "^4.1.0", "estraverse": "^4.1.1" } }, "sha512-75IUQsusDdalQEW/G/2esa87J7raqdJF+Ca0/Xm5C3Q58Nr4yVYjZGp/P1+2xiEVgXRrA39dpRb8LcshajbqDQ=="], - "eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], + "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], @@ -994,7 +994,7 @@ "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], - "eslint-plugin-jest": ["eslint-plugin-jest@29.15.1", "", { "dependencies": { "@typescript-eslint/utils": "^8.0.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "jest": "*", "typescript": ">=4.8.4 <7.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "jest", "typescript"] }, "sha512-6BjyErCQauz3zfJvzLw/kAez2lf4LEpbHLvWBfEcG4EI0ZiRSwjoH2uZulMouU8kRkBH+S0rhqn11IhTvxKgKw=="], + "eslint-plugin-jest": ["eslint-plugin-jest@29.15.2", "", { "dependencies": { "@typescript-eslint/utils": "^8.0.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "jest": "*", "typescript": ">=4.8.4 <7.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "jest", "typescript"] }, "sha512-kEN4r9RZl1xcsb4arGq89LrcVdOUFII/JSCwtTPJyv16mDwmPrcuEQwpxqZHeINvcsd7oK5O/rhdGlxFRaZwvQ=="], "eslint-plugin-no-null": ["eslint-plugin-no-null@1.0.2", "", { "peerDependencies": { "eslint": ">=3.0.0" } }, "sha512-uRDiz88zCO/2rzGfgG15DBjNsgwWtWiSo4Ezy7zzajUgpnFIqd1TjepKeRmJZHEfBGu58o2a8S0D7vglvvhkVA=="], @@ -1002,7 +1002,7 @@ "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -1176,7 +1176,7 @@ "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], - "happy-dom": ["happy-dom@20.8.9", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], "has-ansi": ["has-ansi@2.0.0", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg=="], @@ -1226,7 +1226,7 @@ "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "i18next": ["i18next@26.0.3", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg=="], + "i18next": ["i18next@26.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1560,7 +1560,7 @@ "meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="], - "meshoptimizer": ["meshoptimizer@1.0.1", "", {}, "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g=="], + "meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1730,7 +1730,7 @@ "possible-typed-array-names": ["possible-typed-array-names@1.0.0", "", {}, "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q=="], - "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], "postcss-media-query-parser": ["postcss-media-query-parser@0.2.3", "", {}, "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig=="], @@ -1790,23 +1790,23 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": "cli.js" }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-color": ["react-color@2.19.3", "", { "dependencies": { "@icons/material": "^0.2.4", "lodash": "^4.17.15", "lodash-es": "^4.17.15", "material-colors": "^1.2.1", "prop-types": "^15.5.10", "reactcss": "^1.2.0", "tinycolor2": "^1.4.1" }, "peerDependencies": { "react": "*" } }, "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], - "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], + "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], "react-popper": ["react-popper@2.3.0", "", { "dependencies": { "react-fast-compare": "^3.0.1", "warning": "^4.0.2" }, "peerDependencies": { "@popperjs/core": "^2.0.0", "react": "^16.8.0 || ^17 || ^18", "react-dom": "^16.8.0 || ^17 || ^18" } }, "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" } }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="], + "react-router": ["react-router@7.14.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="], - "react-test-renderer": ["react-test-renderer@19.2.4", "", { "dependencies": { "react-is": "^19.2.4", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-Ttl5D7Rnmi6JGMUpri4UjB4BAN0FPs4yRDnu2XSsigCWOLm11o8GwRlVsh27ER+4WFqsGtrBuuv5zumUaRCmKw=="], + "react-test-renderer": ["react-test-renderer@19.2.5", "", { "dependencies": { "react-is": "^19.2.5", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-kwViRpdISMTpcpy5B6TSewfJzRjnajihRaj57ZmOWKD+SPN6k9LUM13O0pfOuW8ir6B6OOiAXwCRqOoVxRNykA=="], "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], @@ -2020,7 +2020,7 @@ "strip-json-comments": ["strip-json-comments@1.0.4", "", { "bin": "cli.js" }, "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg=="], - "stylelint": ["stylelint@17.6.0", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.1.1", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.8", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-tokrsMIVAR9vAQ/q3UVEr7S0dGXCi7zkCezPRnS2kqPUulvUh5Vgfwngrk4EoAoW7wnrThqTdnTFN5Ra7CaxIg=="], + "stylelint": ["stylelint@17.8.0", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-syntax-patches-for-csstree": "^1.1.2", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.1", "css-functions-list": "^3.3.3", "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", "file-entry-cache": "^11.1.2", "global-modules": "^2.0.0", "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", "is-plain-object": "^5.0.0", "mathml-tag-names": "^4.0.0", "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.5.9", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.0", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" } }, "sha512-oHkld9T60LDSaUQ4CSVc+tlt9eUoDlxhaGWShsUCKyIL14boZfmK5bSphZqx64aiC5tCqX+BsQMTMoSz8D1zIg=="], "stylelint-config-recommended": ["stylelint-config-recommended@18.0.0", "", { "peerDependencies": { "stylelint": "^17.0.0" } }, "sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg=="], @@ -2062,7 +2062,7 @@ "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], - "three": ["three@0.183.2", "", {}, "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ=="], + "three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="], "three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], @@ -2134,7 +2134,7 @@ "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], - "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], @@ -2380,6 +2380,8 @@ "@react-three/eslint-plugin/eslint": ["eslint@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.0", "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": "bin/eslint.js" }, "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ=="], + "@react-three/fiber/@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2478,7 +2480,7 @@ "eslint-plugin-import/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "eslint-plugin-jest/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/typescript-estree": "8.57.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ=="], + "eslint-plugin-jest/@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], "eslint-plugin-promise/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], @@ -2954,11 +2956,11 @@ "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1" } }, "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0" } }, "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.57.1", "", {}, "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.1", "@typescript-eslint/tsconfig-utils": "8.57.1", "@typescript-eslint/types": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], "eslint-plugin-promise/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3202,17 +3204,15 @@ "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A=="], - - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.1", "@typescript-eslint/types": "^8.57.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.0", "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.1", "", { "dependencies": { "@typescript-eslint/types": "8.57.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + "eslint-plugin-jest/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/frontend/__test_support__/bun_test_setup.ts b/frontend/__test_support__/bun_test_setup.ts index 040e58b2a9..b8d34187ea 100644 --- a/frontend/__test_support__/bun_test_setup.ts +++ b/frontend/__test_support__/bun_test_setup.ts @@ -28,7 +28,7 @@ const ensureSyntaxError = () => { writable: true, }); }; - assign(globalThis as unknown as Record); + assign(globalThis); assign(globalAny.window as unknown as Record | undefined); const windowCtor = globalAny.window?.constructor as | { prototype?: Record } @@ -74,13 +74,11 @@ const withAxiosDefaultExport = (factory: () => unknown) => () => { if (globalAny.jest?.mock) { const originalMock = globalAny.jest.mock.bind(globalAny.jest); - globalAny.jest.mock = ((specifier: string, factory?: unknown) => { - const moduleFactory = - typeof factory === "function" ? factory as () => unknown : undefined; + globalAny.jest.mock = ((specifier: string, factory?: () => unknown) => { return specifier === "axios" && typeof factory === "function" ? originalMock(specifier, - withAxiosDefaultExport(moduleFactory as () => unknown)) - : originalMock(specifier, factory as never); + withAxiosDefaultExport(factory)) + : originalMock(specifier, factory); }) as typeof globalAny.jest.mock; } @@ -278,7 +276,7 @@ beforeEach(() => { resetMutableFixture(globalAny.globalConfig, globalConfigBaseline); } else { globalAny.globalConfig = - cloneForReset(globalConfigBaseline) as Record; + cloneForReset(globalConfigBaseline); } globalThis.localStorage?.clear(); globalThis.sessionStorage?.clear(); diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 9eee54e122..6ec691e255 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -119,7 +119,7 @@ jest.mock("../three_d_garden/components", () => ({ instanceColor: { needsUpdate: false }, }) as unknown as THREE.InstancedMesh); return } + ref={ref} {...rest} />; }, ), diff --git a/frontend/__tests__/loading_plant_test.tsx b/frontend/__tests__/loading_plant_test.tsx index 612d277a89..39b1effce2 100644 --- a/frontend/__tests__/loading_plant_test.tsx +++ b/frontend/__tests__/loading_plant_test.tsx @@ -26,7 +26,7 @@ describe("", () => { }); it("clears initial loading text", () => { - const el = { outerHTML: "hidden" } as Pick; + const el = { outerHTML: "hidden" }; const collection = [el as unknown as Element] as unknown as HTMLCollectionOf; jest.spyOn(document, "getElementsByClassName") diff --git a/frontend/__tests__/revert_to_english_test.ts b/frontend/__tests__/revert_to_english_test.ts index 00d67b40fe..1099e08e32 100644 --- a/frontend/__tests__/revert_to_english_test.ts +++ b/frontend/__tests__/revert_to_english_test.ts @@ -4,7 +4,7 @@ import { revertToEnglish } from "../revert_to_english"; describe("revertToEnglish", () => { it("runs without throwing", async () => { jest.spyOn(I18n, "detectLanguage") - .mockResolvedValue({ lng: "en" } as never); + .mockResolvedValue({ lng: "en" }); await expect(Promise.resolve(revertToEnglish() as unknown)) .resolves.toBeUndefined(); diff --git a/frontend/api/__tests__/crud_data_tracking_test.ts b/frontend/api/__tests__/crud_data_tracking_test.ts index 821d9adbcf..eca611c877 100644 --- a/frontend/api/__tests__/crud_data_tracking_test.ts +++ b/frontend/api/__tests__/crud_data_tracking_test.ts @@ -56,7 +56,7 @@ describe("AJAX data tracking", () => { if (!destroy) { return; } const thunk = destroy(uuid); if (typeof thunk !== "function") { return; } - await thunk(dispatch as unknown as Function, () => + await thunk(dispatch, () => ({ resources: { index: resourceIndex() } })); expect(maybeStartTrackingModule.maybeStartTracking).toHaveBeenCalled(); }); @@ -69,7 +69,7 @@ describe("AJAX data tracking", () => { }); const saveAllAction = loadCrud().saveAll?.(r); if (typeof saveAllAction !== "function") { return; } - await saveAllAction(dispatch as unknown as Function); + await saveAllAction(dispatch); expect(maybeStartTrackingModule.maybeStartTracking).toHaveBeenCalled(); }); @@ -94,7 +94,7 @@ describe("AJAX data tracking", () => { email: "test@test.com" }); if (typeof initSaveGetIdAction !== "function") { return; } - const result = initSaveGetIdAction(statefulDispatch as unknown as Function); + const result = initSaveGetIdAction(statefulDispatch); if (result && typeof result === "object" && result && "catch" in result) { await (result as Promise).catch(() => { }); } @@ -107,7 +107,7 @@ describe("AJAX data tracking", () => { email: "test@test.com" }); if (typeof action !== "function") { return; } - await action(dispatch as unknown as Function); + await action(dispatch); expect(maybeStartTrackingModule.maybeStartTracking).toHaveBeenCalled(); }); }); diff --git a/frontend/api/__tests__/crud_destroy_test.ts b/frontend/api/__tests__/crud_destroy_test.ts index 0bc91f20c6..6245f3a0e8 100644 --- a/frontend/api/__tests__/crud_destroy_test.ts +++ b/frontend/api/__tests__/crud_destroy_test.ts @@ -69,7 +69,7 @@ describe("destroy", () => { .mockImplementation(() => mockReadonlyState); mockDelete = Promise.resolve({}); deleteSpy = jest.spyOn(axios, "delete") - .mockImplementation(() => mockDelete as never); + .mockImplementation(() => mockDelete); }); API.setBaseUrl("http://localhost:3000"); @@ -183,11 +183,11 @@ describe("destroyAll", () => { .mockImplementation(() => mockReadonlyState); mockDelete = Promise.resolve({}); deleteSpy = jest.spyOn(axios, "delete") - .mockImplementation(() => mockDelete as never); + .mockImplementation(() => mockDelete); }); it("confirmed", async () => { - deleteSpy.mockResolvedValueOnce(undefined as never); + deleteSpy.mockResolvedValueOnce(undefined); const result = fakeDestroyAll("FarmwareEnv", true); if (!result) { return; } await expect(result).resolves.toEqual(undefined); @@ -230,7 +230,7 @@ describe("destroyAll", () => { }); it("rejected", async () => { - deleteSpy.mockRejectedValueOnce("error" as never); + deleteSpy.mockRejectedValueOnce("error"); const result = fakeDestroyAll("FarmwareEnv", true); if (!result) { return; } await expect(result).rejects.toEqual("error"); diff --git a/frontend/config/__tests__/actions_test.ts b/frontend/config/__tests__/actions_test.ts index 80f8dd6cc5..59a051cb17 100644 --- a/frontend/config/__tests__/actions_test.ts +++ b/frontend/config/__tests__/actions_test.ts @@ -29,9 +29,9 @@ describe("ready()", () => { didLoginSpy = jest.spyOn(authActions, "didLogin") .mockImplementation(() => { }); maybeRefreshTokenSpy = jest.spyOn(refreshToken, "maybeRefreshToken") - .mockImplementation(() => Promise.resolve(undefined) as never); + .mockImplementation(() => Promise.resolve(undefined)); timeoutSpy = jest.spyOn(promiseTimeoutModule, "timeout") - .mockImplementation(() => mockTimeout as never); + .mockImplementation(() => mockTimeout); fetchStoredTokenSpy = jest.spyOn(Session, "fetchStoredToken") .mockReturnValue(undefined); clearSpy = jest.spyOn(Session, "clear") diff --git a/frontend/connectivity/__tests__/connect_device/index_test.ts b/frontend/connectivity/__tests__/connect_device/index_test.ts index a5ea40cf76..74a50301cc 100644 --- a/frontend/connectivity/__tests__/connect_device/index_test.ts +++ b/frontend/connectivity/__tests__/connect_device/index_test.ts @@ -339,7 +339,7 @@ describe("onPublicBroadcast", () => { log.message = "bot xyz is offline"; const taggedLog = fn(log); const getStateSpy = - jest.spyOn(store, "getState").mockReturnValue(fakeState() as never); + jest.spyOn(store, "getState").mockReturnValue(fakeState()); globalQueue.maybeWork(); getStateSpy.mockRestore(); expect(taggedLog?.kind).toEqual("Log"); diff --git a/frontend/controls/__tests__/pin_form_fields_test.tsx b/frontend/controls/__tests__/pin_form_fields_test.tsx index 63496c1433..5cb1193cd6 100644 --- a/frontend/controls/__tests__/pin_form_fields_test.tsx +++ b/frontend/controls/__tests__/pin_form_fields_test.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { fakeSensor } from "../../__test_support__/fake_state/resources"; import { Actions } from "../../constants"; import * as crud from "../../api/crud"; @@ -34,7 +33,7 @@ describe("", () => { const input = NameInputBox(p); input.props.onChange({ currentTarget: { value: "GPIO 3" }, - } as React.ChangeEvent); + }); expect(p.dispatch).toHaveBeenCalledWith( expectedPayload({ label: "GPIO 3" })); }); diff --git a/frontend/controls/move/__tests__/take_photo_button_test.tsx b/frontend/controls/move/__tests__/take_photo_button_test.tsx index 6bf10e3fb5..4309c58807 100644 --- a/frontend/controls/move/__tests__/take_photo_button_test.tsx +++ b/frontend/controls/move/__tests__/take_photo_button_test.tsx @@ -18,7 +18,7 @@ describe("", () => { mockPhotoOutcome = Promise.resolve(); takePhotoSpy = jest.spyOn(deviceActions, "takePhoto") - .mockImplementation(() => mockPhotoOutcome as never); + .mockImplementation(() => mockPhotoOutcome); }); afterEach(() => { diff --git a/frontend/demo/lua_runner/index.ts b/frontend/demo/lua_runner/index.ts index 394742ed0d..a8a51036ae 100644 --- a/frontend/demo/lua_runner/index.ts +++ b/frontend/demo/lua_runner/index.ts @@ -26,14 +26,14 @@ export const collectDemoSequenceActions = ( } const sequence = findSequenceById(resources, sequenceId); const varData = resources.sequenceMetas[sequence.uuid]; - const sequenceVariables = Object.values(varData || {}) + const sequenceVariables: ParameterApplication[] = Object.values(varData || {}) .map(v => v?.celeryNode) .filter(v => v?.kind == "variable_declaration") .filter(v => !bodyVariables?.map(v => v.args.label).includes(v.args.label)) .map(v => ({ kind: "parameter_application", args: v.args, - } as ParameterApplication)); + })); const variables = [...sequenceVariables, ...(bodyVariables || [])]; const actions: Action[] = []; const firstVarArgs = variables[0]?.args; diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index de4687799d..d2d43eba1e 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -318,7 +318,7 @@ export const runLua = lua.lua_pushjsfunction(L, () => { const n = lua.lua_gettop(L); - const args = []; + const args: string[] = []; for (let i = 1; i <= n; i++) { args.push(luaToJs(L, i) as string); } @@ -377,7 +377,7 @@ export const runLua = lua.lua_setfield(L, envIndex, to_luastring("set_job")); lua.lua_pushjsfunction(L, () => { - const args = []; + const args: number[] = []; const n = lua.lua_gettop(L); if (n == 1) { const params = luaToJs(L, 1) as XyzNumber; @@ -394,7 +394,7 @@ export const runLua = lua.lua_pushjsfunction(L, () => { const n = lua.lua_gettop(L); - const args = []; + const args: number[] = []; for (let i = 1; i <= n; i++) { args.push(luaToJs(L, i) as number); } diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index 2c1586ff62..b5822d578c 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -75,7 +75,7 @@ beforeEach(() => { getDeviceSpy = jest.spyOn(deviceModule, "getDevice") .mockImplementation(() => mockDevice.current as Farmbot); axiosGetSpy = jest.spyOn(axios, "get") - .mockImplementation(() => mockGet as never); + .mockImplementation(() => mockGet); editSpy = jest.spyOn(crud, "edit").mockImplementation(jest.fn()); saveSpy = jest.spyOn(crud, "save").mockImplementation(jest.fn()); jest.spyOn(demoLuaRunner, "runDemoSequence").mockImplementation(jest.fn()); @@ -351,7 +351,7 @@ describe("takePhoto()", () => { getDeviceSpy.mockImplementation(() => ({ ...mockDeviceDefault, takePhoto, - }) as unknown as Farmbot); + })); await deviceActions().takePhoto(); expect(takePhoto).toHaveBeenCalled(); expect(success).toHaveBeenCalledWith(Content.PROCESSING_PHOTO, @@ -364,7 +364,7 @@ describe("takePhoto()", () => { getDeviceSpy.mockImplementation(() => ({ ...mockDeviceDefault, takePhoto, - }) as unknown as Farmbot); + })); localStorage.setItem("myBotIs", "online"); await deviceActions().takePhoto(); expect(takePhoto).not.toHaveBeenCalled(); @@ -377,7 +377,7 @@ describe("takePhoto()", () => { getDeviceSpy.mockImplementation(() => ({ ...mockDeviceDefault, takePhoto, - }) as unknown as Farmbot); + })); await deviceActions().takePhoto(); await expect(takePhoto).toHaveBeenCalled(); expect(success).not.toHaveBeenCalled(); diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index 85c1cb1f47..19e3398fce 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -27,7 +27,6 @@ import * as crud from "../api/crud"; import { CONFIG_DEFAULTS } from "farmbot/dist/config"; import { Log } from "farmbot/dist/resources/api_resources"; import { FbosConfig } from "farmbot/dist/resources/configs/fbos"; -import { FirmwareConfig } from "farmbot/dist/resources/configs/firmware"; import { getFirmwareConfig, getFbosConfig } from "../resources/getters"; import { isObject, isString, get, noop } from "lodash"; import { t } from "../i18next_wrapper"; @@ -524,7 +523,7 @@ export function updateMCU(key: ConfigKey, val: string) { function proceed() { if (firmwareConfig) { - dispatch(crud.edit(firmwareConfig, { [key]: val } as Partial)); + dispatch(crud.edit(firmwareConfig, { [key]: val })); dispatch(crud.save(firmwareConfig.uuid)); } } diff --git a/frontend/farm_designer/__tests__/index_test.tsx b/frontend/farm_designer/__tests__/index_test.tsx index bcc2a3463d..bc15cfb2f7 100644 --- a/frontend/farm_designer/__tests__/index_test.tsx +++ b/frontend/farm_designer/__tests__/index_test.tsx @@ -51,7 +51,7 @@ describe("", () => { .mockImplementation(((props: GardenMapLegendProps) => { lastLegendProps = props; return
; - }) as never); + })); gardenMapSpy = jest.spyOn(gardenMap, "GardenMap") .mockImplementation(((props: GardenMapProps) => { lastGardenMapProps = props; diff --git a/frontend/farm_designer/__tests__/location_info_test.tsx b/frontend/farm_designer/__tests__/location_info_test.tsx index cf476fa1ca..fcff27e34a 100644 --- a/frontend/farm_designer/__tests__/location_info_test.tsx +++ b/frontend/farm_designer/__tests__/location_info_test.tsx @@ -232,7 +232,7 @@ describe("", () => { if (buttons.length === 0) { return; } - fireEvent.click(buttons[0] as HTMLButtonElement); + fireEvent.click(buttons[0]); expect(container.querySelectorAll( "button.image-flipper-left, button.image-flipper-right").length) .toBe(1); diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index 534e04d763..f4c7b39510 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -25,7 +25,7 @@ beforeEach(() => { getPositionSpy = jest.spyOn(suncalc, "getPosition").mockReturnValue({ altitude: 0.5, azimuth: 1.0, - } as never); + }); }); afterEach(() => { diff --git a/frontend/farm_designer/map/__tests__/garden_map_test.tsx b/frontend/farm_designer/map/__tests__/garden_map_test.tsx index a9a4cab8ca..f7fb13efe7 100644 --- a/frontend/farm_designer/map/__tests__/garden_map_test.tsx +++ b/frontend/farm_designer/map/__tests__/garden_map_test.tsx @@ -68,9 +68,9 @@ interface RenderedGardenMap { // eslint-disable-next-line complexity const fire = (target: Element, event: EventName, payload?: unknown) => { - const eventPayload = { + const eventPayload: Record = { ...((typeof payload === "object" && payload) ? payload : {}), - } as Record; + }; if (!("clientX" in eventPayload) && "pageX" in eventPayload) { eventPayload.clientX = eventPayload.pageX; } @@ -171,16 +171,16 @@ const fireWrapperEvent = ( dragStart?: (event: unknown) => void; }; if ((selector == ".drop-area-svg" || selector == "svg") && event == "click") { - instance.click?.(payload as never); + instance.click?.(payload); return; } if (selector == ".drop-area-svg" || selector == "svg") { if (event == "mouseDown") { - instance.startDrag?.(payload as never); + instance.startDrag?.(payload); return; } if (event == "mouseMove") { - instance.drag?.(payload as never); + instance.drag?.(payload); return; } if (event == "mouseUp") { @@ -189,20 +189,20 @@ const fireWrapperEvent = ( } } if (selector == ".drop-area-background" && event == "mouseDown") { - instance.startDragOnBackground?.(payload as never); + instance.startDragOnBackground?.(payload); return; } if (selector == ".drop-area") { if (event == "dragOver") { - instance.handleDragOver?.(payload as never); + instance.handleDragOver?.(payload); return; } if (event == "dragEnter") { - instance.handleDragEnter?.(payload as never); + instance.handleDragEnter?.(payload); return; } if (event == "dragStart") { - instance.dragStart?.(payload as never); + instance.dragStart?.(payload); return; } } @@ -244,8 +244,10 @@ const makeWrapper = ( }; }; -const renderMap = (element: React.ReactElement) => makeWrapper(element); -const renderMapWithContext = (element: React.ReactElement) => +// eslint-disable-next-line comma-spacing +const renderMap = (element: React.ReactElement) => makeWrapper(element); +// eslint-disable-next-line comma-spacing +const renderMapWithContext = (element: React.ReactElement) => makeWrapper(element, true); const DEFAULT_EVENT = { preventDefault: jest.fn(), pageX: NaN, pageY: NaN }; diff --git a/frontend/farm_designer/map/background/__tests__/selection_box_actions_test.ts b/frontend/farm_designer/map/background/__tests__/selection_box_actions_test.ts index cbab8623e6..31725adf63 100644 --- a/frontend/farm_designer/map/background/__tests__/selection_box_actions_test.ts +++ b/frontend/farm_designer/map/background/__tests__/selection_box_actions_test.ts @@ -89,8 +89,7 @@ describe("resizeBox", () => { it("doesn't resize box: no location", () => { const p = fakeProps(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - p.gardenCoords = undefined as any; + p.gardenCoords = undefined; resizeBox(p); expect(p.setMapState).not.toHaveBeenCalled(); expect(p.dispatch).not.toHaveBeenCalled(); @@ -98,8 +97,7 @@ describe("resizeBox", () => { it("doesn't resize box: no box", () => { const p = fakeProps(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - p.selectionBox = undefined as any; + p.selectionBox = undefined; resizeBox(p); expect(p.setMapState).not.toHaveBeenCalled(); expect(p.dispatch).not.toHaveBeenCalled(); @@ -156,8 +154,7 @@ describe("startNewSelectionBox", () => { it("doesn't start box", () => { const p = fakeProps(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - p.gardenCoords = undefined as any; + p.gardenCoords = undefined; startNewSelectionBox(p); expect(p.setMapState).not.toHaveBeenCalled(); expect(p.dispatch).toHaveBeenCalledWith({ diff --git a/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.ts b/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.ts index a33eb138d4..08a199a8b7 100644 --- a/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.ts +++ b/frontend/farm_designer/map/layers/plants/__tests__/plant_actions_test.ts @@ -209,8 +209,7 @@ describe("plantActions().dropPlant()", () => { it("throws error", () => { const p = fakeProps(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - p.gardenCoords = undefined as any; + p.gardenCoords = undefined; expect(() => plantActions().dropPlant(p)) .toThrow(/while trying to add a plant/); }); @@ -349,8 +348,7 @@ describe("plantActions().setActiveSpread()", () => { it("sets crop spread value", async () => { const p = fakeProps(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - p.selectedPlant = undefined as any; + p.selectedPlant = undefined; await plantActions().setActiveSpread(p); expect(p.setMapState).toHaveBeenCalledWith({ activeDragSpread: 75 }); }); @@ -369,8 +367,7 @@ describe("plantActions().beginPlantDrag()", () => { it("starts drag: not plant", () => { const p = fakeProps(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - p.plant = undefined as any; + p.plant = undefined; plantActions().beginPlantDrag(p); }); }); diff --git a/frontend/farm_designer/map/profile/__tests__/options_test.tsx b/frontend/farm_designer/map/profile/__tests__/options_test.tsx index 10bc3f056e..7cf5810fa4 100644 --- a/frontend/farm_designer/map/profile/__tests__/options_test.tsx +++ b/frontend/farm_designer/map/profile/__tests__/options_test.tsx @@ -61,7 +61,7 @@ describe("", () => { const p = fakeProps(); const { container } = render(); const buttons = container.querySelectorAll("button"); - fireEvent.click(buttons[buttons.length - 1] as Element); + fireEvent.click(buttons[buttons.length - 1]); expect(p.dispatch).toHaveBeenCalledWith({ type: Actions.SET_PROFILE_FOLLOW_BOT, payload: true, diff --git a/frontend/farm_designer/map/profile/__tests__/viewer_test.tsx b/frontend/farm_designer/map/profile/__tests__/viewer_test.tsx index 2b66a53a29..00fbdee92b 100644 --- a/frontend/farm_designer/map/profile/__tests__/viewer_test.tsx +++ b/frontend/farm_designer/map/profile/__tests__/viewer_test.tsx @@ -131,7 +131,7 @@ describe("", () => { p.designer.profilePosition = { x: 1, y: 2 }; const { container } = render(); const icons = container.querySelectorAll("i"); - fireEvent.click(icons[icons.length - 1] as Element); + fireEvent.click(icons[icons.length - 1]); expect(container.querySelector("svg")?.classList.contains("expand")) .toBeFalsy(); fireEvent.click(container.querySelector(".profile-button") as Element); diff --git a/frontend/farm_events/__tests__/add_farm_event_test.tsx b/frontend/farm_events/__tests__/add_farm_event_test.tsx index 7535703f4c..56f78e4b13 100644 --- a/frontend/farm_events/__tests__/add_farm_event_test.tsx +++ b/frontend/farm_events/__tests__/add_farm_event_test.tsx @@ -198,10 +198,9 @@ describe("", () => { p.findFarmEventByUuid = () => farmEvent; const formRef = { current: undefined as unknown as EditFEForm }; const createRefSpy = jest.spyOn(React, "createRef") - .mockReturnValue(formRef as React.RefObject); + .mockReturnValue(formRef); const { container } = render(); - formRef.current.commitViewModel = - mockSave as unknown as EditFEForm["commitViewModel"]; + formRef.current.commitViewModel = mockSave; fireEvent.click(container.querySelector(".save-btn") as Element); expect(mockSave).toHaveBeenCalled(); createRefSpy.mockRestore(); diff --git a/frontend/farm_events/__tests__/edit_farm_event_test.tsx b/frontend/farm_events/__tests__/edit_farm_event_test.tsx index b249cfafbd..d181371f3c 100644 --- a/frontend/farm_events/__tests__/edit_farm_event_test.tsx +++ b/frontend/farm_events/__tests__/edit_farm_event_test.tsx @@ -74,10 +74,9 @@ describe("", () => { it("calls farm event save", () => { const formRef = { current: undefined as unknown as EditFEForm }; const createRefSpy = jest.spyOn(React, "createRef") - .mockReturnValue(formRef as React.RefObject); + .mockReturnValue(formRef); const { container } = render(); - formRef.current.commitViewModel = - mockSave as unknown as EditFEForm["commitViewModel"]; + formRef.current.commitViewModel = mockSave; fireEvent.click(container.querySelector(".save-btn") as Element); expect(mockSave).toHaveBeenCalled(); createRefSpy.mockRestore(); @@ -85,7 +84,7 @@ describe("", () => { it("doesn't call farm event save if event is missing", () => { const p = fakeProps(); - p.getFarmEvent = () => undefined as never; + p.getFarmEvent = () => undefined; location.pathname = Path.mock(Path.farmEvents("nope")); const { container } = render(); fireEvent.click(container.querySelector(".save-btn") as Element); diff --git a/frontend/farm_events/__tests__/edit_fe_form_test.tsx b/frontend/farm_events/__tests__/edit_fe_form_test.tsx index 9a819d329e..8448af3a1c 100644 --- a/frontend/farm_events/__tests__/edit_fe_form_test.tsx +++ b/frontend/farm_events/__tests__/edit_fe_form_test.tsx @@ -63,7 +63,7 @@ describe("", () => { const update = isFunction(state) ? state(i.state, i.props) : state; i.state = { ...i.state, ...update }; callback?.(); - }) as EditFEForm["setState"]; + }); i.forceUpdate = jest.fn(); return i; } diff --git a/frontend/farm_events/__tests__/farm_event_repeat_form_test.tsx b/frontend/farm_events/__tests__/farm_event_repeat_form_test.tsx index 9f8de7f9df..eb158599a1 100644 --- a/frontend/farm_events/__tests__/farm_event_repeat_form_test.tsx +++ b/frontend/farm_events/__tests__/farm_event_repeat_form_test.tsx @@ -72,7 +72,7 @@ describe("", () => { defaultValue={props.value} disabled={props.disabled} onChange={() => { }} - onBlur={e => props.onCommit(e)} />) as never); + onBlur={e => props.onCommit(e)} />)); }); afterEach(() => { @@ -100,7 +100,7 @@ describe("", () => { it("defaults to `daily` when a bad input it passed", () => { const p = fakeProps(); - p.timeUnit = "never" as "daily"; + p.timeUnit = "never"; const { container } = render(); expect((container.querySelector( `[data-testid="${Selectors.REPEAT}"]`) as HTMLInputElement).value) diff --git a/frontend/farm_events/__tests__/farm_events_test.tsx b/frontend/farm_events/__tests__/farm_events_test.tsx index 309848c7b5..1510681c14 100644 --- a/frontend/farm_events/__tests__/farm_events_test.tsx +++ b/frontend/farm_events/__tests__/farm_events_test.tsx @@ -124,7 +124,7 @@ describe("", () => { : state; instance.state = { ...instance.state, ...update }; callback?.(); - }) as FarmEvents["setState"]; + }); instance.setState({ searchTerm: "farm events" }); instance.resetCalendar(); expect(mockScrollTo).toHaveBeenCalledWith(0, 0); @@ -142,7 +142,7 @@ describe("", () => { : state; instance.state = { ...instance.state, ...update }; callback?.(); - }) as FarmEvents["setState"]; + }); instance.setState({ searchTerm: "farm events" }); instance.resetCalendar(); expect(instance.state.searchTerm).toEqual(""); diff --git a/frontend/farmware/__tests__/actions_test.ts b/frontend/farmware/__tests__/actions_test.ts index e909c8a86b..b6ea2a315e 100644 --- a/frontend/farmware/__tests__/actions_test.ts +++ b/frontend/farmware/__tests__/actions_test.ts @@ -14,9 +14,9 @@ beforeEach(() => { { package: "farmware0" }, { package: "farmware1" }, ] - }) as never); + })); axiosPostSpy = jest.spyOn(axios, "post") - .mockImplementation(() => Promise.resolve({}) as never); + .mockImplementation(() => Promise.resolve({})); }); afterEach(() => { diff --git a/frontend/farmware/panel/__tests__/info_test.tsx b/frontend/farmware/panel/__tests__/info_test.tsx index a95a6e77b0..adaad97c1c 100644 --- a/frontend/farmware/panel/__tests__/info_test.tsx +++ b/frontend/farmware/panel/__tests__/info_test.tsx @@ -26,13 +26,13 @@ let activeFarmwareSpy: jest.SpyInstance; beforeEach(() => { designerPanelSpy = jest.spyOn(designerPanel, "DesignerPanel") .mockImplementation(((props: React.PropsWithChildren) => -
{props.children}
) as never); +
{props.children}
)); designerPanelTopSpy = jest.spyOn(designerPanel, "DesignerPanelTop") .mockImplementation(((props: React.PropsWithChildren) => -
{props.children}
) as never); +
{props.children}
)); designerPanelContentSpy = jest.spyOn(designerPanel, "DesignerPanelContent") .mockImplementation(((props: React.PropsWithChildren) => -
{props.children}
) as never); +
{props.children}
)); activeFarmwareSpy = jest.spyOn(activeFarmware, "setActiveFarmwareByName") .mockImplementation(jest.fn()); }); diff --git a/frontend/folders/__tests__/actions_test.ts b/frontend/folders/__tests__/actions_test.ts index 0100643238..9db641704f 100644 --- a/frontend/folders/__tests__/actions_test.ts +++ b/frontend/folders/__tests__/actions_test.ts @@ -60,7 +60,7 @@ beforeEach(() => { stepGetSpy = jest.spyOn(draggableActions, "stepGet") .mockImplementation(() => (() => mockStepGetResult) as unknown as - ReturnType); + ReturnType); destroySpy = jest.spyOn(crudModule, "destroy").mockImplementation(jest.fn()); editSpy = jest.spyOn(crudModule, "edit").mockImplementation(jest.fn()); initSpy = jest.spyOn(crudModule, "init").mockImplementation(jest.fn()); @@ -340,7 +340,7 @@ describe("moveSequence", () => { index: { references: { [sequence.uuid]: sequence }, } - } as Everything["resources"], + }, }; (store as unknown as { getState: () => DeepPartial }).getState = () => localState; diff --git a/frontend/folders/__tests__/component_test.tsx b/frontend/folders/__tests__/component_test.tsx index 1d71a40cdc..558ba73c65 100644 --- a/frontend/folders/__tests__/component_test.tsx +++ b/frontend/folders/__tests__/component_test.tsx @@ -157,7 +157,7 @@ const setStateSync = ), }; callback?.(); - }) as T["setState"]; + }); return instance; }; @@ -836,14 +836,14 @@ describe("", () => { it("creates new folder", () => { const p = fakeProps(); const { container } = render(); - fireEvent.click(container.querySelectorAll("button")[1] as Element); + fireEvent.click(container.querySelectorAll("button")[1]); expect(createFolder).toHaveBeenCalled(); }); it("creates new sequence", () => { const p = fakeProps(); const { container } = render(); - fireEvent.click(container.querySelectorAll("button")[2] as Element); + fireEvent.click(container.querySelectorAll("button")[2]); expect(addNewSequenceToFolder).toHaveBeenCalled(); }); }); diff --git a/frontend/front_page/__tests__/demo_login_option_test.tsx b/frontend/front_page/__tests__/demo_login_option_test.tsx index 256fb9eff6..05ce89c64e 100644 --- a/frontend/front_page/__tests__/demo_login_option_test.tsx +++ b/frontend/front_page/__tests__/demo_login_option_test.tsx @@ -24,7 +24,7 @@ describe("", () => { .mockImplementation(() => typeof mockResponse === "string" ? Promise.resolve(mockResponse) - : Promise.reject(mockResponse) as never); + : Promise.reject(mockResponse)); }); afterEach(() => { @@ -65,7 +65,7 @@ describe("", () => { : state; instance.state = { ...instance.state, ...update }; callback?.(); - }) as DemoLoginOption["setState"]; + }); expect(instance.state.productLine).toEqual("genesis_1.8"); const select = instance["seedDataSelect"]() as React.ReactElement<{ onChange: (ddi: { value: string }) => void; diff --git a/frontend/front_page/__tests__/front_page_test.tsx b/frontend/front_page/__tests__/front_page_test.tsx index 83a13ba7a6..9db581ad22 100644 --- a/frontend/front_page/__tests__/front_page_test.tsx +++ b/frontend/front_page/__tests__/front_page_test.tsx @@ -28,7 +28,7 @@ let originalPrivUrl: string; const setStateSync = (instance: FrontPage) => { instance.setState = ((state: Partial) => { instance.state = { ...instance.state, ...state }; - }) as FrontPage["setState"]; + }); return instance; }; @@ -65,9 +65,9 @@ describe("", () => { originalPrivUrl = globalConfig.PRIV_URL; const mockState = fakeState(); getStateSpy = jest.spyOn(store, "getState") - .mockReturnValue(mockState as never); + .mockReturnValue(mockState); postSpy = jest.spyOn(axios, "post") - .mockImplementation(() => mockAxiosResponse as never); + .mockImplementation(() => mockAxiosResponse); fetchStoredTokenSpy = jest.spyOn(Session, "fetchStoredToken") .mockImplementation(() => mockAuth); replaceTokenSpy = jest.spyOn(Session, "replaceToken") @@ -140,8 +140,8 @@ describe("", () => { const content = instance.defaultContent(); const createAccount = findElement(content, element => element.type === CreateAccount) as React.ReactElement<{ - set: (key: keyof FrontPage["state"], val: string | boolean) => void; - }>; + set: (key: keyof FrontPage["state"], val: string | boolean) => void; + }>; if (!createAccount) { throw new Error("Expected create account panel"); } @@ -241,7 +241,8 @@ describe("", () => { instance.submitRegistration(fakeFormEvent); await flushPromises(); expect(axios.post).toHaveBeenCalledWith( - "http://localhost:3000/api/users/", { + "http://localhost:3000/api/users/", + { user: { agree_to_terms: true, email: "foo@bar.io", name: "Foo Bar", password: "password", password_confirmation: "password" @@ -265,7 +266,8 @@ describe("", () => { instance.submitRegistration(fakeFormEvent); await flushPromises(); expect(axios.post).toHaveBeenCalledWith( - "http://localhost:3000/api/users/", { + "http://localhost:3000/api/users/", + { user: { agree_to_terms: true, email: "foo@bar.io", name: "Foo Bar", password: "password", password_confirmation: "password" diff --git a/frontend/front_page/__tests__/login_test.tsx b/frontend/front_page/__tests__/login_test.tsx index 184fe76529..28fe20d170 100644 --- a/frontend/front_page/__tests__/login_test.tsx +++ b/frontend/front_page/__tests__/login_test.tsx @@ -21,12 +21,12 @@ describe("", () => { it("interacts with login options", () => { const p = fakeProps(); const { container } = render(); - fireEvent.change(container.querySelectorAll("input")[0] as Element, { + fireEvent.change(container.querySelectorAll("input")[0], { target: { value: "email" } }); expect(p.onEmailChange).toHaveBeenCalled(); expect((p.onEmailChange as jest.Mock).mock.calls.length).toEqual(1); - fireEvent.change(container.querySelectorAll("input")[1] as Element, { + fireEvent.change(container.querySelectorAll("input")[1], { target: { value: "password" } }); expect(p.onLoginPasswordChange).toHaveBeenCalled(); diff --git a/frontend/front_page/__tests__/resend_verification_test.tsx b/frontend/front_page/__tests__/resend_verification_test.tsx index c252030616..71b8e83567 100644 --- a/frontend/front_page/__tests__/resend_verification_test.tsx +++ b/frontend/front_page/__tests__/resend_verification_test.tsx @@ -17,7 +17,7 @@ describe("", () => { beforeEach(() => { mockPost = Promise.resolve({ data: "whatever" }); axiosPostSpy = jest.spyOn(axios, "post") - .mockImplementation(() => mockPost as never); + .mockImplementation(() => mockPost); }); afterEach(() => { diff --git a/frontend/help/documentation.tsx b/frontend/help/documentation.tsx index e66753c1cd..0bb86af867 100644 --- a/frontend/help/documentation.tsx +++ b/frontend/help/documentation.tsx @@ -12,12 +12,8 @@ export interface DocumentationPanelProps { export const DocumentationPanel = (props: DocumentationPanelProps) => { const location = useLocation(); - const [src, setSrc] = React.useState(""); - - React.useEffect(() => { - const page = new URLSearchParams(location.search).get("page"); - setSrc(page ? `${props.url}/${page}` : props.url); - }, [props, location]); + const page = new URLSearchParams(location.search).get("page"); + const src = page ? `${props.url}/${page}` : props.url; return diff --git a/frontend/logs/__tests__/index_test.tsx b/frontend/logs/__tests__/index_test.tsx index 96168dc4aa..0f073fb0a5 100644 --- a/frontend/logs/__tests__/index_test.tsx +++ b/frontend/logs/__tests__/index_test.tsx @@ -50,7 +50,7 @@ describe("", () => { const setStateSync = (instance: Logs) => { instance.setState = ((state: Partial) => { instance.state = { ...instance.state, ...state }; - }) as Logs["setState"]; + }); return instance; }; diff --git a/frontend/logs/components/__tests__/settings_menu_test.tsx b/frontend/logs/components/__tests__/settings_menu_test.tsx index d7b9424f8d..7f7fd52bac 100644 --- a/frontend/logs/components/__tests__/settings_menu_test.tsx +++ b/frontend/logs/components/__tests__/settings_menu_test.tsx @@ -95,7 +95,7 @@ describe("", () => { p.dispatch = jest.fn(); const { container } = render(); const buttons = container.querySelectorAll("button"); - fireEvent.click(buttons[buttons.length - 1] as Element); + fireEvent.click(buttons[buttons.length - 1]); await Promise.resolve(); await Promise.resolve(); expect(crud.destroyAll).toHaveBeenCalledWith( diff --git a/frontend/messages/__tests__/actions_test.ts b/frontend/messages/__tests__/actions_test.ts index cb5a64dea6..1613825f31 100644 --- a/frontend/messages/__tests__/actions_test.ts +++ b/frontend/messages/__tests__/actions_test.ts @@ -18,9 +18,9 @@ beforeEach(() => { toastErrorsSpy = jest.spyOn(toastErrorsModule, "toastErrors") .mockImplementation(jest.fn()); axiosGetSpy = jest.spyOn(axios, "get") - .mockImplementation(() => Promise.resolve({ data: { foo: "bar" } }) as never); + .mockImplementation(() => Promise.resolve({ data: { foo: "bar" } })); axiosPostSpy = jest.spyOn(axios, "post") - .mockImplementation(() => mockPostResponse as never); + .mockImplementation(() => mockPostResponse); }); afterEach(() => { @@ -55,7 +55,7 @@ describe("seedAccount()", () => { }); it("returns error while trying to seed account", async () => { - axiosPostSpy.mockRejectedValueOnce({ response: { data: ["error"] } } as never); + axiosPostSpy.mockRejectedValueOnce({ response: { data: ["error"] } }); const dismiss = jest.fn(); await seedAccount(dismiss)({ label: "Genesis v1.2", value: "genesis_1.2" }); expect(axios.post).toHaveBeenCalledWith(API.current.accountSeedPath, { diff --git a/frontend/messages/__tests__/cards_test.tsx b/frontend/messages/__tests__/cards_test.tsx index 2bd0ade4b3..2dd1f2f65f 100644 --- a/frontend/messages/__tests__/cards_test.tsx +++ b/frontend/messages/__tests__/cards_test.tsx @@ -65,10 +65,10 @@ beforeEach(() => { shouldDisplayFeatureSpy = jest.spyOn(shouldDisplay, "shouldDisplayFeature") .mockImplementation(() => mockFeatureBoolean); fetchBulletinContentSpy = jest.spyOn(messageActions, "fetchBulletinContent") - .mockImplementation(() => Promise.resolve(mockData) as never); + .mockImplementation(() => Promise.resolve(mockData)); seedAccountSpy = jest.spyOn(messageActions, "seedAccount") .mockImplementation(() => mockSeedAccount as never); - axiosDeleteSpy = jest.spyOn(axios, "delete").mockResolvedValue({} as never); + axiosDeleteSpy = jest.spyOn(axios, "delete").mockResolvedValue({}); sessionClearSpy = jest.spyOn(Session, "clear") .mockImplementation(() => undefined as never); fbSelectSpy = jest.spyOn(ui, "FBSelect") @@ -233,8 +233,8 @@ describe("", () => { const { container } = render(); expect(container.textContent).toContain("currently using"); const links = container.querySelectorAll("a"); - fireEvent.click(links[0] as Element); - fireEvent.click(links[links.length - 1] as Element); + fireEvent.click(links[0]); + fireEvent.click(links[links.length - 1]); expect(Session.clear).toHaveBeenCalledTimes(2); }); @@ -341,7 +341,7 @@ describe("", () => { const { container } = render(); fireEvent.click(container.querySelector(".fb-select-mock") as Element); const buttons = container.querySelectorAll("button"); - fireEvent.click(buttons[buttons.length - 1] as Element); + fireEvent.click(buttons[buttons.length - 1]); expect(mockSeedAccount).toHaveBeenCalledWith({ label: "", value: "selection", diff --git a/frontend/messages/__tests__/reducer_test.ts b/frontend/messages/__tests__/reducer_test.ts index cb65d79dc2..c57681df76 100644 --- a/frontend/messages/__tests__/reducer_test.ts +++ b/frontend/messages/__tests__/reducer_test.ts @@ -12,8 +12,7 @@ beforeEach(() => { describe("Contextual `Alert` creation", () => { it("toggles on", () => { const c = fakeFbosConfig(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - c.body.firmware_hardware = undefined as any; + c.body.firmware_hardware = undefined; const s: AlertReducerState = { alerts: {} }; diff --git a/frontend/password_reset/__tests__/password_reset_test.tsx b/frontend/password_reset/__tests__/password_reset_test.tsx index ac98f989af..9469795af7 100644 --- a/frontend/password_reset/__tests__/password_reset_test.tsx +++ b/frontend/password_reset/__tests__/password_reset_test.tsx @@ -15,7 +15,7 @@ describe("", () => { originalPathname = location.pathname; location.pathname = "/password_resets/"; axiosPutSpy = jest.spyOn(axios, "put") - .mockImplementation(() => mockPut as never); + .mockImplementation(() => mockPut); }); afterEach(() => { diff --git a/frontend/photos/images/__tests__/photos_test.tsx b/frontend/photos/images/__tests__/photos_test.tsx index af3966b0e4..6dd14dcf48 100644 --- a/frontend/photos/images/__tests__/photos_test.tsx +++ b/frontend/photos/images/__tests__/photos_test.tsx @@ -96,7 +96,7 @@ describe("", () => { ...(update as Partial), }; callback?.(); - }) as Photos["setState"]; + }); return instance; }; diff --git a/frontend/photos/photo_filter_settings/__tests__/image_filter_menu_test.tsx b/frontend/photos/photo_filter_settings/__tests__/image_filter_menu_test.tsx index 63d6261c46..19e435b9c9 100644 --- a/frontend/photos/photo_filter_settings/__tests__/image_filter_menu_test.tsx +++ b/frontend/photos/photo_filter_settings/__tests__/image_filter_menu_test.tsx @@ -44,7 +44,7 @@ beforeEach(() => { { length: props.max - props.min + 1 }, (_, index) => props.min + index, ).map(day => {props.labelRenderer(day)})} -
) as never); +
)); }); afterEach(() => { @@ -64,7 +64,7 @@ describe("", () => { const setStateSync = (instance: ImageFilterMenu) => { instance.setState = (((update: Partial) => { instance.state = { ...instance.state, ...update }; - }) as unknown) as typeof instance.setState; + })); }; const setConfigDispatch = ( diff --git a/frontend/plants/__tests__/crop_info_test.tsx b/frontend/plants/__tests__/crop_info_test.tsx index 70256cf0b5..e29e18584d 100644 --- a/frontend/plants/__tests__/crop_info_test.tsx +++ b/frontend/plants/__tests__/crop_info_test.tsx @@ -56,7 +56,7 @@ beforeEach(() => { .mockImplementation(((props: BIProps) => - props.onCommit(e as React.SyntheticEvent)} />) as never); + props.onCommit(e)} />) as never); }); afterEach(() => { diff --git a/frontend/plants/crop_info.tsx b/frontend/plants/crop_info.tsx index 51086226fe..25ecda4a00 100644 --- a/frontend/plants/crop_info.tsx +++ b/frontend/plants/crop_info.tsx @@ -273,10 +273,6 @@ export function mapStateToProps(props: Everything): CropInfoProps { export const RawCropInfo = (props: CropInfoProps) => { const [gridOpen, setGridOpen] = React.useState(false); const toggleOpen = () => setGridOpen(!gridOpen); - React.useEffect(() => { - selectMostUsedCurves(Path.getCropSlug()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); const selectMostUsedCurves = (slug: string) => { const findCurve = findMostUsedCurveForCrop({ @@ -289,6 +285,10 @@ export const RawCropInfo = (props: CropInfoProps) => { changeCurve(props.dispatch)(id, curveType); }); }; + React.useEffect(() => { + selectMostUsedCurves(Path.getCropSlug()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const { dispatch, designer } = props; const slug = Path.getCropSlug(); diff --git a/frontend/point_groups/__tests__/actions_test.ts b/frontend/point_groups/__tests__/actions_test.ts index 9a014bd582..2b318658e9 100644 --- a/frontend/point_groups/__tests__/actions_test.ts +++ b/frontend/point_groups/__tests__/actions_test.ts @@ -38,7 +38,7 @@ beforeEach(() => { selectAllPlantPointersSpy = jest.spyOn(selectors, "selectAllPlantPointers") .mockImplementation(jest.fn(() => [])); findUuidSpy = jest.spyOn(selectors, "findUuid") - .mockImplementation((() => "PointGroup.0.0") as typeof selectors.findUuid); + .mockImplementation(() => "PointGroup.0.0"); }); afterEach(() => { diff --git a/frontend/point_groups/criteria/__tests__/component_test.tsx b/frontend/point_groups/criteria/__tests__/component_test.tsx index 3c80e4cf0b..d9962beec7 100644 --- a/frontend/point_groups/criteria/__tests__/component_test.tsx +++ b/frontend/point_groups/criteria/__tests__/component_test.tsx @@ -170,7 +170,7 @@ describe("", () => { p.group.body.point_ids = [1, 2]; const { container } = render(); window.confirm = () => true; - fireEvent.click(container.querySelectorAll("button")[0] as Element); + fireEvent.click(container.querySelectorAll("button")[0]); const expectedBody = cloneDeep(p.group.body); expectedBody.point_ids = []; expect(overwriteGroupSpy).toHaveBeenCalledWith(p.group, expectedBody); @@ -181,7 +181,7 @@ describe("", () => { p.group.body.point_ids = [1, 2]; const { container } = render(); window.confirm = () => false; - fireEvent.click(container.querySelectorAll("button")[0] as Element); + fireEvent.click(container.querySelectorAll("button")[0]); expect(overwriteGroupSpy).not.toHaveBeenCalled(); }); @@ -189,7 +189,7 @@ describe("", () => { const p = fakeProps(); const { container } = render(); window.confirm = () => true; - fireEvent.click(container.querySelectorAll("button")[1] as Element); + fireEvent.click(container.querySelectorAll("button")[1]); const expectedBody = cloneDeep(p.group.body); expectedBody.criteria = DEFAULT_CRITERIA; expect(overwriteGroupSpy).toHaveBeenCalledWith(p.group, expectedBody); @@ -199,7 +199,7 @@ describe("", () => { const p = fakeProps(); const { container } = render(); window.confirm = () => false; - fireEvent.click(container.querySelectorAll("button")[1] as Element); + fireEvent.click(container.querySelectorAll("button")[1]); expect(overwriteGroupSpy).not.toHaveBeenCalled(); }); @@ -305,7 +305,7 @@ describe("", () => { const p = fakeProps(); p.pointTypes = ["Plant", "Weed"]; const { container } = render(); - fireEvent.click(container.querySelectorAll("input")[0] as Element); + fireEvent.click(container.querySelectorAll("input")[0]); expect(togglePointTypeCriteriaSpy).toHaveBeenCalledWith(p.group, "Plant"); }); }); diff --git a/frontend/point_groups/criteria/__tests__/show_test.tsx b/frontend/point_groups/criteria/__tests__/show_test.tsx index 91c4df7936..62523db63f 100644 --- a/frontend/point_groups/criteria/__tests__/show_test.tsx +++ b/frontend/point_groups/criteria/__tests__/show_test.tsx @@ -89,7 +89,7 @@ describe(" />", () => { const p = fakeProps(); p.eqCriteria = { openfarm_slug: ["slug"] }; const { container } = render( {...p} />); - fireEvent.click(container.querySelectorAll("button")[1] as Element); + fireEvent.click(container.querySelectorAll("button")[1]); expect(removeEqCriteriaValueSpy).toHaveBeenCalledWith( p.group, { openfarm_slug: ["slug"] }, @@ -121,7 +121,7 @@ describe("", () => { p.criteria.number_gt = { x: 1 }; const { container } = render(); expect(container.textContent).toContain(">"); - fireEvent.click(container.querySelectorAll("button")[1] as Element); + fireEvent.click(container.querySelectorAll("button")[1]); expect(clearCriteriaFieldSpy).toHaveBeenCalledWith( p.group, ["number_gt"], @@ -192,7 +192,7 @@ describe("", () => { it("changes number_gt", () => { const p = fakeProps(); const { container } = render(); - fireEvent.blur(container.querySelectorAll("input")[0] as Element, { + fireEvent.blur(container.querySelectorAll("input")[0], { target: { value: "1" } }); expect(editGtLtCriteriaFieldSpy).toHaveBeenCalledWith( @@ -205,7 +205,7 @@ describe("", () => { it("changes number_lt", () => { const p = fakeProps(); const { container } = render(); - fireEvent.blur(container.querySelectorAll("input")[1] as Element, { + fireEvent.blur(container.querySelectorAll("input")[1], { target: { value: "1" } }); expect(editGtLtCriteriaFieldSpy).toHaveBeenCalledWith( diff --git a/frontend/points/__tests__/point_info_test.tsx b/frontend/points/__tests__/point_info_test.tsx index bd3f550d5a..d4339d3e56 100644 --- a/frontend/points/__tests__/point_info_test.tsx +++ b/frontend/points/__tests__/point_info_test.tsx @@ -34,7 +34,7 @@ beforeEach(() => { saveSpy = jest.spyOn(crud, "save").mockImplementation(jest.fn()); popoverSpy = jest.spyOn(popover, "Popover").mockImplementation((( { target, content }: PopoverProps, - ) =>
{target}{content}
) as never); + ) =>
{target}{content}
)); }); afterEach(() => { diff --git a/frontend/promo/__tests__/plants_test.ts b/frontend/promo/__tests__/plants_test.ts index 035576743e..d3033d9c60 100644 --- a/frontend/promo/__tests__/plants_test.ts +++ b/frontend/promo/__tests__/plants_test.ts @@ -22,7 +22,7 @@ describe("calculatePlantPositions()", () => { seed: expect.any(Number), size: 150, spread: 17.5, - x: 350, + x: 200, y: 680, }); expect(positions.length).toEqual(65); diff --git a/frontend/promo/plants.ts b/frontend/promo/plants.ts index 21ef640187..a94fb9d187 100644 --- a/frontend/promo/plants.ts +++ b/frontend/promo/plants.ts @@ -7,11 +7,11 @@ import { Season, SEASON_DURATIONS } from "./constants"; export const calculatePlantPositions = (config: Config): ThreeDGardenPlant[] => { const gardenPlants = GARDENS[config.plants] || []; const positions: ThreeDGardenPlant[] = []; - const startX = 350; + const startX = 200; let nextX = startX; let index = 0; let nextId = 1; - while (nextX <= config.bedLengthOuter - 100) { + while (nextX <= config.bedLengthOuter - 250) { const plantKey = gardenPlants[index]; const plant = PLANTS[plantKey]; if (!plant) { return []; } diff --git a/frontend/redux/__tests__/create_refresh_trigger_test.ts b/frontend/redux/__tests__/create_refresh_trigger_test.ts index 10b4ce1384..5df00c7912 100644 --- a/frontend/redux/__tests__/create_refresh_trigger_test.ts +++ b/frontend/redux/__tests__/create_refresh_trigger_test.ts @@ -11,7 +11,7 @@ describe("createRefreshTrigger", () => { .mockImplementation(jest.fn(() => jest.fn())); maybeGetDeviceSpy = jest.spyOn(deviceModule, "maybeGetDevice") .mockImplementation((() => - ({} as import("farmbot").Farmbot)) as typeof deviceModule.maybeGetDevice); + ({} as import("farmbot").Farmbot))); }); afterEach(() => { diff --git a/frontend/redux/__tests__/root_reducer_test.ts b/frontend/redux/__tests__/root_reducer_test.ts index 5f8734a56e..23c0ff90fb 100644 --- a/frontend/redux/__tests__/root_reducer_test.ts +++ b/frontend/redux/__tests__/root_reducer_test.ts @@ -9,7 +9,7 @@ describe("rootReducer()", () => { beforeEach(() => { clearSpy = jest.spyOn(Session, "clear") - .mockImplementation((() => undefined as never) as typeof Session.clear); + .mockImplementation((() => undefined) as typeof Session.clear); }); afterEach(() => { diff --git a/frontend/regimens/editor/__tests__/state_to_props_test.ts b/frontend/regimens/editor/__tests__/state_to_props_test.ts index e7c977605e..4f8dccda79 100644 --- a/frontend/regimens/editor/__tests__/state_to_props_test.ts +++ b/frontend/regimens/editor/__tests__/state_to_props_test.ts @@ -63,7 +63,7 @@ describe("mapStateToProps()", () => { const varData = fakeVariableNameSet(); state.resources.index.sequenceMetas[seq.uuid] = varData; const findSequenceByIdSpy = jest.spyOn(selectors, "findSequenceById") - .mockReturnValue(seq as never); + .mockReturnValue(seq); try { const props = mapStateToProps(state); expect(props.variableData).toEqual(expect.objectContaining(varData)); diff --git a/frontend/regimens/list/__tests__/list_test.tsx b/frontend/regimens/list/__tests__/list_test.tsx index 93ef214043..aff02c6639 100644 --- a/frontend/regimens/list/__tests__/list_test.tsx +++ b/frontend/regimens/list/__tests__/list_test.tsx @@ -28,15 +28,15 @@ describe("", () => { designerPanelSpy = jest.spyOn(designerPanel, "DesignerPanel") .mockImplementation((( { children }: React.ComponentProps) => -
{children}
) as never); +
{children}
)); designerPanelContentSpy = jest.spyOn(designerPanel, "DesignerPanelContent") .mockImplementation((( { children }: React.ComponentProps) => -
{children}
) as never); +
{children}
)); designerPanelTopSpy = jest.spyOn(designerPanel, "DesignerPanelTop") .mockImplementation((( props: React.ComponentProps) => - mockDesignerPanelTop(props)) as never); + mockDesignerPanelTop(props))); mockDesignerPanelTop.mockClear(); }); diff --git a/frontend/resources/reducer_support.ts b/frontend/resources/reducer_support.ts index ed40a37878..9b5196e744 100644 --- a/frontend/resources/reducer_support.ts +++ b/frontend/resources/reducer_support.ts @@ -423,7 +423,7 @@ export function beforeEach(state: RestResources, const { warning } = require("../toast/toast"); warning(`(${place}) Can't modify account data when in read-only mode.`); }; - const { kind } = unpackUUID(get(action, "payload.uuid", "x.y.z") as string); + const { kind } = unpackUUID(get(action, "payload.uuid", "x.y.z")); switch (action.type) { case Actions.EDIT_RESOURCE: diff --git a/frontend/resources/selectors_by_id.ts b/frontend/resources/selectors_by_id.ts index 15e1f93f0d..843220e957 100644 --- a/frontend/resources/selectors_by_id.ts +++ b/frontend/resources/selectors_by_id.ts @@ -30,7 +30,7 @@ const byId = const resources = findAll(index, kind); const f = (x: TaggedResource) => (x.kind === kind) && (x.body.id === id); // Maybe we should add a throw here? - return resources.filter(f)[0] as T | undefined; + return resources.filter(f)[0]; }; export const findFarmEventById = (ri: ResourceIndex, fe_id: number) => { diff --git a/frontend/resources/sequence_meta.ts b/frontend/resources/sequence_meta.ts index e0e054beaa..9500d6bf0e 100644 --- a/frontend/resources/sequence_meta.ts +++ b/frontend/resources/sequence_meta.ts @@ -170,7 +170,7 @@ export const determineDropdown = label: get({ "SavedGarden": t("Garden"), "PointGroup": t("Group"), - }, resourceType, resourceType) as string, + }, resourceType, resourceType), value: data_value.args.resource_type, headingId: "Resource", }; diff --git a/frontend/saved_gardens/__tests__/garden_snapshot_test.tsx b/frontend/saved_gardens/__tests__/garden_snapshot_test.tsx index 3bab78fe87..2f7a0509ae 100644 --- a/frontend/saved_gardens/__tests__/garden_snapshot_test.tsx +++ b/frontend/saved_gardens/__tests__/garden_snapshot_test.tsx @@ -14,7 +14,7 @@ let axiosPostSpy: jest.SpyInstance; beforeEach(() => { axiosPostSpy = jest.spyOn(axios, "post") - .mockImplementation(() => Promise.resolve({}) as never); + .mockImplementation(() => Promise.resolve({})); snapshotGardenSpy = jest.spyOn(savedGardenActions, "snapshotGarden") .mockImplementation(jest.fn()); newSavedGardenSpy = jest.spyOn(savedGardenActions, "newSavedGarden") @@ -82,7 +82,7 @@ describe("", () => { currentTarget: { value: "new saved garden" }, target: { value: "new saved garden" }, }); - fireEvent.click(container.querySelectorAll("button")[1] as Element); + fireEvent.click(container.querySelectorAll("button")[1]); expect(newSavedGarden).toHaveBeenCalledWith(expect.any(Function), "new saved garden", ""); }); diff --git a/frontend/sensors/__tests__/sensor_list_test.tsx b/frontend/sensors/__tests__/sensor_list_test.tsx index 32b528c583..fcccfd34ff 100644 --- a/frontend/sensors/__tests__/sensor_list_test.tsx +++ b/frontend/sensors/__tests__/sensor_list_test.tsx @@ -89,9 +89,9 @@ describe("", function () { it("reads sensors", () => { const { container } = render(); const readSensorBtn = container.querySelectorAll("button"); - fireEvent.click(readSensorBtn[0] as Element); + fireEvent.click(readSensorBtn[0]); expect(mockDevice.readPin).toHaveBeenCalledWith(expectedPayload(51, 0)); - fireEvent.click(readSensorBtn[1] as Element); + fireEvent.click(readSensorBtn[1]); expect(mockDevice.readPin).toHaveBeenLastCalledWith(expectedPayload(50, 1)); expect(mockDevice.readPin).toHaveBeenCalledTimes(2); }); @@ -101,8 +101,8 @@ describe("", function () { p.disabled = true; const { container } = render(); const readSensorBtn = container.querySelectorAll("button"); - fireEvent.click(readSensorBtn[0] as Element); - fireEvent.click(readSensorBtn[readSensorBtn.length - 1] as Element); + fireEvent.click(readSensorBtn[0]); + fireEvent.click(readSensorBtn[readSensorBtn.length - 1]); expect(mockDevice.readPin).not.toHaveBeenCalled(); }); diff --git a/frontend/sequences/__tests__/actions_test.ts b/frontend/sequences/__tests__/actions_test.ts index 4e9b1cc3aa..eeff629d14 100644 --- a/frontend/sequences/__tests__/actions_test.ts +++ b/frontend/sequences/__tests__/actions_test.ts @@ -46,7 +46,7 @@ beforeEach(() => { overwriteSpy = jest.spyOn(crud, "overwrite") .mockImplementation(jest.fn()); axiosPostSpy = jest.spyOn(axios, "post") - .mockImplementation(() => mockPost as never); + .mockImplementation(() => mockPost); setActiveSequenceByNameSpy = jest.spyOn( activeSequenceByName, "setActiveSequenceByName") .mockImplementation(jest.fn()); diff --git a/frontend/sequences/__tests__/request_auto_generation_test.ts b/frontend/sequences/__tests__/request_auto_generation_test.ts index d5d6961cd8..56de16188e 100644 --- a/frontend/sequences/__tests__/request_auto_generation_test.ts +++ b/frontend/sequences/__tests__/request_auto_generation_test.ts @@ -62,7 +62,7 @@ describe("requestAutoGeneration()", () => { .mockResolvedValueOnce({ done: false, value: new Uint8Array([101]) }) .mockResolvedValueOnce({ done: false, value: new Uint8Array([100]) }), ))); - global.fetch = fetchMock as unknown as typeof fetch; + global.fetch = fetchMock as never; const p = fakeProps(); p.contextKey = "color"; actualRequestAutoGeneration(p); @@ -90,7 +90,7 @@ describe("requestAutoGeneration()", () => { jest.fn().mockResolvedValue({ done: true, value: "" }), { ok: false, body: undefined }, ))); - global.fetch = fetchMock as unknown as typeof fetch; + global.fetch = fetchMock as never; const p = fakeProps(); p.contextKey = "lua"; actualRequestAutoGeneration(p); diff --git a/frontend/sequences/__tests__/sequences_test.tsx b/frontend/sequences/__tests__/sequences_test.tsx index 8df7b085a2..e3190c3e8a 100644 --- a/frontend/sequences/__tests__/sequences_test.tsx +++ b/frontend/sequences/__tests__/sequences_test.tsx @@ -33,7 +33,7 @@ beforeEach(() => { data: [ { id: 1, name: "name", description: "", path: "", color: "gray" }, ] - }) as never); + })); }); afterEach(() => { diff --git a/frontend/sequences/locals_list/__tests__/default_value_form_test.tsx b/frontend/sequences/locals_list/__tests__/default_value_form_test.tsx index 0dac0d71ab..0252f786c6 100644 --- a/frontend/sequences/locals_list/__tests__/default_value_form_test.tsx +++ b/frontend/sequences/locals_list/__tests__/default_value_form_test.tsx @@ -31,7 +31,7 @@ describe("", () => { mockVariableFormOnChangeArg.args.label, )} />
; - }) as never); + })); }); afterEach(() => { diff --git a/frontend/sequences/locals_list/__tests__/variable_form_test.tsx b/frontend/sequences/locals_list/__tests__/variable_form_test.tsx index e3f588e5fa..f72023d545 100644 --- a/frontend/sequences/locals_list/__tests__/variable_form_test.tsx +++ b/frontend/sequences/locals_list/__tests__/variable_form_test.tsx @@ -54,7 +54,7 @@ beforeEach(() => { ...e, currentTarget: { ...e.currentTarget, value } - } as React.SyntheticEvent)} /> + })} />