From ea4d57fc6ff3aec1f3869bf97a93c738d02803b8 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 18 Aug 2026 17:41:38 -0700 Subject: [PATCH 01/15] fix geometry errors --- .../bot/__tests__/belts_test.tsx | 4 +- frontend/three_d_garden/bot/belt_path.ts | 14 ++- frontend/three_d_garden/bot/belts.tsx | 19 +++- .../__tests__/cable_carriers_test.tsx | 18 ++++ .../components/__tests__/gantry_beam_test.tsx | 33 +++++-- .../mutable_routing_geometry_test.ts | 8 +- .../bot/components/cable_carriers.tsx | 95 ++++++++++--------- .../bot/components/gantry_beam.tsx | 14 ++- .../components/mutable_routing_geometry.ts | 17 +++- .../__tests__/constellation_data_test.ts | 26 +++++ .../garden/constellation_data.ts | 17 +++- 11 files changed, 190 insertions(+), 75 deletions(-) diff --git a/frontend/three_d_garden/bot/__tests__/belts_test.tsx b/frontend/three_d_garden/bot/__tests__/belts_test.tsx index b51b18fff7..d9476e3796 100644 --- a/frontend/three_d_garden/bot/__tests__/belts_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/belts_test.tsx @@ -153,9 +153,9 @@ describe("belts", () => { (callback: Parameters[0]) => { frameCallbacks.push(callback); return undefined; - }); + }); const positionRef = { - current: { x: 300, y: 0, z: 0 }, + current: { x: -50, y: 0, z: 0 }, }; const disposeSpy = jest.spyOn(BufferGeometry.prototype, "dispose"); const { container, unmount } = render( { if (candidates.length != 1) { - throw new Error("Belt route does not have one valid tangent."); + throw new InvalidBeltPathError( + "Belt route does not have one valid tangent.", + ); } return candidates[0]; }; @@ -314,7 +320,9 @@ const beltProjection = (nodes: BeltPathNode3D[]): BeltProjection => { origin, }; } - throw new Error("Belt path must lie in one axis-aligned plane."); + throw new InvalidBeltPathError( + "Belt path must lie in one axis-aligned plane.", + ); }; const projectBeltPoint = ( diff --git a/frontend/three_d_garden/bot/belts.tsx b/frontend/three_d_garden/bot/belts.tsx index 08dfd3a943..cde4d3bbe0 100644 --- a/frontend/three_d_garden/bot/belts.tsx +++ b/frontend/three_d_garden/bot/belts.tsx @@ -7,7 +7,7 @@ import { useFrame } from "@react-three/fiber"; import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"; import { Group, Mesh, MeshPhongMaterial } from "../components"; -import { BeltPath } from "./belt_path"; +import { BeltPath, InvalidBeltPathError } from "./belt_path"; import { getBotVersion } from "./bot_versions"; import { millimetreGeometryKey, useOwnedBufferGeometries, @@ -70,7 +70,12 @@ const FrameBelt = (props: FrameBeltProps) => { const initialPosition = props.positionRef.current; const [initialGeometry] = React.useState(() => { perfCount(props.metric); - return new MutableBeltGeometry(props.createPath(initialPosition)); + try { + return new MutableBeltGeometry(props.createPath(initialPosition)); + } catch (error) { + if (!(error instanceof InvalidBeltPathError)) { throw error; } + return new MutableBeltGeometry(props.createPath({ x: 0, y: 0, z: 0 })); + } }); const lastDeformationKey = React.useRef( props.deformationKey(initialPosition), @@ -85,8 +90,14 @@ const FrameBelt = (props: FrameBeltProps) => { const deformationKey = props.deformationKey(position); if (deformationKey === lastDeformationKey.current) { return; } perfCount(`${props.metric}.update`); - initialGeometry.update(props.createPath(position)); - lastDeformationKey.current = deformationKey; + try { + initialGeometry.update(props.createPath(position)); + lastDeformationKey.current = deformationKey; + } catch (error) { + if (!(error instanceof InvalidBeltPathError)) { + throw error; + } + } }); React.useLayoutEffect(() => () => { diff --git a/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx b/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx index 563e78c0b1..7a64d1a159 100644 --- a/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/cable_carriers_test.tsx @@ -91,6 +91,24 @@ describe("moving cable carriers", () => { moveToSpy.mockRestore(); }); + it("sizes the Y carrier by beam length", () => { + const p = fakeProps(); + const moveToSpy = jest.spyOn(Shape.prototype, "moveTo"); + const { rerender } = render(); + expect(moveToSpy).toHaveBeenCalledTimes(1); + rerender(); + expect(moveToSpy).toHaveBeenCalledTimes(1); + rerender(); + expect(moveToSpy).toHaveBeenCalledTimes(2); + moveToSpy.mockRestore(); + }); + it("disposes replaced and unmounted moving carrier geometry", () => { const p = fakeProps(); const disposeSpy = jest.spyOn(ExtrudeGeometry.prototype, "dispose"); diff --git a/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx b/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx index f8aeb591d4..bde1a7941d 100644 --- a/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx @@ -34,7 +34,7 @@ import React from "react"; import { render } from "@testing-library/react"; import { useHelper } from "@react-three/drei"; import { INITIAL, INITIAL_POSITION, PRESETS } from "../../../config"; -import { clone } from "lodash"; +import { clone, range } from "lodash"; import { GantryBeam, GantryBeamProps } from "../gantry_beam"; import { Shape, Texture } from "three"; import * as threeFiber from "@react-three/fiber"; @@ -88,23 +88,42 @@ describe("", () => { expect(container).toContainHTML("light"); }); - it("renders lights without frame callbacks", () => { + it("updates light targets in render frames", () => { + const frameCallbacks: Parameters[0][] = []; + const helperUpdate = jest.fn(); + const helperMock = useHelper as unknown as jest.Mock; + range(5).forEach(() => helperMock.mockReturnValueOnce({ + current: { update: helperUpdate }, + })); + useFrameSpy.mockImplementation( + (callback: Parameters[0]) => { + frameCallbacks.push(callback); + return undefined; + }); const p = fakeProps(); p.config.light = true; p.config.kitVersion = "v1.8"; const { container } = render(); expect(container).toContainHTML("light"); - expect(useFrameSpy).not.toHaveBeenCalled(); + expect(frameCallbacks).toHaveLength(5); + jest.clearAllMocks(); + + frameCallbacks.forEach(callback => callback({} as never, 0)); + + expect(mockRef.current?.getWorldPosition).toHaveBeenCalledTimes(5); + expect(mockRef.current?.target.position.copy).toHaveBeenCalledTimes(5); + expect(mockRef.current?.target.updateMatrixWorld).toHaveBeenCalledTimes(5); + expect(helperUpdate).toHaveBeenCalledTimes(5); }); it("updates light targets", () => { const p = fakeProps(); p.config.light = true; render(); - expect(mockRef.current?.getWorldPosition).toHaveBeenCalledTimes(5); - expect(mockRef.current?.copy).toHaveBeenCalledTimes(5); - expect(mockRef.current?.target.position.copy).toHaveBeenCalledTimes(5); - expect(mockRef.current?.target.updateMatrixWorld).toHaveBeenCalledTimes(5); + expect(mockRef.current?.getWorldPosition).toHaveBeenCalledTimes(10); + expect(mockRef.current?.copy).toHaveBeenCalledTimes(10); + expect(mockRef.current?.target.position.copy).toHaveBeenCalledTimes(10); + expect(mockRef.current?.target.updateMatrixWorld).toHaveBeenCalledTimes(10); }); it("renders debug helpers", () => { diff --git a/frontend/three_d_garden/bot/components/__tests__/mutable_routing_geometry_test.ts b/frontend/three_d_garden/bot/components/__tests__/mutable_routing_geometry_test.ts index 19c185e8a4..e7049277dd 100644 --- a/frontend/three_d_garden/bot/components/__tests__/mutable_routing_geometry_test.ts +++ b/frontend/three_d_garden/bot/components/__tests__/mutable_routing_geometry_test.ts @@ -39,18 +39,22 @@ describe("mutable routing geometry", () => { geometry.dispose(); }); - it("rejects carrier and belt topology changes", () => { + it("rebuilds carrier geometry when its topology changes", () => { const shape = new Shape(); shape.moveTo(0, 0); shape.lineTo(10, 0); shape.lineTo(10, 10); shape.closePath(); const carrier = new MutableCarrierGeometry(shape, 5); + const position = carrier.getAttribute("position"); const changedShape = shape.clone(); changedShape.lineTo(0, 5); - expect(() => carrier.update(changedShape)).toThrow("topology changed"); + carrier.update(changedShape); + expect(carrier.getAttribute("position")).not.toBe(position); carrier.dispose(); + }); + it("rejects belt topology changes", () => { const belt = new MutableBeltGeometry( buildXAxisBeltPath("v1.9", 500, 2987, 300), ); diff --git a/frontend/three_d_garden/bot/components/cable_carriers.tsx b/frontend/three_d_garden/bot/components/cable_carriers.tsx index b73338dda9..39307e1949 100644 --- a/frontend/three_d_garden/bot/components/cable_carriers.tsx +++ b/frontend/three_d_garden/bot/components/cable_carriers.tsx @@ -160,8 +160,8 @@ const cableCarrierXConfigFields: ConfigField[] = [ const cableCarrierYConfigFields: ConfigField[] = [ "cableCarriers", + "beamLength", "columnLength", - "botSizeY", "kitVersion", ...positionTransformConfigFields, ]; @@ -258,7 +258,7 @@ const sameCableCarrierSupportHorizontalProps = ( ) => { const configFields = usesExtrudedCableCarrierSupports(prev.config.kitVersion) || - usesExtrudedCableCarrierSupports(next.config.kitVersion) + usesExtrudedCableCarrierSupports(next.config.kitVersion) ? supportHorizontalV18ConfigFields : supportHorizontalConfigFields; return sameCableCarrierProps( @@ -313,18 +313,19 @@ const VisibleCableCarrierX = (props: CableCarrierXProps) => { ] as [number, number, number] : [position.x, position.y, -40] as [number, number, number]; if (props.positionRef) { - return `${botPosition.x}`} - initialPosition={renderPosition} - metric={"bot.geometry.carrier.x"} - name={"xCC"} - position={() => renderPosition} - positionRef={props.positionRef} - rotation={[-Math.PI / 2, -Math.PI, 0 * Math.PI]} />; + return `${botPosition.x}`} + initialPosition={renderPosition} + metric={"bot.geometry.carrier.x"} + name={"xCC"} + position={() => renderPosition} + positionRef={props.positionRef} + rotation={[-Math.PI / 2, -Math.PI, 0 * Math.PI]} />; } return { const VisibleCableCarrierY = (props: CableCarrierYProps) => { const { - columnLength, botSizeY, kitVersion, + beamLength, columnLength, kitVersion, } = props.config; const { x, y } = props.configPosition; const get3DPosition = get3DPositionNoMirrorFunc(props.config); @@ -365,31 +366,32 @@ const VisibleCableCarrierY = (props: CableCarrierYProps) => { : [position.x, position.y, columnLength + 150]; }; const createArgs = React.useCallback(() => [[ - buildCableCarrierShape(botSizeY, y + 40, 70), + buildCableCarrierShape(beamLength, y + 40, 70), { steps: 1, depth: ccDepth, bevelEnabled: false }, - ]] as ExtrudeGeometryArgs[], [botSizeY, ccDepth, y]); + ]] as ExtrudeGeometryArgs[], [beamLength, ccDepth, y]); const createFrameArgs = (botPosition: PositionConfig) => [[ - buildCableCarrierShape(botSizeY, botPosition.y + 40, 70), + buildCableCarrierShape(beamLength, botPosition.y + 40, 70), { steps: 1, depth: ccDepth, bevelEnabled: false }, ]] as ExtrudeGeometryArgs[]; if (props.positionRef) { - return `${botPosition.y}`} - initialPosition={getFramePosition(props.positionRef.current)} - metric={"bot.geometry.carrier.y"} - name={"yCC"} - position={getFramePosition} - positionRef={props.positionRef} - rotation={[-Math.PI / 2, -Math.PI / 2, 0]} />; + return `${botPosition.y}`} + initialPosition={getFramePosition(props.positionRef.current)} + metric={"bot.geometry.carrier.y"} + name={"yCC"} + position={getFramePosition} + positionRef={props.positionRef} + rotation={[-Math.PI / 2, -Math.PI / 2, 0]} />; } return { ]; }; if (props.positionRef) { - return `${botPosition.z}`} - initialPosition={getFramePosition(props.positionRef.current)} - metric={"bot.geometry.carrier.z"} - name={"zCC"} - position={getFramePosition} - positionRef={props.positionRef} - rotation={[Math.PI / 2, Math.PI, Math.PI / 2]} />; + return `${botPosition.z}`} + initialPosition={getFramePosition(props.positionRef.current)} + metric={"bot.geometry.carrier.z"} + name={"zCC"} + position={getFramePosition} + positionRef={props.positionRef} + rotation={[Math.PI / 2, Math.PI, Math.PI / 2]} />; } return { // eslint-disable-next-line no-null/no-null const lightRef = React.useRef(null!); - useHelper(debug ? lightRef : undefined, SpotLightHelper, "white"); + const helperRef = useHelper( + debug ? lightRef : undefined, + SpotLightHelper, + "white", + ); const worldPosRef = React.useRef(new Vector3()); const targetPosRef = React.useRef(new Vector3()); const downVector = React.useMemo(() => new Vector3(0, 0, -1), []); - React.useLayoutEffect(() => { + const updateTarget = React.useCallback(() => { const light = lightRef.current; if (!light || typeof light.getWorldPosition != "function") { return; } const worldPos = worldPosRef.current; @@ -123,7 +128,10 @@ const Light = ({ yOffset, debug }: { yOffset: number, debug: boolean }) => { targetPos.copy(worldPos).add(downVector); light.target.position.copy(targetPos); light.target.updateMatrixWorld(); - }); + helperRef?.current?.update(); + }, [downVector, helperRef]); + React.useLayoutEffect(updateTarget); + useFrame(updateTarget); return { expect(fetchCatalog).toHaveBeenCalledWith("/constellations.bin"); }); + it("retries a truncated catalog without using the cached response", async () => { + const fetchCatalog = jest.fn() + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }) + .mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(catalogBuffer()), + }) as unknown as typeof fetch; + const resource = createCropConstellationCatalogResource( + "/constellations.bin", + fetchCatalog, + ); + const load = thrownBy(resource.read) as Promise; + + await load; + expect(resource.read().constellations[0].cropSlug).toEqual("crop"); + expect(fetchCatalog).toHaveBeenNthCalledWith(1, "/constellations.bin"); + expect(fetchCatalog).toHaveBeenNthCalledWith( + 2, + "/constellations.bin", + { cache: "reload" }, + ); + }); + it("reports HTTP failures", async () => { const fetchCatalog = jest.fn(() => Promise.resolve({ ok: false, diff --git a/frontend/three_d_garden/garden/constellation_data.ts b/frontend/three_d_garden/garden/constellation_data.ts index 66d22e23b1..f1feef0ee3 100644 --- a/frontend/three_d_garden/garden/constellation_data.ts +++ b/frontend/three_d_garden/garden/constellation_data.ts @@ -87,8 +87,11 @@ export const createCropConstellationCatalogResource = ( let catalogPromise: Promise | undefined; let catalogError: Error | undefined; - const load = () => { - catalogPromise ||= fetchCatalog(url) + const fetchAndDecode = (reload = false) => { + const request = reload + ? fetchCatalog(url, { cache: "reload" }) + : fetchCatalog(url); + return request .then(response => { if (!response.ok) { throw new Error( @@ -97,7 +100,15 @@ export const createCropConstellationCatalogResource = ( } return response.arrayBuffer(); }) - .then(decodeCropConstellationCatalog) + .then(decodeCropConstellationCatalog); + }; + + const load = () => { + catalogPromise ||= fetchAndDecode() + .catch(error => error instanceof Error && + error.message == "Constellation data is truncated." + ? fetchAndDecode(true) + : Promise.reject(error)) .then(decodedCatalog => catalog = decodedCatalog) .catch(error => { const loadError = error instanceof Error From 44a12781a7cddc6c7f6faa4b4502c9ab2fd2ee1a Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 18 Aug 2026 17:44:47 -0700 Subject: [PATCH 02/15] organize stargazing files --- .gitignore | 3 +- .madgerc | 33 +++- frontend/__tests__/session_test.ts | 2 +- frontend/command_palette/commands.tsx | 134 +++++++++-------- frontend/farm_designer/index.tsx | 2 +- .../map/legend/garden_map_legend.tsx | 2 +- frontend/farm_designer/reducer.ts | 2 +- .../farm_designer/three_d_camera_controls.tsx | 4 +- frontend/farm_designer/three_d_section.tsx | 6 +- .../__tests__/garden_model_test.tsx | 36 ++--- .../three_d_garden/bed/objects/telescope.tsx | 4 +- .../bot/__tests__/belts_test.tsx | 2 +- .../garden/__tests__/sun_test.tsx | 4 +- frontend/three_d_garden/garden/index.ts | 2 +- frontend/three_d_garden/garden/sun.tsx | 2 +- frontend/three_d_garden/garden_model.tsx | 6 +- frontend/three_d_garden/section_controls.tsx | 34 +++-- .../__tests__/constellation_data_test.ts | 0 .../__tests__/constellations_test.tsx | 0 .../__tests__/stargazing_progress_test.ts | 2 +- .../stargazing}/__tests__/stargazing_test.tsx | 4 +- .../constellation_data.ts | 0 .../{garden => stargazing}/constellations.tsx | 22 +-- .../generated_constellations.bin | Bin .../stargazing}/stargazing.tsx | 30 ++-- .../stargazing}/stargazing_constants.ts | 0 .../stargazing}/stargazing_progress.ts | 2 +- .../stargazing}/stargazing_progress_key.ts | 0 package.json | 2 - scripts/generate_constellation.ts | 2 +- scripts/generate_constellation_test.ts | 4 +- scripts/graphs/color_by_directory.ts | 142 ++++++++++++++++++ scripts/graphs/create.sh | 40 +++++ 33 files changed, 367 insertions(+), 161 deletions(-) rename frontend/three_d_garden/{garden => stargazing}/__tests__/constellation_data_test.ts (100%) rename frontend/three_d_garden/{garden => stargazing}/__tests__/constellations_test.tsx (100%) rename frontend/{farm_designer => three_d_garden/stargazing}/__tests__/stargazing_progress_test.ts (98%) rename frontend/{farm_designer => three_d_garden/stargazing}/__tests__/stargazing_test.tsx (99%) rename frontend/three_d_garden/{garden => stargazing}/constellation_data.ts (100%) rename frontend/three_d_garden/{garden => stargazing}/constellations.tsx (98%) rename frontend/three_d_garden/{garden => stargazing}/generated_constellations.bin (100%) rename frontend/{farm_designer => three_d_garden/stargazing}/stargazing.tsx (93%) rename frontend/{farm_designer => three_d_garden/stargazing}/stargazing_constants.ts (100%) rename frontend/{farm_designer => three_d_garden/stargazing}/stargazing_progress.ts (98%) rename frontend/{farm_designer => three_d_garden/stargazing}/stargazing_progress_key.ts (100%) create mode 100644 scripts/graphs/color_by_directory.ts create mode 100644 scripts/graphs/create.sh diff --git a/.gitignore b/.gitignore index 76ac6ab77d..6870486ace 100755 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,6 @@ upgrade_deps.sh # ActiveStorage blobs: storage/* tmp -module_graph.* +scripts/graphs/*.svg +scripts/graphs/*.dot scripts/ci/__pycache__ diff --git a/.madgerc b/.madgerc index 588d2038a7..72343d75ab 100644 --- a/.madgerc +++ b/.madgerc @@ -1,14 +1,33 @@ { "excludeRegExp": [ - "^(?!.*three_d_garden)", - "[/\\\\]components\\.tsx$", - "[/\\\\]config\\.ts$", - "[/\\\\]helpers\\.ts$", - "[/\\\\]constants\\.ts$", - "[/\\\\]__tests__[/\\\\]" + "(^|[/\\\\])i18next_wrapper\\.tsx$", + "(^|[/\\\\])components\\.tsx$", + "(^|[/\\\\])config\\.ts$", + "(^|[/\\\\])helpers\\.ts$", + "(^|[/\\\\])constants\\.ts$", + "(^|[/\\\\])interfaces\\.ts$", + "/util/index.ts", + "internal_urls.ts", + "api/crud.ts", + "toast/toast.ts", + "redux/store.ts", + "resources/selectors.ts", + "session_keys.ts", + "farm_designer/map/util.ts", + "performance/perf.ts", + "config_storage/actions.ts", + "devices/actions.ts", + "link.tsx", + "error_boundary.tsx", + "(^|[/\\\\])__tests__[/\\\\]", + "(^|[/\\\\])__test_support__[/\\\\]", + "(^|[/\\\\])crops[/\\\\]", + "(^|[/\\\\])ui[/\\\\]" ], "fileExtensions": [ "ts", "tsx" - ] + ], + "tsConfig": "./tsconfig.json", + "layout": "sfdp" } diff --git a/frontend/__tests__/session_test.ts b/frontend/__tests__/session_test.ts index 81ac6bb4d6..d081fe24c3 100644 --- a/frontend/__tests__/session_test.ts +++ b/frontend/__tests__/session_test.ts @@ -5,7 +5,7 @@ import { } from "../session"; import { auth } from "../__test_support__/fake_state/token"; import { STARGAZING_PROGRESS_STORAGE_KEY } from - "../farm_designer/stargazing_progress_key"; + "../three_d_garden/stargazing/stargazing_progress_key"; beforeEach(() => { localStorage.clear(); diff --git a/frontend/command_palette/commands.tsx b/frontend/command_palette/commands.tsx index f13441509e..6fb4c15a5c 100644 --- a/frontend/command_palette/commands.tsx +++ b/frontend/command_palette/commands.tsx @@ -848,29 +848,30 @@ const settingsSectionCommands = (props: BuildCommandProps): Command[] => { const sections: { key: keyof SettingsPanelState; title: DeviceSetting; - }[] = [ - { key: "farmbot_settings", title: DeviceSetting.farmbotSettings }, - { key: "power_and_reset", title: DeviceSetting.powerAndReset }, - { key: "axis_settings", title: DeviceSetting.axisSettings }, - { key: "motors", title: DeviceSetting.motors }, - { - key: "encoders_or_stall_detection", - title: DeviceSetting.encoders, - }, - { key: "limit_switches", title: DeviceSetting.limitSwitchSettings }, - { key: "error_handling", title: DeviceSetting.errorHandling }, - { key: "pin_bindings", title: DeviceSetting.pinBindings }, - { key: "pin_guard", title: DeviceSetting.pinGuard }, - { - key: "parameter_management", - title: DeviceSetting.parameterManagement, - }, - { key: "custom_settings", title: DeviceSetting.customSettings }, - { key: "farm_designer", title: DeviceSetting.farmDesigner }, - { key: "three_d", title: DeviceSetting.threeDGarden }, - { key: "account", title: DeviceSetting.accountSettings }, - { key: "other_settings", title: DeviceSetting.otherSettings }, - ]; + }[] = + [ + { key: "farmbot_settings", title: DeviceSetting.farmbotSettings }, + { key: "power_and_reset", title: DeviceSetting.powerAndReset }, + { key: "axis_settings", title: DeviceSetting.axisSettings }, + { key: "motors", title: DeviceSetting.motors }, + { + key: "encoders_or_stall_detection", + title: DeviceSetting.encoders, + }, + { key: "limit_switches", title: DeviceSetting.limitSwitchSettings }, + { key: "error_handling", title: DeviceSetting.errorHandling }, + { key: "pin_bindings", title: DeviceSetting.pinBindings }, + { key: "pin_guard", title: DeviceSetting.pinGuard }, + { + key: "parameter_management", + title: DeviceSetting.parameterManagement, + }, + { key: "custom_settings", title: DeviceSetting.customSettings }, + { key: "farm_designer", title: DeviceSetting.farmDesigner }, + { key: "three_d", title: DeviceSetting.threeDGarden }, + { key: "account", title: DeviceSetting.accountSettings }, + { key: "other_settings", title: DeviceSetting.otherSettings }, + ]; return sections.map(({ key, title }) => ({ id: `settings-section:${key}`, ...sectionCommandText(`Settings > ${title}`, @@ -1069,15 +1070,16 @@ const boolSettingConfirmations: Partial> = { const toggleAccessory = ( current: boolean, disabled = false, -) => (run: () => void, recentValue?: boolean) => - { - event.stopPropagation(); - run(); - }} - customText={{ textFalse: t("off"), textTrue: t("on") }} />; +) => + (run: () => void, recentValue?: boolean) => + { + event.stopPropagation(); + run(); + }} + customText={{ textFalse: t("off"), textTrue: t("on") }} />; const laserCommand = (props: BuildCommandProps): Command => { const index = props.state.resources.index; @@ -1877,7 +1879,8 @@ const homeCommands = (props: BuildCommandProps): Command[] => { const axisActions = ( axes: T[], execute: (axis: T) => unknown, - ): CommandAction[] => axes.map(axis => { + ): CommandAction[] => { + return axes.map(axis => { const label = axis == "all" ? "All" : axis.toUpperCase(); return { id: axis, @@ -1886,6 +1889,7 @@ const homeCommands = (props: BuildCommandProps): Command[] => { execute: () => execute(axis), }; }); + }; const findActions = axisActions(allAxes, findHome); const moveActions = axisActions(allAxes, moveToHome); const lengthActions = axisActions(allAxes, findAxisLength); @@ -1986,31 +1990,32 @@ const simplePanelCommands = (props: BuildCommandProps): Command[] => { panel: Panel; addAction: CommandAction; additionalActions?: CommandAction[]; - }[] = [ - { - panel: Panel.SceneObjects, - addAction: add("add-new", "Add New", - () => openAddPage(props, Path.sceneObjects("catalog"))), - additionalActions: [add("add-custom", "Add Custom", - () => openAddPage(props, Path.sceneObjects("add"))), - ], - }, - { - panel: Panel.Regimens, - addAction: add("add-new", "Add New", () => props.dispatch(addRegimen( - selectAllRegimens(index).length, props.navigate))), - }, - { - panel: Panel.FarmEvents, - addAction: add("add-new", "Add New", - () => openAddPage(props, Path.farmEvents("add"))), - }, - { - panel: Panel.Sensors, - addAction: add("add-new", "Add New", - () => openAddPage(props, Path.sensors())), - }, - ]; + }[] = + [ + { + panel: Panel.SceneObjects, + addAction: add("add-new", "Add New", + () => openAddPage(props, Path.sceneObjects("catalog"))), + additionalActions: [add("add-custom", "Add Custom", + () => openAddPage(props, Path.sceneObjects("add"))), + ], + }, + { + panel: Panel.Regimens, + addAction: add("add-new", "Add New", () => props.dispatch(addRegimen( + selectAllRegimens(index).length, props.navigate))), + }, + { + panel: Panel.FarmEvents, + addAction: add("add-new", "Add New", + () => openAddPage(props, Path.farmEvents("add"))), + }, + { + panel: Panel.Sensors, + addAction: add("add-new", "Add New", + () => openAddPage(props, Path.sensors())), + }, + ]; const commands = definitions.map(({ panel, addAction, additionalActions = [], }): Command => { @@ -2173,12 +2178,13 @@ const directDeviceCommands = (props: BuildCommandProps): Command[] => { }; const emergencyButton = ( id: "estop" | "unlock", - ) => (execute: () => void) => - ; + ) => + (execute: () => void) => + ; return commands.map(([id, name, execute, reason]) => ({ id: `farmbot:${id}`, priority: priorities[id], diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index 5a7d1fd44d..5c24a5d889 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -32,7 +32,7 @@ import { } from "../settings/three_d_settings"; import { isDesktop, isMobile } from "../screen_size"; import { NavigationContext } from "../routes_helpers"; -import { StargazingControls } from "./stargazing"; +import { StargazingControls } from "../three_d_garden/stargazing/stargazing"; export const getDefaultAxisLength = (getConfigValue: GetWebAppConfigValue): Record => { diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index 82dfb29eb4..157eeb195f 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -29,7 +29,7 @@ import { McuParams } from "farmbot"; import { DesignerState } from "../../interfaces"; import { isMobile } from "../../../screen_size"; import type { Config } from "../../../three_d_garden/config"; -import { ThreeDSectionSettings } from "../../three_d_section"; +import { ThreeDSectionSettings } from "../../../farm_designer/three_d_section"; export interface ZoomControlsProps { zoom(value: number): () => void; diff --git a/frontend/farm_designer/reducer.ts b/frontend/farm_designer/reducer.ts index b8b57fa79b..407f314fac 100644 --- a/frontend/farm_designer/reducer.ts +++ b/frontend/farm_designer/reducer.ts @@ -15,7 +15,7 @@ import { PointGroupSortType } from "farmbot/dist/resources/api_resources"; import { UUID } from "../resources/interfaces"; import { clampStargazingFov, STARGAZING_DEFAULT_FOV, -} from "./stargazing_constants"; +} from "../three_d_garden/stargazing/stargazing_constants"; export const initialState: DesignerState = { selectedPoints: undefined, diff --git a/frontend/farm_designer/three_d_camera_controls.tsx b/frontend/farm_designer/three_d_camera_controls.tsx index 5c4c5815b7..c9c5667646 100644 --- a/frontend/farm_designer/three_d_camera_controls.tsx +++ b/frontend/farm_designer/three_d_camera_controls.tsx @@ -4,8 +4,8 @@ import { UTM_FOLLOW_PERSPECTIVE_REQUIRED, } from "../constants"; import { t } from "../i18next_wrapper"; -import { DesignerState } from "./interfaces"; -import { Panel, TAB_ICON } from "./panel_header"; +import { DesignerState } from "../farm_designer/interfaces"; +import { Panel, TAB_ICON } from "../farm_designer/panel_header"; import { info } from "../toast/toast"; export interface ThreeDCameraControlsProps { diff --git a/frontend/farm_designer/three_d_section.tsx b/frontend/farm_designer/three_d_section.tsx index a00c1c804f..674085c07f 100644 --- a/frontend/farm_designer/three_d_section.tsx +++ b/frontend/farm_designer/three_d_section.tsx @@ -3,9 +3,9 @@ import { Actions } from "../constants"; import { t } from "../i18next_wrapper"; import { BlurableInput, ToggleButton } from "../ui"; import { BotPosition } from "../devices/interfaces"; -import { AxisNumberProperty } from "./map/interfaces"; -import { DesignerState, ThreeDDesignerState } from "./interfaces"; -import { BugsButton } from "./map/easter_eggs/bugs"; +import { AxisNumberProperty } from "../farm_designer/map/interfaces"; +import { DesignerState, ThreeDDesignerState } from "../farm_designer/interfaces"; +import { BugsButton } from "../farm_designer/map/easter_eggs/bugs"; export const SECTION_STEP = 1; export const SECTION_WIDTH_MIN = 1; diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index f14484f957..cd0c87556b 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -2406,17 +2406,17 @@ describe("", () => { stopPropagation: jest.fn(), })); - expect(wrapper.root.findByType(GardenAreaSelectionOverlay) - .props.selection).toEqual({ - phase: "complete", - pointType: "Plant", - box: { - x0: 100, - y0: 100, - x1: 0, - y1: p.config.botSizeY, - }, - }); + expect(wrapper.root.findByType(GardenAreaSelectionOverlay).props.selection) + .toEqual({ + phase: "complete", + pointType: "Plant", + box: { + x0: 100, + y0: 100, + x1: 0, + y1: p.config.botSizeY, + }, + }); actRenderer(() => { window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift", @@ -2642,13 +2642,13 @@ describe("", () => { const beyondX = get3DPositionFunc(p.config)({ x: -100, y: 300 }); actRenderer(() => hoverTarget.props.onPointerMove({ point: beyondX })); - expect(wrapper.root.findByType(GardenAreaSelectionOverlay) - .props.selection.box).toEqual({ - x0: 100, - y0: 100, - x1: 0, - y1: 300, - }); + expect(wrapper.root.findByType(GardenAreaSelectionOverlay).props.selection.box) + .toEqual({ + x0: 100, + y0: 100, + x1: 0, + y1: 300, + }); actRenderer(() => hoverTarget.props.onPointerMove({ point: end })); let overlay = wrapper.root.findByType(GardenAreaSelectionOverlay); diff --git a/frontend/three_d_garden/bed/objects/telescope.tsx b/frontend/three_d_garden/bed/objects/telescope.tsx index da725cebaf..6467051807 100644 --- a/frontend/three_d_garden/bed/objects/telescope.tsx +++ b/frontend/three_d_garden/bed/objects/telescope.tsx @@ -15,7 +15,7 @@ import { } from "../../components"; import { SECTION_CLIPPING_EXEMPT } from "../../section"; import { Camera } from "../../zoom_beacons_constants"; -import { setStargazingMode } from "../../../farm_designer/stargazing"; +import { setStargazingMode } from "../../stargazing/stargazing"; import { getUtilitiesPostWorldPosition } from "./utilities_post_position"; import { RenderOrder } from "../../constants"; import { t } from "../../../i18next_wrapper"; @@ -207,7 +207,7 @@ const telescopeBodyPoint = ( rootX + Math.cos(DEFAULT_TELESCOPE_HEADING) * horizontalOffset, rootY + Math.sin(DEFAULT_TELESCOPE_HEADING) * horizontalOffset, rootZ + telescopeBodyZ(config) - - Math.sin(DEFAULT_TELESCOPE_TILT) * localX, + - Math.sin(DEFAULT_TELESCOPE_TILT) * localX, ]; }; diff --git a/frontend/three_d_garden/bot/__tests__/belts_test.tsx b/frontend/three_d_garden/bot/__tests__/belts_test.tsx index d9476e3796..2f3461fb80 100644 --- a/frontend/three_d_garden/bot/__tests__/belts_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/belts_test.tsx @@ -153,7 +153,7 @@ describe("belts", () => { (callback: Parameters[0]) => { frameCallbacks.push(callback); return undefined; - }); + }); const positionRef = { current: { x: -50, y: 0, z: 0 }, }; diff --git a/frontend/three_d_garden/garden/__tests__/sun_test.tsx b/frontend/three_d_garden/garden/__tests__/sun_test.tsx index 9001e6533b..1fb5c9dba1 100644 --- a/frontend/three_d_garden/garden/__tests__/sun_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/sun_test.tsx @@ -17,7 +17,7 @@ import * as SunCalc from "suncalc"; import { Constellations, generateStars, projectConstellationPoint, starShaderModification, -} from "../constellations"; +} from "../../stargazing/constellations"; import { INITIAL } from "../../config"; import { clone } from "lodash"; import { @@ -29,7 +29,7 @@ import { unmountRenderer, } from "../../../__test_support__/test_renderer"; import { SECTION_CLIPPING_EXEMPT } from "../../section"; -import { CropConstellationCatalog } from "../constellation_data"; +import { CropConstellationCatalog } from "../../stargazing/constellation_data"; import { fakeSceneObject } from "../../../__test_support__/fake_state/resources"; diff --git a/frontend/three_d_garden/garden/index.ts b/frontend/three_d_garden/garden/index.ts index 58db2c351b..b73b5cba2f 100644 --- a/frontend/three_d_garden/garden/index.ts +++ b/frontend/three_d_garden/garden/index.ts @@ -1,5 +1,5 @@ export * from "./clouds"; -export * from "./constellations"; +export * from "../stargazing/constellations"; export * from "./grid"; export * from "./ground"; export * from "./plants"; diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx index 0a69794f1c..444f8b665c 100644 --- a/frontend/three_d_garden/garden/sun.tsx +++ b/frontend/three_d_garden/garden/sun.tsx @@ -23,7 +23,7 @@ import { ASSETS, BigDistance } from "../constants"; import { SECTION_CLIPPING_EXEMPT } from "../section"; import { Constellations, ConstellationsHandle, -} from "./constellations"; +} from "../stargazing/constellations"; import { polarToCartesian } from "./celestial_coordinates"; import { TaggedSceneObject } from "farmbot"; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 72e6efdb16..090bddb971 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -177,10 +177,8 @@ import { } from "./view_prism"; import { t } from "../i18next_wrapper"; import { soilHeightPoint } from "../points/soil_height"; -import { STARGAZING_DEFAULT_FOV } from - "../farm_designer/stargazing_constants"; -import { markConstellationFound } from - "../farm_designer/stargazing_progress"; +import { STARGAZING_DEFAULT_FOV } from "./stargazing/stargazing_constants"; +import { markConstellationFound } from "./stargazing/stargazing_progress"; import { ControlCursorProvider } from "./controls"; import { CameraFollowController } from "./camera_follow"; import { UtmFollowController } from "./utm_follow"; diff --git a/frontend/three_d_garden/section_controls.tsx b/frontend/three_d_garden/section_controls.tsx index f155865ca0..2713b397b4 100644 --- a/frontend/three_d_garden/section_controls.tsx +++ b/frontend/three_d_garden/section_controls.tsx @@ -56,19 +56,21 @@ const pointForAxis = ( axisPosition: number, transversePosition: number, z: number, -): Point => axis == "x" - ? [axisPosition, transversePosition, z] - : [transversePosition, axisPosition, z]; +): Point => + axis == "x" + ? [axisPosition, transversePosition, z] + : [transversePosition, axisPosition, z]; const guideLine = ( axis: ThreeDSectionAxis, position: number, extent: number, z: number, -): [Point, Point] => [ - pointForAxis(axis, position, -extent, z), - pointForAxis(axis, position, extent, z), -]; +): [Point, Point] => + [ + pointForAxis(axis, position, -extent, z), + pointForAxis(axis, position, extent, z), + ]; export interface SectionControlLayoutProps { config: Config; @@ -125,10 +127,11 @@ export const getSectionControlLayout = ( const atBothSides = ( axisPosition: number, extent: number, - ): [Point, Point] => [ - pointForAxis(axis, axisPosition, -extent, z), - pointForAxis(axis, axisPosition, extent, z), - ]; + ): [Point, Point] => + [ + pointForAxis(axis, axisPosition, -extent, z), + pointForAxis(axis, axisPosition, extent, z), + ]; const centerHandles = atBothSides(centerPosition, centerExtent); const transverseIndex = axis == "x" ? 1 : 0; const axisTogglePositions = centerHandles.map((handle, index) => { @@ -168,10 +171,11 @@ export const sectionCameraDirection = ( nearPlane: Plane, farPlane: Plane, axis: ThreeDSectionAxis, -): 1 | -1 => getSectionNearPosition(nearPlane, axis) - >= getSectionNearPosition(farPlane, axis) - ? 1 - : -1; +): 1 | -1 => + getSectionNearPosition(nearPlane, axis) + >= getSectionNearPosition(farPlane, axis) + ? 1 + : -1; export interface SectionControlsProps { config: Config; diff --git a/frontend/three_d_garden/garden/__tests__/constellation_data_test.ts b/frontend/three_d_garden/stargazing/__tests__/constellation_data_test.ts similarity index 100% rename from frontend/three_d_garden/garden/__tests__/constellation_data_test.ts rename to frontend/three_d_garden/stargazing/__tests__/constellation_data_test.ts diff --git a/frontend/three_d_garden/garden/__tests__/constellations_test.tsx b/frontend/three_d_garden/stargazing/__tests__/constellations_test.tsx similarity index 100% rename from frontend/three_d_garden/garden/__tests__/constellations_test.tsx rename to frontend/three_d_garden/stargazing/__tests__/constellations_test.tsx diff --git a/frontend/farm_designer/__tests__/stargazing_progress_test.ts b/frontend/three_d_garden/stargazing/__tests__/stargazing_progress_test.ts similarity index 98% rename from frontend/farm_designer/__tests__/stargazing_progress_test.ts rename to frontend/three_d_garden/stargazing/__tests__/stargazing_progress_test.ts index 43e29996f4..f053691c25 100644 --- a/frontend/farm_designer/__tests__/stargazing_progress_test.ts +++ b/frontend/three_d_garden/stargazing/__tests__/stargazing_progress_test.ts @@ -7,7 +7,7 @@ import { } from "../stargazing_progress"; import { STARGAZING_PROGRESS_STORAGE_KEY } from "../stargazing_progress_key"; -import { CROP_SLUGS } from "../../crops/metadata"; +import { CROP_SLUGS } from "../../../crops/metadata"; describe("stargazing progress", () => { it("reads unique valid crop slugs in discovery order", () => { diff --git a/frontend/farm_designer/__tests__/stargazing_test.tsx b/frontend/three_d_garden/stargazing/__tests__/stargazing_test.tsx similarity index 99% rename from frontend/farm_designer/__tests__/stargazing_test.tsx rename to frontend/three_d_garden/stargazing/__tests__/stargazing_test.tsx index cb43cadce4..eb5cd963f9 100644 --- a/frontend/farm_designer/__tests__/stargazing_test.tsx +++ b/frontend/three_d_garden/stargazing/__tests__/stargazing_test.tsx @@ -6,8 +6,8 @@ import { setSpaceflightMode, setStargazingFov, setStargazingMode, StargazingControls, } from "../stargazing"; -import { Actions } from "../../constants"; -import { CROP_SLUGS } from "../../crops/metadata"; +import { Actions } from "../../../constants"; +import { CROP_SLUGS } from "../../../crops/metadata"; import { STARGAZING_PROGRESS_STORAGE_KEY } from "../stargazing_progress_key"; diff --git a/frontend/three_d_garden/garden/constellation_data.ts b/frontend/three_d_garden/stargazing/constellation_data.ts similarity index 100% rename from frontend/three_d_garden/garden/constellation_data.ts rename to frontend/three_d_garden/stargazing/constellation_data.ts diff --git a/frontend/three_d_garden/garden/constellations.tsx b/frontend/three_d_garden/stargazing/constellations.tsx similarity index 98% rename from frontend/three_d_garden/garden/constellations.tsx rename to frontend/three_d_garden/stargazing/constellations.tsx index 6f64196120..569f68ed15 100644 --- a/frontend/three_d_garden/garden/constellations.tsx +++ b/frontend/three_d_garden/stargazing/constellations.tsx @@ -18,8 +18,8 @@ import { } from "./constellation_data"; import { getPlantIconTextureTransform, getPlantIconTextureUrl, -} from "./plant_icon_atlas"; -import { polarToCartesian, toRad } from "./celestial_coordinates"; +} from "../garden/plant_icon_atlas"; +import { polarToCartesian, toRad } from "../garden/celestial_coordinates"; import { ErrorBoundary } from "../../error_boundary"; export interface StarData { @@ -635,8 +635,7 @@ export const advanceConstellationAnimation = ( export const cameraSideShaderModification = ( shader: WebGLProgramParametersWithUniforms, - cameraSideClipUniform: CameraSideClipUniform = - defaultCameraSideClipUniform, + cameraSideClipUniform: CameraSideClipUniform = defaultCameraSideClipUniform, ) => { shader.uniforms.cameraSideClipEnabled = cameraSideClipUniform; shader.vertexShader = shader.vertexShader @@ -664,10 +663,8 @@ export const cameraSideShaderModification = ( export const constellationLineShaderModification = ( shader: WebGLProgramParametersWithUniforms, - cameraSideClipUniform: CameraSideClipUniform = - defaultCameraSideClipUniform, - constellationDebugUniform: ConstellationDebugUniform = - defaultConstellationDebugUniform, + cameraSideClipUniform: CameraSideClipUniform = defaultCameraSideClipUniform, + constellationDebugUniform: ConstellationDebugUniform = defaultConstellationDebugUniform, ) => { shader.uniforms.constellationTime = constellationTimeUniform; shader.uniforms.cameraSideClipEnabled = cameraSideClipUniform; @@ -754,10 +751,8 @@ export const constellationLineShaderModification = ( export const constellationImageShaderModification = ( shader: WebGLProgramParametersWithUniforms, - cameraSideClipUniform: CameraSideClipUniform = - defaultCameraSideClipUniform, - constellationDebugUniform: ConstellationDebugUniform = - defaultConstellationDebugUniform, + cameraSideClipUniform: CameraSideClipUniform = defaultCameraSideClipUniform, + constellationDebugUniform: ConstellationDebugUniform = defaultConstellationDebugUniform, ) => { shader.uniforms.constellationTime = constellationTimeUniform; shader.uniforms.cameraSideClipEnabled = cameraSideClipUniform; @@ -836,8 +831,7 @@ export const constellationImageShaderModification = ( export const starShaderModification = ( shader: WebGLProgramParametersWithUniforms, - cameraSideClipUniform: CameraSideClipUniform = - defaultCameraSideClipUniform, + cameraSideClipUniform: CameraSideClipUniform = defaultCameraSideClipUniform, ) => { cameraSideShaderModification(shader, cameraSideClipUniform); shader.vertexShader = shader.vertexShader.replace( diff --git a/frontend/three_d_garden/garden/generated_constellations.bin b/frontend/three_d_garden/stargazing/generated_constellations.bin similarity index 100% rename from frontend/three_d_garden/garden/generated_constellations.bin rename to frontend/three_d_garden/stargazing/generated_constellations.bin diff --git a/frontend/farm_designer/stargazing.tsx b/frontend/three_d_garden/stargazing/stargazing.tsx similarity index 93% rename from frontend/farm_designer/stargazing.tsx rename to frontend/three_d_garden/stargazing/stargazing.tsx index 01faa3b99d..49f9b6df9d 100644 --- a/frontend/farm_designer/stargazing.tsx +++ b/frontend/three_d_garden/stargazing/stargazing.tsx @@ -1,8 +1,8 @@ import React from "react"; -import { Actions } from "../constants"; -import { findCropIcon, findCropMetadata } from "../crops/metadata"; -import { t } from "../i18next_wrapper"; -import { ThreeDViewMode } from "./interfaces"; +import { Actions } from "../../constants"; +import { findCropIcon, findCropMetadata } from "../../crops/metadata"; +import { t } from "../../i18next_wrapper"; +import { ThreeDViewMode } from "../../farm_designer/interfaces"; import { clampStargazingFov, STARGAZING_MAX_FOV, STARGAZING_MIN_FOV, } from "./stargazing_constants"; @@ -296,17 +296,21 @@ export const StargazingControls = (props: StargazingControlsProps) => { disabled={spaceflight || zoomUnlockedFraction == 0} style={sliderStyle} tabIndex={active ? 0 : -1} - title={`${t("Field of view")}: ${displayedFov}°. ${ - t("Maximum unlocked field of view")}: ${maxFov}°`} + title={[ + `${t("Field of view")}: ${displayedFov}°.`, + `${t("Maximum unlocked field of view")}: ${maxFov}°`, + ].join(" ")} onChange={setFov} /> {zoomUnlockedFraction < 1 && - - - } + + + } {t("Zoom")} diff --git a/frontend/farm_designer/stargazing_constants.ts b/frontend/three_d_garden/stargazing/stargazing_constants.ts similarity index 100% rename from frontend/farm_designer/stargazing_constants.ts rename to frontend/three_d_garden/stargazing/stargazing_constants.ts diff --git a/frontend/farm_designer/stargazing_progress.ts b/frontend/three_d_garden/stargazing/stargazing_progress.ts similarity index 98% rename from frontend/farm_designer/stargazing_progress.ts rename to frontend/three_d_garden/stargazing/stargazing_progress.ts index b42da47293..b407980691 100644 --- a/frontend/farm_designer/stargazing_progress.ts +++ b/frontend/three_d_garden/stargazing/stargazing_progress.ts @@ -1,5 +1,5 @@ import React from "react"; -import { CROP_SLUGS } from "../crops/metadata"; +import { CROP_SLUGS } from "../../crops/metadata"; import { STARGAZING_MAX_FOV, STARGAZING_MIN_FOV, } from "./stargazing_constants"; diff --git a/frontend/farm_designer/stargazing_progress_key.ts b/frontend/three_d_garden/stargazing/stargazing_progress_key.ts similarity index 100% rename from frontend/farm_designer/stargazing_progress_key.ts rename to frontend/three_d_garden/stargazing/stargazing_progress_key.ts diff --git a/package.json b/package.json index 5ec428fa18..c12111346b 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "coverage-html": "genhtml -q coverage_fe/lcov.info --output-directory coverage_fe", "ruby-check": "bundle exec rubocop -c .rubocop.yml && bundle exec brakeman . --skip-files /docker_volumes/", "ruby-autocorrect": "bundle exec rubocop -c .rubocop.yml -A", - "graph-modules-dot": "bunx madge --dot ./frontend > module_graph.dot", - "graph-modules-svg": "dot -Tsvg module_graph.dot -o module_graph.svg", "typecheck": "bun scripts/run.js bun node_modules/@typescript/native/bin/tsc --project tsconfig.tests.json --noEmit --pretty", "dev-typecheck": "bun scripts/run.js bun node_modules/@typescript/native/bin/tsc --project tsconfig.dev.json --noEmit --pretty", "eslint": "TSESTREE_SINGLE_RUN=true bun scripts/run.js bunx eslint --no-cache frontend public/app-resources/languages", diff --git a/scripts/generate_constellation.ts b/scripts/generate_constellation.ts index 24ebf3f83c..698ddef14b 100644 --- a/scripts/generate_constellation.ts +++ b/scripts/generate_constellation.ts @@ -25,7 +25,7 @@ export interface BoundaryEdge { const ROOT = process.cwd(); const OUTPUT_PATH = resolve( ROOT, - "frontend/three_d_garden/garden/generated_constellations.bin", + "frontend/three_d_garden/stargazing/generated_constellations.bin", ); const CATALOG_CROPS = Object.entries(CROPS).map(([cropSlug, crop]) => ({ cropSlug, diff --git a/scripts/generate_constellation_test.ts b/scripts/generate_constellation_test.ts index dd5a48f261..23547996c8 100644 --- a/scripts/generate_constellation_test.ts +++ b/scripts/generate_constellation_test.ts @@ -1,7 +1,7 @@ import { CROPS } from "../frontend/crops/constants"; import { decodeCropConstellationCatalog, -} from "../frontend/three_d_garden/garden/constellation_data"; +} from "../frontend/three_d_garden/stargazing/constellation_data"; import { distanceToSegment, encodeConstellations, @@ -145,7 +145,7 @@ describe("generated_constellations.bin", () => { it("contains one valid contour for every catalog crop", async () => { const file = Bun.file( - "frontend/three_d_garden/garden/generated_constellations.bin", + "frontend/three_d_garden/stargazing/generated_constellations.bin", ); const catalog = decodeCropConstellationCatalog(await file.arrayBuffer()); expect(catalog.constellations.map(constellation => constellation.cropSlug)) diff --git a/scripts/graphs/color_by_directory.ts b/scripts/graphs/color_by_directory.ts new file mode 100644 index 0000000000..fc75500e5c --- /dev/null +++ b/scripts/graphs/color_by_directory.ts @@ -0,0 +1,142 @@ +type DependencyGraph = Record; + +const quote = (value: string): string => JSON.stringify(value); + +export const topLevelDirectoryOf = ( + filePath: string, + target: string, +): string => { + const segments = target + .replaceAll("\\", "/") + .split("/") + .filter(Boolean); + let outsideFrontend = false; + filePath.replaceAll("\\", "/").split("/").forEach(segment => { + if (segment === "..") { + if (segments.length) { + segments.pop(); + } else { + outsideFrontend = true; + } + } else if (segment && segment !== ".") { + segments.push(segment); + } + }); + return outsideFrontend + ? "(outside frontend)" + : segments[0] || "(frontend root)"; +}; + +const hash = (value: string): number => { + let result = 2166136261; + for (const character of value) { + result ^= character.codePointAt(0) || 0; + result = Math.imul(result, 16777619); + } + return result >>> 0; +}; + +export const directoryColor = (directory: string): string => { + const hue = (hash(directory) % 360) / 360; + return `${hue.toFixed(3)} 0.35 0.95`; +}; + +const resolveHighlightedFile = ( + nodes: Set, + query: string | undefined, + target: string, +): string | undefined => { + if (!query) { + return undefined; + } + const normalized = query.replaceAll("\\", "/").replace(/^\.\/+/, ""); + const targetMarker = `frontend/${target}/`; + const markerIndex = normalized.lastIndexOf(targetMarker); + const graphPath = markerIndex < 0 + ? normalized + : normalized.slice(markerIndex + targetMarker.length); + if (nodes.has(graphPath)) { + return graphPath; + } + const matches = [...nodes].filter(node => node.endsWith(`/${graphPath}`)); + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1) { + throw new Error( + `Ambiguous highlighted file "${query}": ${matches.join(", ")}`, + ); + } + throw new Error(`Highlighted file not found in graph: ${query}`); +}; + +export const toDot = ( + graph: DependencyGraph, + target: string, + highlightQuery?: string, +): string => { + const nodes = new Set(); + const targetDirectory = topLevelDirectoryOf(".", target); + Object.entries(graph).forEach(([filePath, dependencies]) => { + nodes.add(filePath); + dependencies.forEach(dependency => nodes.add(dependency)); + }); + const highlightedFile = resolveHighlightedFile( + nodes, + highlightQuery, + target, + ); + + const lines = [ + "digraph G {", + " graph [bgcolor=\"#111827\", outputorder=\"edgesfirst\",", + " overlap=\"prism\", K=\"1.4\", repulsiveforce=\"2.0\",", + " sep=\"+20\", esep=\"+8\"];", + " node [shape=\"box\", style=\"filled,rounded\", color=\"#374151\",", + " fontname=\"Arial\", fontsize=\"10\", fontcolor=\"#111827\"];", + " edge [color=\"#6b7280\", arrowsize=\"0.6\"];", + ]; + + [...nodes].sort().forEach(filePath => { + const directory = topLevelDirectoryOf(filePath, target); + lines.push( + ` ${quote(filePath)} [fillcolor=${quote(directoryColor(directory))},` + + ` tooltip=${quote(directory)}];`, + ); + }); + + Object.keys(graph).sort().forEach(filePath => { + [...graph[filePath]].sort().forEach(dependency => { + const sourceDirectory = topLevelDirectoryOf(filePath, target); + const dependencyDirectory = topLevelDirectoryOf(dependency, target); + const crossesTargetBoundary = + (sourceDirectory === targetDirectory) !== + (dependencyDirectory === targetDirectory); + const connectsToHighlightedFile = + filePath === highlightedFile || dependency === highlightedFile; + const shouldHighlight = highlightedFile + ? connectsToHighlightedFile + : crossesTargetBoundary; + const attributes = shouldHighlight + ? " [color=\"#f59e0b\", penwidth=\"2.5\", arrowsize=\"0.9\"]" + : ""; + lines.push( + ` ${quote(filePath)} -> ${quote(dependency)}${attributes};`, + ); + }); + }); + + lines.push("}"); + return `${lines.join("\n")}\n`; +}; + +if (import.meta.main) { + const [input, output, target, highlightFile] = Bun.argv.slice(2); + if (!input || !output || !target) { + throw new Error( + "Usage: color_by_directory.ts INPUT.json OUTPUT.dot TARGET", + ); + } + const graph = JSON.parse(await Bun.file(input).text()) as DependencyGraph; + await Bun.write(output, toDot(graph, target, highlightFile)); +} diff --git a/scripts/graphs/create.sh b/scripts/graphs/create.sh new file mode 100644 index 0000000000..17bb101204 --- /dev/null +++ b/scripts/graphs/create.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +target="${1:-three_d_garden}" +source_dir="$project_root/frontend/$target" +output="$project_root/scripts/graphs/$target.svg" +highlight_file="${2:-}" + +if [[ "$highlight_file" == *.svg ]]; then + output="$highlight_file" + highlight_file="${3:-}" +fi + +dot_output="${output%.*}.dot" + +if [[ ! -d "$source_dir" ]]; then + echo "Frontend directory not found: $source_dir" >&2 + exit 1 +fi + +cd "$project_root" + +graph_json="$(mktemp)" +trap 'rm -f "$graph_json"' EXIT + +"$project_root/node_modules/.bin/madge" \ + --json \ + "frontend/$target" > "$graph_json" + +bun "$project_root/scripts/graphs/color_by_directory.ts" \ + "$graph_json" \ + "$dot_output" \ + "$target" \ + "$highlight_file" + +dot -Ksfdp -Tsvg "$dot_output" -o "$output" + +echo "Created: $output" +echo "DOT source: $dot_output" From a83d7cd8022b1ca7b4d77ed28610890791b5883d Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 18 Aug 2026 18:16:47 -0700 Subject: [PATCH 03/15] add buttons to box popup --- .../css/farm_designer/three_d_garden.scss | 19 +++++++++++++++++++ .../farm_designer/__tests__/index_test.tsx | 1 + .../__tests__/state_to_props_test.ts | 1 + .../__tests__/three_d_garden_map_test.tsx | 6 +++++- frontend/farm_designer/index.tsx | 1 + frontend/farm_designer/interfaces.ts | 1 + frontend/farm_designer/state_to_props.ts | 1 + frontend/farm_designer/three_d_garden_map.tsx | 4 +++- .../pin_bindings/__tests__/model_test.tsx | 9 +++++++++ frontend/settings/pin_bindings/interfaces.ts | 1 + frontend/settings/pin_bindings/model.tsx | 5 +++-- .../three_d_garden/__tests__/index_test.tsx | 3 +++ frontend/three_d_garden/garden_model.tsx | 5 ++++- frontend/three_d_garden/index.tsx | 4 +++- .../selection/__tests__/selection_test.tsx | 19 ++++++++++++++++++- .../selection/popup_controls.tsx | 13 +++++++++++++ frontend/three_d_garden/selection/props.ts | 5 ++++- 17 files changed, 90 insertions(+), 8 deletions(-) diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index 4a085c07ee..3bcb4f5b95 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -566,10 +566,29 @@ } .object-popup-electronics-controls { + min-width: 0; + .row { align-items: center; } + .electronics-box-top, + .electronics-box-3d-model, + .box-top-2d-wrapper { + width: 100%; + min-width: 0; + max-width: 100%; + } + + .electronics-box-3d-model { + overflow: hidden; + + canvas { + width: 100% !important; + height: 14rem !important; + } + } + button:not(.bp6-button) { margin: 0; float: none; diff --git a/frontend/farm_designer/__tests__/index_test.tsx b/frontend/farm_designer/__tests__/index_test.tsx index 2762e4708f..a67a798f43 100644 --- a/frontend/farm_designer/__tests__/index_test.tsx +++ b/frontend/farm_designer/__tests__/index_test.tsx @@ -68,6 +68,7 @@ describe("", () => { const fakeProps = (): FarmDesignerProps => ({ dispatch: jest.fn(), + resources: buildResourceIndex().index, device: fakeDevice().body, selectedPlant: undefined, designer: fakeDesignerState(), diff --git a/frontend/farm_designer/__tests__/state_to_props_test.ts b/frontend/farm_designer/__tests__/state_to_props_test.ts index 6ce20e1242..f6f24987de 100644 --- a/frontend/farm_designer/__tests__/state_to_props_test.ts +++ b/frontend/farm_designer/__tests__/state_to_props_test.ts @@ -30,6 +30,7 @@ describe("mapStateToProps()", () => { plantUUID: "x" }; expect(mapStateToProps(state).hoveredPlant).toBeFalsy(); + expect(mapStateToProps(state).resources).toBe(state.resources.index); }); it("reuses resource array props across bot-only updates", () => { 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 076028c96d..2bb01ba6c3 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -16,7 +16,9 @@ import { } from "../../three_d_garden/config"; import { FirmwareHardware } from "farmbot"; import { CROPS } from "../../crops/constants"; -import { fakeDevice } from "../../__test_support__/resource_index_builder"; +import { + buildResourceIndex, fakeDevice, +} from "../../__test_support__/resource_index_builder"; import { fakeCameraCalibrationData } from "../../__test_support__/fake_camera_data"; import * as threeDGarden from "../../three_d_garden"; import * as SunCalc from "suncalc"; @@ -96,6 +98,7 @@ describe("", () => { const fakeProps = (): ThreeDGardenMapProps => ({ gardenSize: fakeMapTransformProps().gridSize, + resources: buildResourceIndex().index, device: fakeDevice().body, firmwareHardware: 0, firmwareSettings: fakeBot.hardware.mcu_params, @@ -201,6 +204,7 @@ describe("", () => { const call = lastThreeDGardenProps(); expect(call).toEqual(expect.objectContaining({ config: expectedConfig, + resources: p.resources, configPosition: { x: 2999, y: 1498, z: 3 }, panelCameraStore: expect.objectContaining({ getSnapshot: expect.any(Function), diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index 5c24a5d889..3ebc4f129f 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -235,6 +235,7 @@ export class RawFarmDesigner {threeDGarden ? { return ", () => { } }); + it("preserves scale in a short viewport", () => { + const p = fakeProps(); + p.shortViewport = true; + const wrapper = createRenderer(); + const camera = wrapper.root.findByProps({ name: "camera" }); + expect(camera.props.fov).toEqual(20); + expect(camera.props.position).toEqual([-130, 0, 300]); + }); + it("triggers binding", () => { const e = fakeEvent(); const p = fakeProps(); diff --git a/frontend/settings/pin_bindings/interfaces.ts b/frontend/settings/pin_bindings/interfaces.ts index d7c9744380..fa7f017269 100644 --- a/frontend/settings/pin_bindings/interfaces.ts +++ b/frontend/settings/pin_bindings/interfaces.ts @@ -50,6 +50,7 @@ export interface PinBindingInputGroupState { } export interface BoxTopBaseProps { + shortViewport?: boolean; isEditing: boolean; dispatch: Function; resources: ResourceIndex; diff --git a/frontend/settings/pin_bindings/model.tsx b/frontend/settings/pin_bindings/model.tsx index 0fcc1e1a59..242c1f4234 100644 --- a/frontend/settings/pin_bindings/model.tsx +++ b/frontend/settings/pin_bindings/model.tsx @@ -262,8 +262,9 @@ export const Model = (props: BoxTopBaseProps) => { }; return - diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index 30be87a813..57985cd754 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -12,6 +12,8 @@ import { createPanelCameraStore } from "../panel_camera"; import { filterSectionIntersections } from "../section"; import { Actions } from "../../constants"; import { bot } from "../../__test_support__/fake_state/bot"; +import { buildResourceIndex } from + "../../__test_support__/resource_index_builder"; const useThreeImplementation = (reactThreeFiber.useThree as jest.Mock).getMockImplementation(); @@ -33,6 +35,7 @@ afterEach(() => { describe("", () => { const fakeProps = (): ThreeDGardenProps => ({ config: { ...clone(INITIAL), viewCube: true }, + resources: buildResourceIndex().index, configPosition: clone(INITIAL_POSITION), firmwareSettings: bot.hardware.mcu_params, panelCameraStore: createPanelCameraStore(true), diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 090bddb971..131113f649 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -62,7 +62,7 @@ import { BooleanSetting } from "../session_keys"; import { PeripheralValues } from "../farm_designer/map/layers/farmbot/bot_trail"; import { Actions, Content } from "../constants"; -import { SlotWithTool } from "../resources/interfaces"; +import { ResourceIndex, SlotWithTool } from "../resources/interfaces"; import { applyCameraClippingRange, applyCameraViewOffset, cameraInit, cameraPositionForFov, CameraViewOffset, CameraViewport, canonicalCamera, @@ -810,6 +810,7 @@ const SceneBoundary = (props: SceneBoundaryProps) => { export interface GardenModelProps { config: Config; + resources?: ResourceIndex; configPosition: PositionConfig; activeFocus: string; setActiveFocus(focus: string): void; @@ -4370,6 +4371,8 @@ const GardenModelSceneBase = (props: GardenModelSceneProps) => { visible={farmbotVisible} /> ({ export interface ThreeDGardenProps { config: Config; + resources: ResourceIndex; configPosition: PositionConfig; panelCameraStore: PanelCameraStore; threeDPlants: ThreeDGardenPlant[]; @@ -135,6 +136,7 @@ export const ThreeDGarden = React.memo((props: ThreeDGardenProps) => { props.addPlantProps.designer.highlighted3DObject}> ({ config: clone(INITIAL), @@ -1222,8 +1225,13 @@ describe("selection popup controls", () => { }); it("renders electronics controls and boot sequence selector states", () => { + const boxTopSpy = jest.spyOn(boxTop, "BoxTop") + .mockImplementation(() =>
); const p = layerProps(); p.fbosConfig = fakeFbosConfig(); + p.bot = fakeBot; + p.resources = buildResourceIndex().index; + p.getConfigValue = jest.fn(() => true); p.sequences = [fakeSequence({ id: 7, name: "Boot" })]; let controls = render( { ...objectBase({ kind: "electronics", id: 0 }), }} />); expect(controls.container).toContainHTML("BOOT SEQUENCE"); + expect(controls.container.querySelector(".mock-box-top")).toBeTruthy(); + expect(boxTopSpy.mock.calls[0][0]).toEqual(expect.objectContaining({ + shortViewport: true, + threeDimensions: true, + isEditing: false, + botOnline: true, + })); controls.unmount(); p.dispatch = undefined; @@ -1242,7 +1257,9 @@ describe("selection popup controls", () => { ...objectBase({ kind: "electronics", id: 0 }), }} />); expect(controls.container).toContainHTML("Unavailable"); + expect(controls.container.querySelector(".mock-box-top")).toBeFalsy(); controls.unmount(); + boxTopSpy.mockRestore(); }); it("updates boot sequence selection", () => { diff --git a/frontend/three_d_garden/selection/popup_controls.tsx b/frontend/three_d_garden/selection/popup_controls.tsx index 1b60232e4e..f0ffbfeca2 100644 --- a/frontend/three_d_garden/selection/popup_controls.tsx +++ b/frontend/three_d_garden/selection/popup_controls.tsx @@ -58,6 +58,7 @@ import { toggleSceneObjectVisibility } from "../../scene_objects/actions"; import { BotConfigInputBox } from "../../settings/fbos_settings/bot_config_input_box"; import { sourceFbosConfigValue } from "../../settings/source_config_value"; +import { BoxTop } from "../../settings/pin_bindings/box_top"; interface PopupControlProps extends ThreeDObjectSelectionLayerProps { object: ResolvedThreeDObject; @@ -491,6 +492,7 @@ const PopupBootSequenceSelector = (props: PopupBootSequenceSelectorProps) => { const ElectronicsPopupControls = (props: PopupControlProps) => { if (props.object.kind != "electronics") { return undefined; } + const firmwareHardware = getFwHardwareValue(props.fbosConfig); return
{ dispatch={props.dispatch} fbosConfig={props.fbosConfig} sequences={props.sequences} /> + {props.dispatch && props.resources && props.bot && + }
; }; diff --git a/frontend/three_d_garden/selection/props.ts b/frontend/three_d_garden/selection/props.ts index 75908689e1..d9b0d815fd 100644 --- a/frontend/three_d_garden/selection/props.ts +++ b/frontend/three_d_garden/selection/props.ts @@ -8,7 +8,8 @@ import { ThreeDLocationSelection, ThreeDObjectSelection, } from "../selection_types"; import { TaggedPlant } from "../../farm_designer/map/interfaces"; -import { SlotWithTool } from "../../resources/interfaces"; +import { ResourceIndex, SlotWithTool } from "../../resources/interfaces"; +import { GetWebAppConfigValue } from "../../config_storage/actions"; import { BotPosition, BotState, UserEnv } from "../../devices/interfaces"; import { MovementState, TimeSettings } from "../../interfaces"; import { PeripheralValues } from @@ -17,6 +18,8 @@ import type { PanelCameraStore } from "../panel_camera"; export interface ThreeDObjectSelectionLayerProps { config: Config; + resources?: ResourceIndex; + getConfigValue?: GetWebAppConfigValue; configPosition: PositionConfig; selection: ThreeDObjectSelection | undefined; panelSelection?: ThreeDObjectSelection; From f090674665147b552e980541222f4a40a11670e9 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 18 Aug 2026 18:30:22 -0700 Subject: [PATCH 04/15] add beam popup --- .../css/farm_designer/three_d_garden.scss | 1 + .../three_d_garden/bot/assemblies/gantry.tsx | 2 + .../components/__tests__/gantry_beam_test.tsx | 66 ++++++++++++-- .../bot/components/gantry_beam.tsx | 86 +++++++++++++------ .../selection/__tests__/selection_test.tsx | 68 +++++++++++++++ .../selection/popup_controls.tsx | 49 ++++++++++- frontend/three_d_garden/selection/popups.tsx | 19 ++-- frontend/three_d_garden/selection/resolve.ts | 74 ++++++++++++++-- frontend/three_d_garden/selection/routes.ts | 1 + frontend/three_d_garden/selection_types.ts | 3 +- 10 files changed, 316 insertions(+), 53 deletions(-) diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index 3bcb4f5b95..bd6467fcb1 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -525,6 +525,7 @@ .object-popup-laser-row, .object-popup-camera-row, .object-popup-scene-object-row, + .object-popup-gantry-beam-row, .object-popup-home-row, .object-popup-tool-action-row { align-items: center; diff --git a/frontend/three_d_garden/bot/assemblies/gantry.tsx b/frontend/three_d_garden/bot/assemblies/gantry.tsx index 6f7c7552df..6197ff3949 100644 --- a/frontend/three_d_garden/bot/assemblies/gantry.tsx +++ b/frontend/three_d_garden/bot/assemblies/gantry.tsx @@ -255,6 +255,8 @@ const GantryAssemblyBase = (props: GantryAssemblyProps) => { configPosition={props.configPosition} aluminumTexture={aluminumTexture} beamShape={props.beamShape} + onSelectObject={props.onSelectObject} + onHoverObject={props.onHoverObject} local={true} /> {version.number == "v1.9" && } diff --git a/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx b/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx index bde1a7941d..8d9657ebdd 100644 --- a/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/gantry_beam_test.tsx @@ -41,9 +41,12 @@ import * as threeFiber from "@react-three/fiber"; import { createRenderer, unmountRenderer, } from "../../../../__test_support__/test_renderer"; +import * as mapUtil from "../../../../farm_designer/map/util"; +import { Mode } from "../../../../farm_designer/map/interfaces"; let reactUseRefSpy: jest.SpyInstance; let useFrameSpy: jest.SpyInstance; +let getModeSpy: jest.SpyInstance; describe("", () => { beforeEach(() => { @@ -51,11 +54,13 @@ describe("", () => { reactUseRefSpy = jest.spyOn(React, "useRef") .mockImplementation(() => mockRef); useFrameSpy = jest.spyOn(threeFiber, "useFrame"); + getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); }); afterEach(() => { reactUseRefSpy.mockRestore(); useFrameSpy.mockRestore(); + getModeSpy.mockRestore(); }); const fakeProps = (): GantryBeamProps => ({ @@ -65,10 +70,15 @@ describe("", () => { aluminumTexture: new Texture(), }); + const lightStrip = (container: HTMLElement) => + container.querySelector("[name='gantry-beam-light-strip']"); + it("renders beam", () => { const { container } = render(); expect(container).toContainHTML("beam"); - expect(container).not.toContainHTML("light"); + expect(container.querySelector("[name='gantry-beam-highlight']")) + .toBeTruthy(); + expect(lightStrip(container)).toBeNull(); }); it("renders lights", () => { @@ -76,7 +86,7 @@ describe("", () => { p.config.light = true; const { container } = render(); expect(container).toContainHTML("beam"); - expect(container).toContainHTML("light"); + expect(lightStrip(container)).toBeTruthy(); }); it("renders alternative lights", () => { @@ -85,7 +95,7 @@ describe("", () => { p.config.kitVersion = "v1.8"; const { container } = render(); expect(container).toContainHTML("beam"); - expect(container).toContainHTML("light"); + expect(lightStrip(container)).toBeTruthy(); }); it("updates light targets in render frames", () => { @@ -104,7 +114,7 @@ describe("", () => { p.config.light = true; p.config.kitVersion = "v1.8"; const { container } = render(); - expect(container).toContainHTML("light"); + expect(lightStrip(container)).toBeTruthy(); expect(frameCallbacks).toHaveLength(5); jest.clearAllMocks(); @@ -132,7 +142,7 @@ describe("", () => { p.config.lightsDebug = true; const { container } = render(); expect(container).toContainHTML("beam"); - expect(container).toContainHTML("light"); + expect(lightStrip(container)).toBeTruthy(); }); it("handles missing ref", () => { @@ -141,7 +151,51 @@ describe("", () => { p.config.light = true; const { container } = render(); expect(container).toContainHTML("beam"); - expect(container).toContainHTML("light"); + expect(lightStrip(container)).toBeTruthy(); + }); + + it("selects and hovers the beam", () => { + const p = fakeProps(); + p.onSelectObject = jest.fn(); + p.onHoverObject = jest.fn(); + const wrapper = createRenderer(); + const beam = wrapper.root.find(node => + node.props.name == "gantry-beam"); + const stopPropagation = jest.fn(); + + beam.props.onClick({ delta: 0, stopPropagation }); + beam.props.onPointerOver({ stopPropagation }); + beam.props.onPointerOut({ stopPropagation }); + + expect(p.onSelectObject).toHaveBeenCalledWith({ + kind: "gantryBeam", + id: 0, + }); + expect(p.onHoverObject).toHaveBeenNthCalledWith(1, true); + expect(p.onHoverObject).toHaveBeenNthCalledWith(2, false); + expect(stopPropagation).toHaveBeenCalledTimes(3); + unmountRenderer(wrapper); + }); + + it("doesn't select the beam after a drag", () => { + const p = fakeProps(); + p.onSelectObject = jest.fn(); + const wrapper = createRenderer(); + wrapper.root.find(node => node.props.name == "gantry-beam") + .props.onClick({ delta: 2 }); + expect(p.onSelectObject).not.toHaveBeenCalled(); + unmountRenderer(wrapper); + }); + + it("doesn't select the beam in camera selection mode", () => { + getModeSpy.mockReturnValue(Mode.cameraSelection); + const p = fakeProps(); + p.onSelectObject = jest.fn(); + const wrapper = createRenderer(); + wrapper.root.find(node => node.props.name == "gantry-beam") + .props.onClick({ delta: 0 }); + expect(p.onSelectObject).not.toHaveBeenCalled(); + unmountRenderer(wrapper); }); }); diff --git a/frontend/three_d_garden/bot/components/gantry_beam.tsx b/frontend/three_d_garden/bot/components/gantry_beam.tsx index d24b446633..930d307233 100644 --- a/frontend/three_d_garden/bot/components/gantry_beam.tsx +++ b/frontend/three_d_garden/bot/components/gantry_beam.tsx @@ -1,5 +1,5 @@ import { Cylinder, Extrude, useHelper } from "@react-three/drei"; -import { useFrame } from "@react-three/fiber"; +import { ThreeEvent, useFrame } from "@react-three/fiber"; import React from "react"; import { get3DPositionNoMirrorFunc } from "../../helpers"; import { Group, MeshPhongMaterial, SpotLight } from "../../components"; @@ -9,6 +9,14 @@ import { } from "three"; import { range } from "lodash"; import { getBotVersion } from "../bot_versions"; +import { + ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, +} from "../../selection_types"; +import { clickWasDragged } from "../../click_event"; +import { HOVER_OBJECT_MODES } from "../../constants"; +import { Mode } from "../../../farm_designer/map/interfaces"; +import { getMode } from "../../../farm_designer/map/util"; +import { Highlight } from "../../elements"; export interface GantryBeamProps { config: Config; @@ -16,11 +24,14 @@ export interface GantryBeamProps { beamShape: Shape | undefined; aluminumTexture: Texture; local?: boolean; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } const gantryBeamPropsEqual = ( prevProps: GantryBeamProps, nextProps: GantryBeamProps, + // eslint-disable-next-line complexity ): boolean => { const prevConfig = prevProps.config; const nextConfig = nextProps.config; @@ -28,6 +39,8 @@ const gantryBeamPropsEqual = ( && prevProps.beamShape == nextProps.beamShape && prevProps.aluminumTexture == nextProps.aluminumTexture && prevProps.local == nextProps.local + && prevProps.onSelectObject == nextProps.onSelectObject + && prevProps.onHoverObject == nextProps.onHoverObject && prevConfig.beamLength == nextConfig.beamLength && prevConfig.columnLength == nextConfig.columnLength && prevConfig.bedYOffset == nextConfig.bedYOffset @@ -52,30 +65,53 @@ const GantryBeamComponent = (props: GantryBeamProps) => { x: x - 39, y: beamLength - version.beamEndOffset, }); - return - - - - {props.config.light && - } - ; + const { onSelectObject, onHoverObject } = props; + const selectBeam = React.useCallback((event: ThreeEvent) => { + if (clickWasDragged(event) + || [...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + return; + } + if (onSelectObject) { + onSelectObject({ kind: "gantryBeam", id: 0 }) !== false + && event.stopPropagation?.(); + } + }, [onSelectObject]); + const hoverBeam = React.useCallback(( + hovered: boolean, + event: ThreeEvent, + ) => { + event.stopPropagation?.(); + onHoverObject?.(hovered); + }, [onHoverObject]); + return + hoverBeam(true, event)} + onPointerOut={event => hoverBeam(false, event)} + position={[ + position.x, + position.y, + columnLength + 40, + ]} + rotation={[Math.PI / 2, 0, 0]}> + + + + {props.config.light && + } + + ; }; export const GantryBeam = React.memo(GantryBeamComponent, diff --git a/frontend/three_d_garden/selection/__tests__/selection_test.tsx b/frontend/three_d_garden/selection/__tests__/selection_test.tsx index 55f3667918..5a9eb9694c 100644 --- a/frontend/three_d_garden/selection/__tests__/selection_test.tsx +++ b/frontend/three_d_garden/selection/__tests__/selection_test.tsx @@ -232,6 +232,12 @@ const bedObject = (): ResolvedThreeDObject => ({ name: "Bed", }); +const gantryBeamObject = (): ResolvedThreeDObject => ({ + ...objectBase({ kind: "gantryBeam", id: 0 }), + kind: "gantryBeam", + name: "Gantry Beam", +}); + const sceneObjectObject = (): Extract< ResolvedThreeDObject, { kind: "sceneObject" } @@ -419,6 +425,8 @@ describe("selection routes", () => { .toEqual(Path.settings("3d_garden")); expect(pathForThreeDSelection({ kind: "safeHeight", id: 0 })) .toEqual(Path.settings("farmbot")); + expect(pathForThreeDSelection({ kind: "gantryBeam", id: 0 })) + .toEqual(Path.settings("3d_garden")); }); }); @@ -546,6 +554,24 @@ describe("selection resolve", () => { expect(objectHasSelectionOverlay(safeHeight)).toBeFalsy(); }); + it("resolves the gantry beam", () => { + const props = resolveProps(); + const gantryBeam = resolveSelectedObject( + props, + { kind: "gantryBeam", id: 0 }, + ); + expect(gantryBeam).toEqual(expect.objectContaining({ + kind: "gantryBeam", + name: "Gantry Beam", + locationCoordinate: { + x: props.configPosition.x, + y: props.config.beamLength / 2, + z: props.config.columnLength, + }, + })); + expect(objectHasSelectionOverlay(gantryBeam)).toBeFalsy(); + }); + it("resolves selected locations and overlay eligibility", () => { const props = resolveProps(); const selection: ThreeDLocationSelection = { @@ -1010,6 +1036,45 @@ describe("selection popup controls", () => { unmountRenderer(wrapper); }); + it("controls gantry beam lighting and length", () => { + const pinToggleSpy = jest.spyOn(deviceActions, "pinToggle") + .mockImplementation(jest.fn()); + const p = layerProps(); + p.set3DConfigValue = jest.fn(); + const lighting = fakePeripheral(); + lighting.body.label = "Lighting"; + lighting.body.pin = 7; + p.peripherals = [lighting]; + p.peripheralValues = [{ label: "Lighting", value: true }]; + const controls = render(); + + expect(controls.getByText("Lighting")).toBeInTheDocument(); + expect(controls.getByText("Beam Length")).toBeInTheDocument(); + const toggle = controls.container.querySelector(".fb-toggle-button"); + expect(toggle).toHaveClass("green"); + toggle && fireEvent.click(toggle); + expect(pinToggleSpy).toHaveBeenCalledWith(7); + const input = controls.getByLabelText("Beam Length"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "1800" } }); + fireEvent.blur(input); + expect(p.set3DConfigValue).toHaveBeenCalledWith("beamLength", "1800"); + controls.unmount(); + pinToggleSpy.mockRestore(); + }); + + it("disables unavailable gantry beam controls", () => { + const controls = render(); + expect(controls.container.querySelector(".fb-toggle-button")) + .toBeDisabled(); + expect(controls.getByLabelText("Beam Length")).toBeDisabled(); + }); + it("updates plant values", () => { const p = layerProps(); const controls = render( { expect(createRenderer().toJSON()).toBeNull(); + expect(createRenderer().toJSON()).toBeNull(); }); it("renders scene object visibility and copy buttons", () => { diff --git a/frontend/three_d_garden/selection/popup_controls.tsx b/frontend/three_d_garden/selection/popup_controls.tsx index f0ffbfeca2..d246fce3ab 100644 --- a/frontend/three_d_garden/selection/popup_controls.tsx +++ b/frontend/three_d_garden/selection/popup_controls.tsx @@ -4,7 +4,7 @@ import { TaggedSceneObject, Vector3, Xyz, } from "farmbot"; import moment from "moment"; -import { isUndefined, noop, round } from "lodash"; +import { isNumber, isUndefined, noop, round } from "lodash"; import { ThreeDObjectSelectionLayerProps } from "./props"; import { ResolvedLocationObject, ResolvedThreeDObject, @@ -18,7 +18,7 @@ import { BooleanSetting } from "../../session_keys"; import { setWebAppConfigValue } from "../../config_storage/actions"; import { destroy, edit, save } from "../../api/crud"; import { - findHome, moveToHome, powerOff, reboot, takePhoto, + findHome, moveToHome, pinToggle, powerOff, reboot, takePhoto, } from "../../devices/actions"; import { resetVirtualTrail } from "../../farm_designer/map/layers/farmbot/bot_trail"; @@ -640,6 +640,46 @@ const SafeHeightPopupControls = (props: PopupControlProps) => {
; }; +const GantryBeamPopupControls = (props: PopupControlProps) => { + if (props.object.kind != "gantryBeam") { return undefined; } + const lighting = props.peripherals.find(peripheral => + peripheral.body.label.toLowerCase().includes("light")); + const pin = lighting?.body.pin; + const value = lighting + ? props.peripheralValues.find(peripheral => + peripheral.label == lighting.body.label)?.value + : undefined; + const disabled = !isNumber(pin) + || !props.botOnline + || props.arduinoBusy + || !!props.bot?.hardware.informational_settings.locked; + return
+
+ + { if (isNumber(pin)) { void pinToggle(pin); } }} + disabled={disabled} + title={t("Toggle Lighting")} + customText={{ textFalse: t("off"), textTrue: t("on") }} /> +
+
+ + props.set3DConfigValue?.( + "beamLength", event.currentTarget.value)} /> +
+
; +}; + export const ObjectPopupControls = (props: PopupControlProps) => { switch (props.object.kind) { case "plant": return ; @@ -653,6 +693,7 @@ export const ObjectPopupControls = (props: PopupControlProps) => { case "sceneObject": return ; case "bed": return ; case "safeHeight": return ; + case "gantryBeam": return ; } }; @@ -705,6 +746,7 @@ type DeletableResolvedThreeDObject = Exclude< ResolvedThreeDObject, { kind: "utm" } | { kind: "electronics" } | { kind: "camera" } | { kind: "connectivity" } | { kind: "bed" } | { kind: "safeHeight" } + | { kind: "gantryBeam" } >; const objectUuid = (object: DeletableResolvedThreeDObject) => { @@ -725,7 +767,8 @@ export const ObjectPopupDeleteButton = (props: PopupControlProps) => { || object.kind == "camera" || object.kind == "connectivity" || object.kind == "bed" - || object.kind == "safeHeight") { + || object.kind == "safeHeight" + || object.kind == "gantryBeam") { return undefined; } return ) as never); + const p = layerProps(); + p.timeSettings = undefined; + const matching = fakeSequence({ id: 10, name: "Water plant" }); + matching.body.args.locals.body = [{ + kind: "parameter_declaration", + args: { + label: "plant_location", + default_value: { + kind: "coordinate", + args: { x: 0, y: 0, z: 0 }, + }, + }, + }]; + const other = fakeSequence({ id: 11, name: "Other sequence" }); + other.body.args.locals.body = [{ + kind: "parameter_declaration", + args: { + label: "duration", + default_value: { kind: "numeric", args: { number: 1 } }, + }, + }]; + const multiple = fakeSequence({ id: 12, name: "Multiple variables" }); + multiple.body.args.locals.body = [ + matching.body.args.locals.body[0], + other.body.args.locals.body[0], + ]; + const noLocals = fakeSequence({ id: 13, name: "No locals" }); + noLocals.body.args.locals.body = undefined; + const noId = fakeSequence({ id: 0, name: "No id" }); + noId.body.id = undefined; + p.sequences = [matching, other, multiple, noLocals, noId]; + const object = plantObject(); + object.plant.body.id = 123; + const controls = render(); + + expect(controls.getByTestId("plant-sequence")) + .toHaveTextContent("Water plant"); + expect(controls.getByTestId("plant-sequence")) + .not.toHaveTextContent("Other sequence"); + expect(controls.getByTestId("plant-sequence")) + .not.toHaveTextContent("Multiple variables"); + fireEvent.click(controls.getByTestId("plant-sequence")); + expect(execSequence).toHaveBeenCalledWith(10, [{ + kind: "parameter_application", + args: { + label: "plant_location", + data_value: { + kind: "point", + args: { pointer_type: "Plant", pointer_id: 123 }, + }, + }, + }]); + controls.unmount(); + object.plant.body.id = undefined; + const noVariable = render(); + expect(noVariable.queryByTestId("plant-sequence")).not.toBeInTheDocument(); + noVariable.unmount(); + fbSelect.mockRestore(); + execSequence.mockRestore(); + }); + + it("runs slot sequences with the tool location", () => { + const execSequence = jest.spyOn(deviceActions, "execSequence") + .mockImplementation(jest.fn()); + const fbSelect = jest.spyOn(ui, "FBSelect") + .mockImplementation(((props: ui.FBSelectProps) => + ) as never); + const p = layerProps(); + const matching = fakeSequence({ id: 20, name: "Pick up tool" }); + matching.body.args.locals.body = [{ + kind: "parameter_declaration", + args: { + label: "tool_location", + default_value: { kind: "location_placeholder", args: {} }, + }, + }]; + const other = fakeSequence({ id: 21, name: "Coordinate sequence" }); + other.body.args.locals.body = [{ + kind: "parameter_declaration", + args: { + label: "location", + default_value: { + kind: "coordinate", + args: { x: 0, y: 0, z: 0 }, + }, + }, + }]; + p.sequences = [matching, other]; + const controls = render(); + + expect(controls.getByTestId("slot-sequence")) + .toHaveTextContent("Pick up tool"); + expect(controls.getByTestId("slot-sequence")) + .not.toHaveTextContent("Coordinate sequence"); + fireEvent.click(controls.getByTestId("slot-sequence")); + expect(execSequence).toHaveBeenCalledWith(20, [{ + kind: "parameter_application", + args: { + label: "tool_location", + data_value: { kind: "tool", args: { tool_id: 4 } }, + }, + }]); + controls.unmount(); + const object = slotObject(); + object.slot.tool = undefined; + const noVariable = render(); + expect(noVariable.queryByTestId("slot-sequence")).not.toBeInTheDocument(); + noVariable.unmount(); + fbSelect.mockRestore(); + execSequence.mockRestore(); + }); + it("updates slot and mounted tool selections", () => { const p = layerProps(); const tool = fakeTool(); @@ -1217,6 +1476,8 @@ describe("selection popup controls", () => { }); it("uses UTM home and mounted tool actions", () => { + const sendRPCSpy = jest.spyOn(deviceActions, "sendRPC") + .mockImplementation(jest.fn()); const moveToHomeSpy = jest.spyOn(deviceActions, "moveToHome") .mockImplementation(jest.fn()); const findHomeSpy = jest.spyOn(deviceActions, "findHome") @@ -1244,8 +1505,13 @@ describe("selection popup controls", () => { expect(controls.container.querySelector( ".object-popup-tool-action-row .fb-toggle-button")) .toHaveTextContent("on"); + fireEvent.click(controls.getByText("Dismount")); fireEvent.click(controls.getByText("MOVE TO HOME")); fireEvent.click(controls.getByText("FIND HOME")); + expect(sendRPCSpy).toHaveBeenCalledWith({ + kind: "lua", + args: { lua: "dismount_tool()" }, + }); expect(moveToHomeSpy).toHaveBeenCalledWith("all"); expect(findHomeSpy).toHaveBeenCalledWith("all"); controls.unmount(); @@ -1253,6 +1519,7 @@ describe("selection popup controls", () => { it("toggles the UTM laser", () => { const p = layerProps(); + p.config.laser = true; p.set3DConfigValue = jest.fn(); const controls = render( { }} />); fireEvent.click(controls.container.querySelector( ".object-popup-laser-row .fb-toggle-button") as Element); + expect(p.set3DConfigValue).toHaveBeenCalledWith("laser", "0"); + p.config.laser = false; + controls.rerender(); + fireEvent.click(controls.container.querySelector( + ".object-popup-laser-row .fb-toggle-button") as Element); expect(p.set3DConfigValue).toHaveBeenCalledWith("laser", "1"); controls.unmount(); }); @@ -1294,6 +1571,8 @@ describe("selection popup controls", () => { .mockImplementation(() =>
); const p = layerProps(); p.fbosConfig = fakeFbosConfig(); + p.fbosConfig.body.firmware_hardware = "farmduino_k15"; + p.fbosConfig.body.boot_sequence_id = 7; p.bot = fakeBot; p.resources = buildResourceIndex().index; p.getConfigValue = jest.fn(() => true); @@ -1314,6 +1593,28 @@ describe("selection popup controls", () => { })); controls.unmount(); + p.getConfigValue = undefined; + controls = render(); + const lastBoxTopCall = boxTopSpy.mock.calls[ + boxTopSpy.mock.calls.length - 1]; + expect(lastBoxTopCall?.[0].threeDimensions).toBeFalsy(); + controls.unmount(); + + p.fbosConfig.body.firmware_hardware = "arduino"; + controls = render(); + expect(controls.container.querySelector(".mock-box-top")).toBeFalsy(); + controls.unmount(); + p.dispatch = undefined; controls = render( { expect(p.dispatch).toHaveBeenCalled(); unmountRenderer(visibility); + object.sceneObject.body.show = false; + const hidden = createRenderer(); + expect(hidden.root.findByType("button").props.className) + .toContain("fa-eye-slash"); + expect(hidden.root.findByType("button").props.title).toEqual("show"); + unmountRenderer(hidden); + const copy = createRenderer(); @@ -1449,5 +1759,13 @@ describe("selection popup controls", () => { expect(wrapper.toJSON()).toBeNull(); unmountRenderer(wrapper); }); + const connectivity = createRenderer(); + expect(connectivity.toJSON()).toBeNull(); + unmountRenderer(connectivity); }); }); diff --git a/frontend/three_d_garden/selection/popup_controls.tsx b/frontend/three_d_garden/selection/popup_controls.tsx index d246fce3ab..8ac822e154 100644 --- a/frontend/three_d_garden/selection/popup_controls.tsx +++ b/frontend/three_d_garden/selection/popup_controls.tsx @@ -1,10 +1,10 @@ import React from "react"; import { - FirmwareHardware, TaggedFbosConfig, TaggedPlantPointer, TaggedSequence, - TaggedSceneObject, Vector3, Xyz, + FirmwareHardware, ParameterDeclaration, TaggedFbosConfig, + TaggedPlantPointer, TaggedSequence, TaggedSceneObject, Vector3, Xyz, } from "farmbot"; import moment from "moment"; -import { isNumber, isUndefined, noop, round } from "lodash"; +import { isNumber, isUndefined, mean, noop, round } from "lodash"; import { ThreeDObjectSelectionLayerProps } from "./props"; import { ResolvedLocationObject, ResolvedThreeDObject, @@ -18,7 +18,8 @@ import { BooleanSetting } from "../../session_keys"; import { setWebAppConfigValue } from "../../config_storage/actions"; import { destroy, edit, save } from "../../api/crud"; import { - findHome, moveToHome, pinToggle, powerOff, reboot, takePhoto, + execSequence, findHome, moveToHome, pinToggle, powerOff, reboot, takePhoto, + sendRPC, } from "../../devices/actions"; import { resetVirtualTrail } from "../../farm_designer/map/layers/farmbot/bot_trail"; @@ -45,7 +46,7 @@ import { } from "../../util"; import { getModifiedClassName } from "../../settings/fbos_settings/default_values"; -import { getFwHardwareValue } from +import { btnIndexList, getFwHardwareValue, hasUTM } from "../../settings/firmware/firmware_hardware_support"; import { cameraBtnProps } from "../../photos/capture_settings/camera_selection"; @@ -59,6 +60,12 @@ import { BotConfigInputBox } from "../../settings/fbos_settings/bot_config_input_box"; import { sourceFbosConfigValue } from "../../settings/source_config_value"; import { BoxTop } from "../../settings/pin_bindings/box_top"; +import { convertDDItoVariable } from + "../../sequences/locals_list/handle_select"; +import { + AllowedVariableNodes, VariableType, +} from "../../sequences/locals_list/locals_list_support"; +import { EditSoilHeight, soilHeightPoint } from "../../points/soil_height"; interface PopupControlProps extends ThreeDObjectSelectionLayerProps { object: ResolvedThreeDObject; @@ -186,6 +193,63 @@ export const PopupSelectedLocationRow = (props: LocationControlProps) => [axis]: round(parseIntInput(value)), })} />; +type ParameterDefaultValueKind = + ParameterDeclaration["args"]["default_value"]["kind"]; + +const sequenceParameter = ( + sequence: TaggedSequence, + defaultValueKinds: ParameterDefaultValueKind[], +) => { + const variables = sequence.body.args.locals.body || []; + const variable = variables[0]; + return variables.length == 1 + && variable?.kind == "parameter_declaration" + && defaultValueKinds.includes(variable.args.default_value.kind) + ? variable + : undefined; +}; + +interface SequenceDropdownProps { + sequences: TaggedSequence[]; + defaultValueKinds: ParameterDefaultValueKind[]; + variable: DropDownItem | undefined; +} + +const SequenceDropdown = (props: SequenceDropdownProps) => { + const { variable } = props; + if (!variable) { return undefined; } + const sequences = betterCompact(props.sequences.map(sequence => { + return sequenceParameter(sequence, props.defaultValueKinds) + && isNumber(sequence.body.id) + ? { label: sequence.body.name, value: sequence.body.id } + : undefined; + })); + return
+ { + const sequence = props.sequences.find(candidate => + candidate.body.id == item.value); + const declaration = sequence && sequenceParameter( + sequence, props.defaultValueKinds); + const bodyVariable = declaration && convertDDItoVariable({ + identifierLabel: declaration.args.label, + allowedVariableNodes: AllowedVariableNodes.variable, + dropdown: variable, + variableType: VariableType.Location, + }); + if (sequence && bodyVariable?.kind == "parameter_application") { + execSequence(sequence.body.id, [bodyVariable]); + } + }} /> +
; +}; + const PlantPopupControls = (props: PopupControlProps) => { if (props.object.kind != "plant" || !props.dispatch) { return undefined; } const { plant } = props.object; @@ -220,6 +284,16 @@ const PlantPopupControls = (props: PopupControlProps) => { {...commonProps} depth={plant.body.depth} />}
+ ; }; @@ -270,6 +344,16 @@ const SlotPopupControls = (props: PopupControlProps) => { selectedTool={slot.tool} isActive={isActive} onChange={update => updateToolSlot(props, slot, update)} /> + ; }; @@ -280,36 +364,54 @@ const UtmPopupControls = (props: PopupControlProps) => { const isActive = (id: number | undefined) => props.toolSlots.some(toolSlot => toolSlot.toolSlot.body.tool_id == id); + const hasTools = hasUTM(getFwHardwareValue(props.fbosConfig)); return <> -
- - { - if (!props.dispatch || !props.deviceAccount) { return; } - props.dispatch(edit(props.deviceAccount, { mounted_tool_id: tool_id })); - props.dispatch(save(props.deviceAccount.uuid)); - }} - noUTM={props.noUTM} - isActive={isActive} - filterSelectedTool={true} - filterActiveTools={false} /> -
- -
- {props.bot && - } -
+ {hasTools && <> +
+ + { + if (!props.dispatch || !props.deviceAccount) { return; } + props.dispatch(edit(props.deviceAccount, { mounted_tool_id: tool_id })); + props.dispatch(save(props.deviceAccount.uuid)); + }} + noUTM={props.noUTM} + isActive={isActive} + filterSelectedTool={true} + filterActiveTools={false} /> +
+ +
+ +
+
+ {props.bot && + } +
+ }
{ const ElectronicsPopupControls = (props: PopupControlProps) => { if (props.object.kind != "electronics") { return undefined; } const firmwareHardware = getFwHardwareValue(props.fbosConfig); + const hasButtons = btnIndexList(firmwareHardware).btns.length > 0; return
{ dispatch={props.dispatch} fbosConfig={props.fbosConfig} sequences={props.sequences} /> - {props.dispatch && props.resources && props.bot && + {props.dispatch && props.resources && props.bot && hasButtons && {
; }; +const SoilHeightPopupControls = (props: PopupControlProps) => { + if (props.object.kind != "soilHeight") { return undefined; } + const soilZ = props.points.filter(soilHeightPoint).map(point => point.body.z); + const averageZ = soilZ.length > 0 + ? round(mean(soilZ)) + : props.config.maxSoilZ; + const sourceFbosConfig = sourceFbosConfigValue( + validFbosConfig(props.fbosConfig), + props.bot?.hardware.configuration || {}, + ); + return
+
+ +
+
; +}; + const GantryBeamPopupControls = (props: PopupControlProps) => { if (props.object.kind != "gantryBeam") { return undefined; } const lighting = props.peripherals.find(peripheral => @@ -693,6 +818,7 @@ export const ObjectPopupControls = (props: PopupControlProps) => { case "sceneObject": return ; case "bed": return ; case "safeHeight": return ; + case "soilHeight": return ; case "gantryBeam": return ; } }; @@ -746,7 +872,7 @@ type DeletableResolvedThreeDObject = Exclude< ResolvedThreeDObject, { kind: "utm" } | { kind: "electronics" } | { kind: "camera" } | { kind: "connectivity" } | { kind: "bed" } | { kind: "safeHeight" } - | { kind: "gantryBeam" } + | { kind: "soilHeight" } | { kind: "gantryBeam" } >; const objectUuid = (object: DeletableResolvedThreeDObject) => { @@ -768,6 +894,7 @@ export const ObjectPopupDeleteButton = (props: PopupControlProps) => { || object.kind == "connectivity" || object.kind == "bed" || object.kind == "safeHeight" + || object.kind == "soilHeight" || object.kind == "gantryBeam") { return undefined; } diff --git a/frontend/three_d_garden/selection/popups.tsx b/frontend/three_d_garden/selection/popups.tsx index 0e7fb5bd64..480eb279b5 100644 --- a/frontend/three_d_garden/selection/popups.tsx +++ b/frontend/three_d_garden/selection/popups.tsx @@ -46,6 +46,7 @@ const objectShowsLocation = (object: ResolvedThreeDObject) => "sceneObject", "bed", "safeHeight", + "soilHeight", "gantryBeam", ].includes(object.kind); diff --git a/frontend/three_d_garden/selection/resolve.ts b/frontend/three_d_garden/selection/resolve.ts index d29c84affa..6583feb18f 100644 --- a/frontend/three_d_garden/selection/resolve.ts +++ b/frontend/three_d_garden/selection/resolve.ts @@ -93,6 +93,10 @@ interface ResolvedSafeHeightObject extends ResolvedThreeDObjectBase { kind: "safeHeight"; } +interface ResolvedSoilHeightObject extends ResolvedThreeDObjectBase { + kind: "soilHeight"; +} + interface ResolvedGantryBeamObject extends ResolvedThreeDObjectBase { kind: "gantryBeam"; } @@ -109,6 +113,7 @@ export type ResolvedThreeDObject = | ResolvedSceneObject | ResolvedBedObject | ResolvedSafeHeightObject + | ResolvedSoilHeightObject | ResolvedGantryBeamObject; export interface ResolvedLocationObject { @@ -131,6 +136,7 @@ export const objectHasSelectionOverlay = ( && object.kind != "utm" && object.kind != "electronics" && object.kind != "safeHeight" + && object.kind != "soilHeight" && object.kind != "camera" && object.kind != "connectivity" && object.kind != "sceneObject" @@ -450,6 +456,32 @@ const resolveSafeHeightObject = ( }; }; +const resolveSoilHeightObject = ( + props: ResolveSelectedObjectProps, + selection: ThreeDObjectSelection, +): ResolvedSoilHeightObject => { + const { botSizeY, minSoilZ, maxSoilZ } = props.config; + const position = get3DPositionNoMirrorFunc(props.config)({ + x: 0, + y: botSizeY / 2, + }); + const z = selection.id == 1 ? minSoilZ : maxSoilZ; + const worldPosition: [number, number, number] = [ + position.x, + position.y, + zZero(props.config) + z, + ]; + return { + kind: "soilHeight", + selection, + name: t("Soil height"), + worldPosition, + popupPosition: [worldPosition[0], worldPosition[1], worldPosition[2] + 75], + ringRadius: MIN_RING_RADIUS, + locationCoordinate: { x: 0, y: botSizeY / 2, z }, + }; +}; + const resolveGantryBeamObject = ( props: ResolveSelectedObjectProps, selection: ThreeDObjectSelection, @@ -487,6 +519,7 @@ const resolveGantryBeamObject = ( export const resolveSelectedObject = ( props: ResolveSelectedObjectProps, selection: ThreeDObjectSelection | undefined, + // eslint-disable-next-line complexity ): ResolvedThreeDObject | undefined => { if (!selection) { return undefined; } switch (selection.kind) { @@ -501,6 +534,7 @@ export const resolveSelectedObject = ( case "sceneObject": return resolveSceneObject(props, selection); case "bed": return resolveBedObject(props, selection); case "safeHeight": return resolveSafeHeightObject(props, selection); + case "soilHeight": return resolveSoilHeightObject(props, selection); case "gantryBeam": return resolveGantryBeamObject(props, selection); } }; diff --git a/frontend/three_d_garden/selection/routes.ts b/frontend/three_d_garden/selection/routes.ts index 6df197287c..68bc0ba50c 100644 --- a/frontend/three_d_garden/selection/routes.ts +++ b/frontend/three_d_garden/selection/routes.ts @@ -181,6 +181,7 @@ export const pathForThreeDSelection = ( case "sceneObject": return Path.sceneObjects(selection.id); case "bed": return Path.settings("3d_garden"); case "safeHeight": return Path.settings("farmbot"); + case "soilHeight": return Path.points(); case "gantryBeam": return Path.settings("3d_garden"); } }; diff --git a/frontend/three_d_garden/selection_types.ts b/frontend/three_d_garden/selection_types.ts index 96110da273..6e829c5f60 100644 --- a/frontend/three_d_garden/selection_types.ts +++ b/frontend/three_d_garden/selection_types.ts @@ -1,6 +1,6 @@ export type ThreeDObjectKind = "plant" | "point" | "weed" | "slot" | "utm" | "electronics" | "camera" - | "connectivity" | "sceneObject" | "bed" | "safeHeight" + | "connectivity" | "sceneObject" | "bed" | "safeHeight" | "soilHeight" | "gantryBeam"; export interface ThreeDObjectSelection { diff --git a/frontend/ui/__tests__/filter_search_test.tsx b/frontend/ui/__tests__/filter_search_test.tsx index d226bfdcb1..6b79580046 100644 --- a/frontend/ui/__tests__/filter_search_test.tsx +++ b/frontend/ui/__tests__/filter_search_test.tsx @@ -86,6 +86,19 @@ describe("", () => { expect(itemListFilter).toHaveBeenLastCalledWith(p.items, "stress"); }); + it("updates the search query", () => { + const instance = createInstance(); + instance["handleQueryChange"]("mint"); + expect(instance.state.query).toEqual("mint"); + }); + + it("matches the popover width to the target", () => { + const p = fakeProps(); + p.matchTargetWidth = true; + const element = createInstance(p).render(); + expect(element.props.popoverProps.matchTargetWidth).toBeTruthy(); + }); + it("shows section headings only when a child item matches", () => { const instance = createInstance(); const items = [ diff --git a/frontend/ui/__tests__/new_fb_select_test.tsx b/frontend/ui/__tests__/new_fb_select_test.tsx index eda6fbc0de..58c2868dd9 100644 --- a/frontend/ui/__tests__/new_fb_select_test.tsx +++ b/frontend/ui/__tests__/new_fb_select_test.tsx @@ -9,6 +9,7 @@ const renderedElement = (props: FBSelectProps) => nullChoice: { label: string; value: string }; items: { label: string; value: string }[]; itemListFilter?: FBSelectProps["itemListFilter"]; + matchTargetWidth?: boolean; }>; }>; @@ -79,6 +80,13 @@ describe("", () => { .toEqual(p.itemListFilter); }); + it("passes the match target width option", () => { + const p = fakeProps(); + p.matchTargetWidth = true; + const element = renderedElement(p); + expect(element.props.children.props.matchTargetWidth).toBeTruthy(); + }); + it("only updates when props change", () => { const p = fakeProps(); const instance = new FBSelect(p); diff --git a/frontend/ui/filter_search.tsx b/frontend/ui/filter_search.tsx index 6c29dcb37f..58ab955f8c 100644 --- a/frontend/ui/filter_search.tsx +++ b/frontend/ui/filter_search.tsx @@ -12,6 +12,7 @@ export interface FilterSearchProps { onChange: (item: DropDownItem) => void; nullChoice: DropDownItem; itemListFilter?: (items: DropDownItem[], query: string) => DropDownItem[]; + matchTargetWidth?: boolean; usePortal?: boolean; title?: string; } @@ -51,6 +52,7 @@ export class FilterSearch items.length < 4 ? "few-items" : "", ].join(" "), modifiers: { offset: { options: { offset: [0, 0] } } }, + matchTargetWidth: this.props.matchTargetWidth, usePortal: this.props.usePortal, }}> diff --git a/frontend/ui/new_fb_select.tsx b/frontend/ui/new_fb_select.tsx index e254028c4a..d5e6b9966d 100644 --- a/frontend/ui/new_fb_select.tsx +++ b/frontend/ui/new_fb_select.tsx @@ -18,6 +18,8 @@ export interface FBSelectProps { customNullLabel?: string; /** Optionally filter the list with access to the current search query. */ itemListFilter?: (items: DropDownItem[], query: string) => DropDownItem[]; + /** Match the open menu width to the select target width. */ + matchTargetWidth?: boolean; usePortal?: boolean; title?: string; } @@ -55,6 +57,7 @@ export class FBSelect extends React.Component { selectedItem={this.item} items={this.list} itemListFilter={this.props.itemListFilter} + matchTargetWidth={this.props.matchTargetWidth} onChange={this.props.onChange} usePortal={this.props.usePortal} nullChoice={this.NULL_CHOICE} diff --git a/scripts/jest-file-coverage b/scripts/jest-file-coverage index 218ed5052e..facf6935f0 100755 --- a/scripts/jest-file-coverage +++ b/scripts/jest-file-coverage @@ -58,6 +58,25 @@ fs.writeFileSync(setupFile, [ "Object.assign(globalThis, { TextDecoder, TextEncoder });", "globalThis.mockNavigate = jest.fn();", "jest.mock('browser-speech', () => ({ talk: jest.fn() }));", + `jest.mock(${JSON.stringify(path.join(rootDir, "frontend/ui/popover.tsx"))},`, + " () => {", + ` const actual = jest.requireActual(${JSON.stringify( + path.join(rootDir, "frontend/ui/popover.tsx"))});`, + " const ProxyPopover = props => {", + ` const current = require(${JSON.stringify( + path.join(rootDir, "frontend/ui/index.ts"))}).Popover;`, + " return current == ProxyPopover", + " ? actual.Popover(props)", + " : current(props);", + " };", + " return { __esModule: true, ...actual, Popover: ProxyPopover };", + " });", + `jest.mock(${JSON.stringify(path.join(rootDir, "frontend/ui/index.ts"))},`, + " () => ({", + " __esModule: true,", + ` ...jest.requireActual(${JSON.stringify( + path.join(rootDir, "frontend/ui/index.ts"))}),`, + " }));", "const originalConsoleError = console.error.bind(console);", "jest.spyOn(console, 'error').mockImplementation((...args) => {", " const message = String(args[0] ?? '');", From 381eee9faaf7621a5304716119cad9bfaf0cb92c Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 19 Aug 2026 09:51:05 -0700 Subject: [PATCH 08/15] add 3D fallbacks --- frontend/__test_support__/setup_tests.ts | 19 ++++ .../css/farm_designer/three_d_garden.scss | 40 ++++++++ .../farm_designer/__tests__/index_test.tsx | 20 +++- frontend/farm_designer/index.tsx | 96 ++++++++++--------- frontend/promo/__tests__/promo_test.tsx | 16 ++++ frontend/promo/promo.tsx | 61 ++++++------ .../pin_bindings/__tests__/box_top_test.tsx | 22 ++++- frontend/settings/pin_bindings/box_top.tsx | 10 +- .../__tests__/garden_model_test.tsx | 1 + .../three_d_required_overlay_test.tsx | 56 +++++++++++ .../three_d_required_overlay.tsx | 47 +++++++++ 11 files changed, 311 insertions(+), 77 deletions(-) create mode 100644 frontend/three_d_garden/__tests__/three_d_required_overlay_test.tsx create mode 100644 frontend/three_d_garden/three_d_required_overlay.tsx diff --git a/frontend/__test_support__/setup_tests.ts b/frontend/__test_support__/setup_tests.ts index 43f87b4568..a3f35d553e 100644 --- a/frontend/__test_support__/setup_tests.ts +++ b/frontend/__test_support__/setup_tests.ts @@ -1,6 +1,25 @@ import "@testing-library/jest-dom"; import "./customMatchers"; +type TestCanvasGetContext = ( + this: HTMLCanvasElement, + contextId: string, + options?: unknown, +) => RenderingContext | null; + +const canvasGetContext = HTMLCanvasElement.prototype.getContext as + TestCanvasGetContext; +HTMLCanvasElement.prototype.getContext = function ( + this: HTMLCanvasElement, + contextId: string, + options?: unknown, +) { + if (contextId == "webgl" || contextId == "webgl2") { + return {} as WebGLRenderingContext; + } + return canvasGetContext.call(this, contextId, options); +} as typeof HTMLCanvasElement.prototype.getContext; + expect.extend({ toContainHTML(received: Element | { innerHTML?: string }, expected: string) { const actual = received?.innerHTML ?? ""; diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index bd6467fcb1..539ba32339 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -1364,6 +1364,46 @@ } } +.three-d-required-overlay { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.5rem; + width: 100%; + height: 100%; + min-height: 23rem; + padding: 3rem; + color: var(--text-color); + background: var(--main-bg); + text-align: center; + cursor: default; + + > .fa { + color: $yellow; + font-size: 4rem; + } + + h2, + p { + margin: 0; + } + + p { + max-width: 44rem; + } +} + +.three-d-required-toggle { + display: flex; + align-items: center; + gap: 1rem; + + label { + margin: 0; + } +} + .stats-gl { position: absolute; top: 3rem; diff --git a/frontend/farm_designer/__tests__/index_test.tsx b/frontend/farm_designer/__tests__/index_test.tsx index a67a798f43..1374551082 100644 --- a/frontend/farm_designer/__tests__/index_test.tsx +++ b/frontend/farm_designer/__tests__/index_test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { getDefaultAxisLength, getGridSize, RawFarmDesigner as FarmDesigner, } from "../index"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { FarmDesignerProps, GardenMapProps } from "../interfaces"; import { bot } from "../../__test_support__/fake_state/bot"; import { @@ -30,6 +30,7 @@ import { NavigationContext } from "../../routes_helpers"; import * as mapLegend from "../map/legend/garden_map_legend"; import * as gardenMap from "../map/garden_map"; import { GardenMapLegendProps } from "../map/interfaces"; +import * as configActions from "../../config_storage/actions"; let lastLegendProps: GardenMapLegendProps | undefined; let lastGardenMapProps: GardenMapProps | undefined; @@ -222,6 +223,23 @@ describe("", () => { expect(container.innerHTML).toContain("three-d-garden"); }); + it("falls back when WebGL is unavailable", () => { + const webGLSpy = jest.spyOn(HTMLCanvasElement.prototype, "getContext") + // eslint-disable-next-line no-null/no-null + .mockImplementation((() => null) as never); + const setConfig = jest.spyOn(configActions, "setWebAppConfigValue"); + const p = fakeProps(); + p.getConfigValue = () => true; + const { container } = render(); + expect(container.textContent).toContain("3D graphics unavailable"); + const toggle = container.querySelector(".fb-toggle-button"); + toggle && fireEvent.click(toggle); + expect(setConfig).toHaveBeenCalledWith( + BooleanSetting.three_d_garden, false); + webGLSpy.mockRestore(); + setConfig.mockRestore(); + }); + it("navigates from context", () => { const navigate = jest.fn(); const ref = React.createRef(); diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index 3ebc4f129f..645cd37812 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -33,6 +33,7 @@ import { import { isDesktop, isMobile } from "../screen_size"; import { NavigationContext } from "../routes_helpers"; import { StargazingControls } from "../three_d_garden/stargazing/stargazing"; +import { ThreeDGuard } from "../three_d_garden/three_d_required_overlay"; export const getDefaultAxisLength = (getConfigValue: GetWebAppConfigValue): Record => { @@ -233,52 +234,55 @@ export class RawFarmDesigner
{threeDGarden - ? + ? this.props.dispatch( + setWebAppConfigValue(BooleanSetting.three_d_garden, false))}> + + :
", () => { unmount(); }); + it("shows guidance when WebGL is unavailable", () => { + const webGLSpy = jest.spyOn(HTMLCanvasElement.prototype, "getContext") + // eslint-disable-next-line no-null/no-null + .mockImplementation((() => null) as never); + const { container, unmount } = render(); + expect(container.textContent).toContain("3D graphics unavailable"); + expect(container.textContent).toContain("Enable WebGL"); + expect(container.querySelector(".three-d-required-toggle")).toBeFalsy(); + expect(container.querySelector(".overlay")).toBeTruthy(); + expect(container.querySelector(".settings-bar-loaded")).toBeTruthy(); + expect(container.querySelector(".gear")).toBeTruthy(); + expect(canvasSpy).not.toHaveBeenCalled(); + webGLSpy.mockRestore(); + unmount(); + }); + it("shows the view prism when viewCube is enabled", () => { window.location.search = "?viewCube=true"; const { container, unmount } = render(); diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index 335cd9bd25..44c1bbeaf5 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -30,6 +30,8 @@ import { getPromoResourcePlants, getPromoResourcePoints, getPromoResourceWeeds, } from "./resources"; import { clearCameraUrlParams } from "../three_d_garden/camera"; +import { isWebGLAvailable, ThreeDRequiredOverlay } from + "../three_d_garden/three_d_required_overlay"; const PROMO_BED_SIZES = [ { @@ -231,39 +233,42 @@ export const Promo = () => { seasonAnimationPaused ? { ...config, animateSeasons: true } : config, [config, seasonAnimationPaused]); + const webGLAvailable = React.useMemo(() => isWebGLAvailable(), []); return
- - { - gl.localClippingEnabled = true; - }}> - - - + {webGLAvailable + ? + { + gl.localClippingEnabled = true; + }}> + + + + : } {
- {config.viewCube && + {config.viewCube && webGLAvailable && }
; }; diff --git a/frontend/settings/pin_bindings/__tests__/box_top_test.tsx b/frontend/settings/pin_bindings/__tests__/box_top_test.tsx index 0af917f6b3..4d60ce4f4a 100644 --- a/frontend/settings/pin_bindings/__tests__/box_top_test.tsx +++ b/frontend/settings/pin_bindings/__tests__/box_top_test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { BoxTop } from "../box_top"; import { BoxTopProps } from "../interfaces"; import { @@ -8,6 +8,8 @@ import { import { bot } from "../../../__test_support__/fake_state/bot"; import * as model from "../model"; import * as boxTopGpioDiagram from "../box_top_gpio_diagram"; +import * as configActions from "../../../config_storage/actions"; +import { BooleanSetting } from "../../../session_keys"; let electronicsBoxModelSpy: jest.SpyInstance; let boxTopButtonsSpy: jest.SpyInstance; @@ -53,4 +55,22 @@ describe("", () => { expect(container.querySelectorAll(".electronics-box-3d-model").length) .toEqual(1); }); + + it("falls back when WebGL is unavailable", () => { + const webGLSpy = jest.spyOn(HTMLCanvasElement.prototype, "getContext") + // eslint-disable-next-line no-null/no-null + .mockImplementation((() => null) as never); + const setConfig = jest.spyOn(configActions, "setWebAppConfigValue"); + const p = fakeProps(); + p.threeDimensions = true; + const { container } = render(); + expect(container.textContent).toContain("3D graphics unavailable"); + expect(electronicsBoxModelSpy).not.toHaveBeenCalled(); + const toggle = container.querySelector(".fb-toggle-button"); + toggle && fireEvent.click(toggle); + expect(setConfig).toHaveBeenCalledWith( + BooleanSetting.enable_3d_electronics_box_top, false); + webGLSpy.mockRestore(); + setConfig.mockRestore(); + }); }); diff --git a/frontend/settings/pin_bindings/box_top.tsx b/frontend/settings/pin_bindings/box_top.tsx index a8bcc97602..664ada77cc 100644 --- a/frontend/settings/pin_bindings/box_top.tsx +++ b/frontend/settings/pin_bindings/box_top.tsx @@ -2,10 +2,18 @@ import React from "react"; import { ElectronicsBoxModel } from "./model"; import { BoxTopButtons } from "./box_top_gpio_diagram"; import { BoxTopProps } from "./interfaces"; +import { ThreeDGuard } from + "../../three_d_garden/three_d_required_overlay"; +import { setWebAppConfigValue } from "../../config_storage/actions"; +import { BooleanSetting } from "../../session_keys"; export const BoxTop = (props: BoxTopProps) =>
{props.threeDimensions - ? + ? props.dispatch( + setWebAppConfigValue( + BooleanSetting.enable_3d_electronics_box_top, false))}> + + : }
; diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 9fc40f3c54..ab5dc2eabf 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -1829,6 +1829,7 @@ describe("", () => { }); it("shows only the hovered plant when the plant layer is hidden", () => { + location.pathname = Path.mock(Path.designer()); const hoveredPlant = fakePlant(); hoveredPlant.body.id = 1; const hiddenPlant = fakePlant(); diff --git a/frontend/three_d_garden/__tests__/three_d_required_overlay_test.tsx b/frontend/three_d_garden/__tests__/three_d_required_overlay_test.tsx new file mode 100644 index 0000000000..9a27c379d9 --- /dev/null +++ b/frontend/three_d_garden/__tests__/three_d_required_overlay_test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { fireEvent, render } from "@testing-library/react"; +import { + isWebGLAvailable, ThreeDGuard, +} from "../three_d_required_overlay"; + +afterEach(() => jest.restoreAllMocks()); + +describe("isWebGLAvailable()", () => { + it("detects WebGL", () => { + expect(isWebGLAvailable()).toEqual(true); + }); + + it("handles context errors", () => { + jest.spyOn(HTMLCanvasElement.prototype, "getContext") + .mockImplementation((() => { throw new Error("no WebGL"); }) as never); + expect(isWebGLAvailable()).toEqual(false); + }); +}); + +describe("", () => { + it("renders children when WebGL is available", () => { + const { container } = render( +
+ ); + expect(container.querySelector(".three-d-content")).toBeTruthy(); + expect(container.querySelector(".three-d-required-overlay")).toBeFalsy(); + }); + + it("renders guidance when WebGL is unavailable", () => { + jest.spyOn(HTMLCanvasElement.prototype, "getContext") + // eslint-disable-next-line no-null/no-null + .mockImplementation((() => null) as never); + const { container } = render( +
+ ); + expect(container.querySelector(".three-d-content")).toBeFalsy(); + expect(container.textContent).toContain("3D graphics unavailable"); + expect(container.textContent).toContain("Enable WebGL"); + expect(container.querySelector(".three-d-required-toggle")).toBeFalsy(); + }); + + it("switches to 2D", () => { + jest.spyOn(HTMLCanvasElement.prototype, "getContext") + // eslint-disable-next-line no-null/no-null + .mockImplementation((() => null) as never); + const onSwitchTo2D = jest.fn(); + const { container } = render( +
); + const toggle = container.querySelector(".fb-toggle-button"); + toggle && fireEvent.click(toggle); + expect(onSwitchTo2D).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("2D"); + expect(container.textContent).toContain("3D"); + }); +}); diff --git a/frontend/three_d_garden/three_d_required_overlay.tsx b/frontend/three_d_garden/three_d_required_overlay.tsx new file mode 100644 index 0000000000..39d4f8bb41 --- /dev/null +++ b/frontend/three_d_garden/three_d_required_overlay.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { t } from "../i18next_wrapper"; +import { ToggleButton } from "../ui"; + +export const isWebGLAvailable = () => { + try { + const canvas = document.createElement("canvas"); + return !!(canvas.getContext("webgl2") || canvas.getContext("webgl")); + } catch { + return false; + } +}; + +interface ThreeDRequiredOverlayProps { + onSwitchTo2D?: () => void; +} + +export const ThreeDRequiredOverlay = + (props: ThreeDRequiredOverlayProps) => +
+ +

{t("3D graphics unavailable")}

+

{t(`This 3D view requires WebGL. Enable WebGL and hardware + acceleration in your browser settings, then restart your browser and + reload this page.`)}

+ {props.onSwitchTo2D && +
+ + + +
} +
; + +interface ThreeDGuardProps extends ThreeDRequiredOverlayProps { + children: React.ReactNode; +} + +export const ThreeDGuard = (props: ThreeDGuardProps) => { + const available = React.useMemo(() => isWebGLAvailable(), []); + return available + ? props.children + : ; +}; From 974e1ab6bbe830c372c2889767937126d7cd07d8 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 19 Aug 2026 10:32:39 -0700 Subject: [PATCH 09/15] cache playwright deps --- .github/workflows/render.yml | 70 ++++++++++++++++++- .../three_d_garden/__tests__/index_test.tsx | 5 -- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index a7b6f2c9c2..8ad30b6c1d 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -52,7 +52,7 @@ jobs: sudo docker compose up -d sudo docker compose ps - - name: Install playwright + - name: Install Playwright tooling run: | curl -fsSL https://bun.sh/install | bash export BUN_INSTALL="$HOME/.bun" @@ -61,7 +61,73 @@ jobs: echo "BUN_INSTALL=$BUN_INSTALL" >> "$GITHUB_ENV" echo "$BUN_INSTALL/bin" >> "$GITHUB_PATH" echo "PLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH" >> "$GITHUB_ENV" - bun run playwright install chromium --with-deps + + - name: Set Playwright apt cache key + id: playwright_apt_cache_key + run: | + . /etc/os-release + echo "platform=${ID}-${VERSION_ID}-${{ runner.arch }}" >> "$GITHUB_OUTPUT" + echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright apt downloads + id: playwright_apt_cache + uses: actions/cache/restore@v5 + with: + path: .cache/playwright-apt + key: playwright-apt-${{ steps.playwright_apt_cache_key.outputs.platform }}-${{ hashFiles('bun.lock') }}-${{ steps.playwright_apt_cache_key.outputs.week }} + restore-keys: | + playwright-apt-${{ steps.playwright_apt_cache_key.outputs.platform }}-${{ hashFiles('bun.lock') }}- + playwright-apt-${{ steps.playwright_apt_cache_key.outputs.platform }}- + + - name: Configure Playwright apt download cache + run: | + sudo install -d -m 755 "$GITHUB_WORKSPACE/.cache/playwright-apt" + sudo install -d -o _apt -g root -m 700 "$GITHUB_WORKSPACE/.cache/playwright-apt/partial" + sudo tee /etc/apt/apt.conf.d/99-playwright-ci-cache >/dev/null <> "$GITHUB_OUTPUT" + else + echo "has_packages=false" >> "$GITHUB_OUTPUT" + fi + + - name: Save Playwright apt downloads + if: steps.playwright_apt_cache.outputs.cache-hit != 'true' && steps.prepare_playwright_apt_cache.outputs.has_packages == 'true' + uses: actions/cache/save@v5 + with: + path: .cache/playwright-apt + key: playwright-apt-${{ steps.playwright_apt_cache_key.outputs.platform }}-${{ hashFiles('bun.lock') }}-${{ steps.playwright_apt_cache_key.outputs.week }} + + - name: Install Playwright Chromium + run: bun run playwright install chromium - name: Set up GPU run: | diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx index 57985cd754..0300b1cc38 100644 --- a/frontend/three_d_garden/__tests__/index_test.tsx +++ b/frontend/three_d_garden/__tests__/index_test.tsx @@ -15,15 +15,10 @@ import { bot } from "../../__test_support__/fake_state/bot"; import { buildResourceIndex } from "../../__test_support__/resource_index_builder"; -const useThreeImplementation = - (reactThreeFiber.useThree as jest.Mock).getMockImplementation(); - beforeEach(() => { console.log = jest.fn(); window.localStorage.clear(); delete window.__fbPerf; - jest.spyOn(reactThreeFiber, "useThree") - .mockImplementation(useThreeImplementation); }); afterEach(() => { From 4dcff5490c36d7f490af8d2d66ba492afba4b071 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 19 Aug 2026 14:36:57 -0700 Subject: [PATCH 10/15] fix missing sequence page --- frontend/route_config.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/route_config.tsx b/frontend/route_config.tsx index 48392bb130..2e18dc668f 100644 --- a/frontend/route_config.tsx +++ b/frontend/route_config.tsx @@ -135,6 +135,7 @@ export const ROUTE_DATA: RouteObject[] = [ { path: Path.app("messages"), element: }, { path: Path.logs(), element: }, { path: Path.sequencePage(":sequence_name"), element: }, + { path: Path.sequencePage(), element: }, { path: Path.sequenceVersion(), element: , children: [{ path: "", element: }], From 551cb9935136c61b6274f5ee47b7937eb4bfeec8 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 19 Aug 2026 14:37:26 -0700 Subject: [PATCH 11/15] fix stale celeryscript view state --- .../sequence_editor_middle_active_test.tsx | 12 ++++++++++++ frontend/sequences/interfaces.ts | 4 ++++ .../sequences/sequence_editor_middle_active.tsx | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx index 0549f72f19..31a1a6729e 100644 --- a/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx +++ b/frontend/sequences/__tests__/sequence_editor_middle_active_test.tsx @@ -479,6 +479,16 @@ describe("", () => { expect(ref.current?.state.sequencePreview).toEqual(sequence); }); + it("disables view sequence celery script", () => { + location.pathname = Path.mock(Path.sequences("1")); + const p = fakeProps(); + const ref = React.createRef(); + render(); + ref.current?.setState({ viewSequenceCeleryScript: true }); + act(() => ref.current?.disableViewSequenceCeleryScript()); + expect(ref.current?.state.viewSequenceCeleryScript).toEqual(false); + }); + it("sets error", () => { location.pathname = Path.mock(Path.sequences("1")); const p = fakeProps(); @@ -725,6 +735,7 @@ describe("", () => { resources: buildResourceIndex().index, syncStatus: "synced", getWebAppConfigValue: jest.fn(), + disableViewSequenceCeleryScript: jest.fn(), toggleViewSequenceCeleryScript: jest.fn(), sequencesState: emptyState().consumers.sequences, viewCeleryScript: true, @@ -891,6 +902,7 @@ describe("", () => { const fakeProps = (): SequenceSettingsMenuProps => ({ dispatch: jest.fn(), getWebAppConfigValue: jest.fn(), + disableViewSequenceCeleryScript: jest.fn(), }); it("renders settings", () => { diff --git a/frontend/sequences/interfaces.ts b/frontend/sequences/interfaces.ts index 82aac9b09d..f59db59b84 100644 --- a/frontend/sequences/interfaces.ts +++ b/frontend/sequences/interfaces.ts @@ -83,6 +83,7 @@ export interface ActiveMiddleState { export interface SequenceHeaderProps extends SequencePropsBase { sequence: TaggedSequence; + disableViewSequenceCeleryScript: () => void; toggleViewSequenceCeleryScript: () => void; viewCeleryScript: boolean; showName: boolean; @@ -90,6 +91,7 @@ export interface SequenceHeaderProps extends SequencePropsBase { export interface SequenceBtnGroupProps extends SequencePropsBase { sequence: TaggedSequence; + disableViewSequenceCeleryScript(): void; toggleViewSequenceCeleryScript(): void; viewCeleryScript: boolean; } @@ -97,6 +99,7 @@ export interface SequenceBtnGroupProps extends SequencePropsBase { export interface SequenceSettingsMenuProps { dispatch: Function; getWebAppConfigValue: GetWebAppConfigValue; + disableViewSequenceCeleryScript(): void; } export interface SequenceShareMenuProps { @@ -111,6 +114,7 @@ export interface SequenceSettingProps { getWebAppConfigValue: GetWebAppConfigValue; confirmation?: string; defaultOn?: boolean; + onChange?: () => void; } export type ChannelName = ALLOWED_CHANNEL_NAMES; diff --git a/frontend/sequences/sequence_editor_middle_active.tsx b/frontend/sequences/sequence_editor_middle_active.tsx index f4872188ea..86918d8828 100644 --- a/frontend/sequences/sequence_editor_middle_active.tsx +++ b/frontend/sequences/sequence_editor_middle_active.tsx @@ -95,8 +95,12 @@ export const SequenceSetting = (props: SequenceSettingProps) => { proceed() && - props.dispatch(setWebAppConfigValue(props.setting, !value))} /> + toggleAction={() => { + if (proceed()) { + props.onChange?.(); + props.dispatch(setWebAppConfigValue(props.setting, !value)); + } + }} /> ; }; @@ -130,6 +134,7 @@ export const SequenceSettingsMenu =
; }; @@ -261,6 +266,7 @@ export const SequenceBtnGroup = ({ resources, sequencesState, getWebAppConfigValue, + disableViewSequenceCeleryScript, toggleViewSequenceCeleryScript, viewCeleryScript, visualized, @@ -279,6 +285,7 @@ export const SequenceBtnGroup = ({ title={t("settings")} />} content={} /> { syncStatus={props.syncStatus} resources={props.resources} getWebAppConfigValue={props.getWebAppConfigValue} + disableViewSequenceCeleryScript={props.disableViewSequenceCeleryScript} toggleViewSequenceCeleryScript={props.toggleViewSequenceCeleryScript} viewCeleryScript={props.viewCeleryScript} visualized={props.visualized} @@ -455,6 +463,8 @@ export class SequenceEditorMiddleActive extends toggleSection = (key: keyof ActiveMiddleState) => () => this.setState({ ...this.state, [key]: !this.state[key] }); + disableViewSequenceCeleryScript = () => + this.setState({ viewSequenceCeleryScript: false }); setSequencePreview = (sequencePreview: TaggedSequence) => this.setState({ sequencePreview, @@ -525,6 +535,7 @@ export class SequenceEditorMiddleActive extends sequence={sequence} resources={this.props.resources} syncStatus={this.props.syncStatus} + disableViewSequenceCeleryScript={this.disableViewSequenceCeleryScript} toggleViewSequenceCeleryScript={ this.toggleSection("viewSequenceCeleryScript")} viewCeleryScript={viewSequenceCeleryScript} From 076a5d02e017009596cc1208cb02565a24a4491a Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 20 Aug 2026 11:17:52 -0700 Subject: [PATCH 12/15] upgrade deps (redis) --- Gemfile | 2 +- Gemfile.lock | 20 ++++++++++++-------- bun.lock | 16 ++++++++-------- docker-compose.yml | 6 ++---- package.json | 8 ++++---- spec/mutations/users/update_spec.rb | 3 ++- 6 files changed, 29 insertions(+), 26 deletions(-) diff --git a/Gemfile b/Gemfile index 0292386116..e302f441da 100755 --- a/Gemfile +++ b/Gemfile @@ -17,7 +17,7 @@ gem "pg" gem "rabbitmq_http_api_client" gem "rack-attack" gem "rack-cors" -gem "redis", "~> 4.0" +gem "redis" gem "request_store" gem "rollbar" gem "scenic" diff --git a/Gemfile.lock b/Gemfile.lock index 2757c43657..ddf1c7030b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,8 +91,8 @@ GEM brakeman (8.0.6) racc builder (3.3.0) - bunny (3.1.0) - amq-protocol (~> 2.7) + bunny (3.2.0) + amq-protocol (~> 2.8) logger (~> 1, >= 1.7) sorted_set (~> 1, >= 1.0.2) case_transform (0.2) @@ -279,7 +279,7 @@ GEM parser (3.3.12.0) ast (~> 2.4.1) racc - passenger (6.1.8) + passenger (6.2.0) logger (>= 1.7.0) rack (>= 1.6.13) rackup (>= 1.0.1) @@ -365,7 +365,10 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort - redis (4.8.1) + redis (6.0.0) + redis-client (= 0.30.1) + redis-client (0.30.1) + connection_pool regexp_parser (2.12.0) reline (0.7.0) io-console (~> 0.5) @@ -514,7 +517,7 @@ DEPENDENCIES rack-attack rack-cors rails - redis (~> 4.0) + redis request_store rollbar rspec @@ -556,7 +559,7 @@ CHECKSUMS brakeman (8.0.6) sha256=759cc69341115e6c2dcd47b6fd8649a0b9bd540e3585ac8a0a94e31c66fee386 builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler (4.0.17) sha256=214e21431b5665dd2f99df8a5511c6b151d7a72e8015c8b38f8b775b61cbb6c1 - bunny (3.1.0) sha256=fd09ea8be3fbb7fe4b1063f874094b444f762f2da1692b3341751af7c6d62f3c + bunny (3.2.0) sha256=b568e3cd6510feb9a12a07818c1d1d412dfc47eddf8b29d401288ecd52f288f1 case_transform (0.2) sha256=e2ad4418dceeb227cf474cc332cd5004c95c136c04186c1cceaad8ab8de6fe3b cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb climate_control (1.2.0) sha256=36b21896193fa8c8536fa1cd843a07cf8ddbd03aaba43665e26c53ec1bd70aa5 @@ -641,7 +644,7 @@ CHECKSUMS ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 - passenger (6.1.8) sha256=01e9b5173048aceb1dcfaeea92bbc7f90ef045ca4240fe98ed2bcddbb6a67a1f + passenger (6.2.0) sha256=2b0497c097d12d1483f4d46008cb9e1268df09f56b36e941a93ab88ec2f1e11c pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c @@ -673,7 +676,8 @@ CHECKSUMS rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4 rbtree (0.4.7) sha256=1efabbcb3fd5f12249c9c8a610a765074868164eed0c50c9db2531c00ed161cb rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 - redis (4.8.1) sha256=387ee086694fffc9632aaeb1efe4a7b1627ca783bf373320346a8a20cd93333a + redis (6.0.0) sha256=de71c10edd106986b759ec7ecdd08b63b9c0ee7414a0d0c1da73d31ba2bccda6 + redis-client (0.30.1) sha256=5151bc5c7bbfe48623732cdae3b900d8a22dc691cc7cdfacfb351ac55116522d regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace diff --git a/bun.lock b/bun.lock index e301a388dc..a07d48e4c8 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ "farmbot": "15.9.8", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.3.6", + "i18next": "26.4.0", "lodash": "4.18.1", "markdown-it": "15.0.0", "markdown-it-emoji": "3.1.0", @@ -61,7 +61,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", - "@happy-dom/global-registrator": "20.11.2", + "@happy-dom/global-registrator": "20.11.6", "@react-three/eslint-plugin": "0.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.1", @@ -82,7 +82,7 @@ "eslint-plugin-promise": "7.3.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", - "happy-dom": "20.11.2", + "happy-dom": "20.11.6", "jest": "30.4.2", "jest-canvas-mock": "2.5.8", "jest-cli": "30.4.2", @@ -97,7 +97,7 @@ "postcss-scss": "4.0.9", "raf": "3.4.1", "react-test-renderer": "19.2.8", - "sass": "1.102.0", + "sass": "1.103.0", "sass-lint": "1.13.1", "stylelint": "17.14.1", "stylelint-config-standard-scss": "17.0.0", @@ -253,7 +253,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.2" } }, "sha512-7fkpoXZWzyxaBkzRtlD0wRU5ckxbNT4j6BywzpSYh+SFPsGxEVmNR9KHaMwM2xkHB0pADQLl4Z8DSrztECJ7Ww=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.11.6" } }, "sha512-ZQ47qUTeNbGhHkCGExJ1oZhruoxKRaxO44RgFETl3T4c1rRxIBlAOnM3SAH4XHu7Ue2owJXP+jOx1vOuKuxcSg=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -1221,7 +1221,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.11.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw=="], + "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], "has-ansi": ["has-ansi@2.0.0", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg=="], @@ -1271,7 +1271,7 @@ "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - "i18next": ["i18next@26.3.6", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA=="], + "i18next": ["i18next@26.4.0", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1951,7 +1951,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass": ["sass@1.102.0", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw=="], + "sass": ["sass@1.103.0", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-+QpdXUDw19lVqRDlYIyje1Lq/0gHsnNHIl4x1CqeUg13zRhaQN11UvTvouwHWLXc9Q3rQVT6oBhFGRYJ5Z8gHw=="], "sass-lint": ["sass-lint@1.13.1", "", { "dependencies": { "commander": "^2.8.1", "eslint": "^2.7.0", "front-matter": "2.1.2", "fs-extra": "^3.0.1", "glob": "^7.0.0", "globule": "^1.0.0", "gonzales-pe-sl": "^4.2.3", "js-yaml": "^3.5.4", "known-css-properties": "^0.3.0", "lodash.capitalize": "^4.1.0", "lodash.kebabcase": "^4.0.0", "merge": "^1.2.0", "path-is-absolute": "^1.0.0", "util": "^0.10.3" }, "bin": "bin/sass-lint.js" }, "sha512-DSyah8/MyjzW2BWYmQWekYEKir44BpLqrCFsgs9iaWiVTcwZfwXHF586hh3D1n+/9ihUNMfd8iHAyb9KkGgs7Q=="], diff --git a/docker-compose.yml b/docker-compose.yml index 072be6bd66..73b082c31c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,10 +28,8 @@ services: redis: env_file: ".env" - image: redis:7 - volumes: - - "./docker_volumes/redis/data:/data" - - "./docker_volumes/redis/conf:/usr/local/etc/redis" + image: valkey/valkey:9.0 + volumes: ["./docker_volumes/valkey/data:/data"] expose: ["6379"] db: diff --git a/package.json b/package.json index c12111346b..49aaa3b06e 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "farmbot": "15.9.8", "fengari": "0.1.5", "fengari-web": "0.1.4", - "i18next": "26.3.6", + "i18next": "26.4.0", "lodash": "4.18.1", "markdown-it": "15.0.0", "markdown-it-emoji": "3.1.0", @@ -94,7 +94,7 @@ }, "devDependencies": { "@eslint/js": "10.0.1", - "@happy-dom/global-registrator": "20.11.2", + "@happy-dom/global-registrator": "20.11.6", "@react-three/eslint-plugin": "0.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "7.0.1", @@ -115,7 +115,7 @@ "eslint-plugin-promise": "7.3.0", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.1.1", - "happy-dom": "20.11.2", + "happy-dom": "20.11.6", "jest": "30.4.2", "jest-canvas-mock": "2.5.8", "jest-cli": "30.4.2", @@ -130,7 +130,7 @@ "postcss-scss": "4.0.9", "raf": "3.4.1", "react-test-renderer": "19.2.8", - "sass": "1.102.0", + "sass": "1.103.0", "sass-lint": "1.13.1", "stylelint": "17.14.1", "stylelint-config-standard-scss": "17.0.0", diff --git a/spec/mutations/users/update_spec.rb b/spec/mutations/users/update_spec.rb index c7a6861a23..3513a6741a 100644 --- a/spec/mutations/users/update_spec.rb +++ b/spec/mutations/users/update_spec.rb @@ -51,9 +51,10 @@ it "stops users from changing to an unauthorized email domain" do user = FactoryBot.create(:user) + email = "#{SecureRandom.hex(8)}@mailinator.com" ClimateControl.modify(TRUSTED_DOMAINS: "farmbot.io,farm.bot") do - result = Users::Update.run(user: user, email: "example@mailinator.com") + result = Users::Update.run(user: user, email: email) expect(result.success?).to be false expect(result.errors.message_list) From ad86070b9ee6126ec870880c498abc8cdbdf3373 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 20 Aug 2026 11:49:57 -0700 Subject: [PATCH 13/15] upgrade actions --- .github/actions/setup-ci/action.yml | 4 ++-- .github/workflows/render.yml | 20 ++++++++++---------- .github/workflows/test.yml | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml index 59166c5c23..b7669097f8 100644 --- a/.github/actions/setup-ci/action.yml +++ b/.github/actions/setup-ci/action.yml @@ -42,13 +42,13 @@ runs: cache-to: type=gha,mode=max,scope=farmbot-web - name: Cache bundle - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: docker_volumes/bundle_cache key: bundle-${{ runner.os }}-${{ hashFiles('Gemfile.lock') }} - name: Cache node modules - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: node_modules key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index 8ad30b6c1d..c507e0340f 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set render env run: scripts/ci/export-render-env @@ -71,7 +71,7 @@ jobs: - name: Restore Playwright apt downloads id: playwright_apt_cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: .cache/playwright-apt key: playwright-apt-${{ steps.playwright_apt_cache_key.outputs.platform }}-${{ hashFiles('bun.lock') }}-${{ steps.playwright_apt_cache_key.outputs.week }} @@ -121,7 +121,7 @@ jobs: - name: Save Playwright apt downloads if: steps.playwright_apt_cache.outputs.cache-hit != 'true' && steps.prepare_playwright_apt_cache.outputs.has_packages == 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: path: .cache/playwright-apt key: playwright-apt-${{ steps.playwright_apt_cache_key.outputs.platform }}-${{ hashFiles('bun.lock') }}-${{ steps.playwright_apt_cache_key.outputs.week }} @@ -162,7 +162,7 @@ jobs: exit 1 - name: Restore branch metric history cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: | /tmp/${{ env.SCENE_METRICS_NAME }}*.csv @@ -184,7 +184,7 @@ jobs: - name: Restore staging metric history cache fallback if: steps.restore_metric_history.outputs.scene_metrics_found != 'true' - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: | /tmp/${{ env.SCENE_METRICS_NAME }}*.csv @@ -193,7 +193,7 @@ jobs: ${{ env.STAGING_FPS_METRICS_CACHE_KEY }}- - name: Restore branch FPS history cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: /tmp/${{ env.FPS_HISTORY }}.csv key: ${{ env.FPS_HISTORY_CACHE_KEY }}-${{ env.CACHE_BASELINE_SHA }}-${{ env.CACHE_KEY_SUFFIX }} @@ -214,7 +214,7 @@ jobs: - name: Restore staging FPS history cache fallback if: steps.restore_fps_history.outputs.fps_history_found != 'true' - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: /tmp/${{ env.FPS_HISTORY }}.csv key: ${{ env.STAGING_FPS_HISTORY_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} @@ -223,7 +223,7 @@ jobs: - name: Restore frontend coverage value if: always() - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} @@ -257,14 +257,14 @@ jobs: bun scripts/ci/combine-render-images - name: Save FPS metrics - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: path: | /tmp/${{ env.SCENE_METRICS_NAME }}*.csv key: ${{ env.FPS_METRICS_CACHE_KEY }}-${{ env.CACHE_BASELINE_SHA }}-${{ env.CACHE_KEY_SUFFIX }} - name: Save FPS history - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: path: /tmp/${{ env.FPS_HISTORY }}.csv key: ${{ env.FPS_HISTORY_CACHE_KEY }}-${{ env.CACHE_BASELINE_SHA }}-${{ env.CACHE_KEY_SUFFIX }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 51a2194684..20d33f828f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 100 @@ -68,7 +68,7 @@ jobs: - name: Upload app coverage to Codecov if: ${{ hashFiles('coverage_api/coverage.xml') != '' }} - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage_api/coverage.xml @@ -123,7 +123,7 @@ jobs: - name: Restore frontend coverage cache fallback if: ${{ always() && steps.restore_fe_coverage_history.outputs.fe_coverage_found != 'true' }} - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} @@ -151,7 +151,7 @@ jobs: - name: Save frontend coverage value if: ${{ always() && env.COVERAGE_AVAILABLE == 'true' }} - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: path: /tmp/${{ env.FE_COVERAGE_NAME }}.csv key: ${{ env.FE_COVERAGE_CACHE_KEY }}-${{ env.CACHE_KEY_SUFFIX }} From 773b0a36347e5150cfe7b44ff7f6e8871a0ccc2e Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 20 Aug 2026 12:09:12 -0700 Subject: [PATCH 14/15] adjust sourcemap uploads --- example.env | 1 - frontend/__tests__/routes_test.tsx | 38 +++++++++++++++++++++++++++++- frontend/routes.tsx | 28 ++++++++++++++++++++++ lib/rollbar_source_maps.rb | 25 ++++++-------------- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/example.env b/example.env index f1d99e90fb..2bf97c868b 100644 --- a/example.env +++ b/example.env @@ -128,7 +128,6 @@ GCS_UPLOAD_KEYFILE_JSON= ROLLBAR_ACCESS_TOKEN=____ ROLLBAR_CLIENT_TOKEN=____ ROLLBAR_SRCMAP_TOKEN=____ -ROLLBAR_ASSET_HOSTS=____ ROLLBAR_ENV= # FarmBot uses DataDog for log analytics and for assessing overall system health. diff --git a/frontend/__tests__/routes_test.tsx b/frontend/__tests__/routes_test.tsx index ba193e56cb..ebc80f34b4 100644 --- a/frontend/__tests__/routes_test.tsx +++ b/frontend/__tests__/routes_test.tsx @@ -5,7 +5,7 @@ import { AuthState } from "../auth/interfaces"; import { auth } from "../__test_support__/fake_state/token"; import { Session } from "../session"; import { Path } from "../internal_urls"; -import { RootComponent } from "../routes"; +import { normalizeRollbarAssetUrls, RootComponent } from "../routes"; describe("", () => { let mockAuth: AuthState | undefined = undefined; @@ -44,3 +44,39 @@ describe("", () => { expect(container.innerHTML).not.toContain("rollbar"); }); }); + +describe("normalizeRollbarAssetUrls()", () => { + it("uses the shared Rollbar host for FarmBot assets", () => { + const payload = { + body: { + trace: { + frames: [ + { filename: "https://my.farm.bot/assets/dist/app.js" }, + { filename: "https://example.com/other.js" }, + {}, + ], + }, + trace_chain: [{ + frames: [ + { filename: "https://custom.farm.bot/assets/dist/chunk.js" }, + ], + }], + }, + }; + + normalizeRollbarAssetUrls(payload); + + expect(payload.body.trace.frames).toEqual([ + { filename: "https://dynamichost/assets/dist/app.js" }, + { filename: "https://example.com/other.js" }, + {}, + ]); + expect(payload.body.trace_chain[0].frames).toEqual([ + { filename: "https://dynamichost/assets/dist/chunk.js" }, + ]); + }); + + it("handles payloads without a trace", () => { + expect(() => normalizeRollbarAssetUrls({})).not.toThrow(); + }); +}); diff --git a/frontend/routes.tsx b/frontend/routes.tsx index e1905a524a..46b6f9b11d 100644 --- a/frontend/routes.tsx +++ b/frontend/routes.tsx @@ -15,6 +15,32 @@ import { App } from "./app"; interface RootComponentProps { store: Store; } +interface RollbarTrace { + frames?: { filename?: string }[]; +} + +interface RollbarPayload { + body?: { + trace?: RollbarTrace; + trace_chain?: RollbarTrace[]; + }; +} + +export const normalizeRollbarAssetUrls = (payload: RollbarPayload) => { + const traces = [payload.body?.trace, ...(payload.body?.trace_chain || [])]; + traces.forEach(trace => { + trace?.frames?.forEach(frame => { + const filename = frame.filename; + if (filename) { + frame.filename = filename.replace( + /^(https?):\/\/[^/]+(\/assets\/dist\/)/, + "$1://dynamichost$2", + ); + } + }); + }); +}; + export const attachAppToDom = () => { attachToRoot(RootComponent, { store: _store }); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -38,6 +64,8 @@ export class RootComponent accessToken: globalConfig.ROLLBAR_CLIENT_TOKEN, captureUncaught: true, captureUnhandledRejections: true, + transform: (payload: RollbarPayload) => + normalizeRollbarAssetUrls(payload), payload: { person: { id: "" + (Session.fetchStoredToken()?.user.id || 0) }, environment: window.location.host, diff --git a/lib/rollbar_source_maps.rb b/lib/rollbar_source_maps.rb index 60dd03c458..e3ddc20479 100644 --- a/lib/rollbar_source_maps.rb +++ b/lib/rollbar_source_maps.rb @@ -52,7 +52,7 @@ def upload_map(token:, version:, minified_url:, map_path:) def upload token = ENV["ROLLBAR_SRCMAP_TOKEN"] - revision = ENV["BUILT_AT"] || ENV["SOURCE_VERSION"] || ENV["HEROKU_BUILD_COMMIT"] + revision = ENV["SOURCE_VERSION"] unless token && revision puts "Skipping Rollbar source map upload: configuration incomplete." return @@ -61,15 +61,6 @@ def upload puts "Uploading Rollbar source maps for revision #{revision}..." version = revision.first(8) - asset_hosts = [ - ENV.fetch("API_HOST"), - *ENV.fetch("ROLLBAR_ASSET_HOSTS", "").split(","), - ] - .compact - .map(&:strip) - .reject(&:empty?) - .uniq - .map { |host| "https://#{host}" } map_glob = File.join( DashboardController::PUBLIC_OUTPUT_DIR, "**/*.js.map", @@ -81,14 +72,12 @@ def upload raise "Missing minified file for #{map_path}" unless File.exist?(js_path) public_path = js_path.delete_prefix("public/") - asset_hosts.each do |asset_host| - upload_map( - token: token, - version: version, - minified_url: "#{asset_host}/#{public_path}", - map_path: map_path, - ) - end + upload_map( + token: token, + version: version, + minified_url: "https://dynamichost/#{public_path}", + map_path: map_path, + ) filename = public_path.delete_prefix("assets/dist/") progress = format("%#{progress_width}d", index + 1) puts " (#{progress} / #{map_paths.length}) #{filename}" From 8ece33b5d76b35c1de903b05cd063226a2677b61 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 20 Aug 2026 13:00:49 -0700 Subject: [PATCH 15/15] fix 3D popup and canvas bugs --- .../pin_bindings/__tests__/model_test.tsx | 27 +++++++++++-------- frontend/settings/pin_bindings/model.tsx | 17 ++++++++---- .../bot/components/electronics_box.tsx | 4 +-- .../selection/popup_controls.tsx | 5 ++-- frontend/tools/tool_slot_edit_components.tsx | 1 + 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/frontend/settings/pin_bindings/__tests__/model_test.tsx b/frontend/settings/pin_bindings/__tests__/model_test.tsx index 67bdcb0029..69ac2dc5ea 100644 --- a/frontend/settings/pin_bindings/__tests__/model_test.tsx +++ b/frontend/settings/pin_bindings/__tests__/model_test.tsx @@ -58,7 +58,6 @@ describe("", () => { beforeEach(() => { jest.useFakeTimers(); - document.body.style.cursor = "default"; reactUseRefSpy = jest.spyOn(ReactModule, "useRef") .mockImplementation(() => ({ current: { @@ -83,7 +82,6 @@ describe("", () => { afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); - document.body.style.cursor = "default"; reactUseRefSpy.mockRestore(); useFrameSpy.mockRestore(); fbSelectSpy.mockRestore(); @@ -155,12 +153,19 @@ describe("", () => { it("un-hovers button", () => { const e = fakeEvent(); - const wrapper = createRenderer(); + const setCanvasCursor = jest.fn(); + const wrapper = createRenderer(); const actionGroups = wrapper.root.findAll(node => node.props.name == "action-group"); + actRenderer(() => { + actionGroups[0]?.props.onPointerOver(e); + }); + expect(setCanvasCursor).toHaveBeenCalledWith("pointer"); actRenderer(() => { actionGroups[0]?.props.onPointerOut(e); }); expect(e.object.parent?.children[0].position.z).toEqual(131); + expect(setCanvasCursor).toHaveBeenCalledWith(""); }); it("resets z", () => { @@ -174,25 +179,25 @@ describe("", () => { }); it("changes cursor: bound", () => { - const wrapper = createRenderer(); - expect(document.body.style.cursor).toEqual("default"); + const setCanvasCursor = jest.fn(); + const wrapper = createRenderer(); const actionGroups = wrapper.root.findAll(node => node.props.name == "action-group"); actRenderer(() => { actionGroups[0]?.props.onPointerMove(); }); - expect(document.body.style.cursor).toEqual("pointer"); - document.body.style.cursor = "default"; + expect(setCanvasCursor).toHaveBeenCalledWith("pointer"); }); it("changes cursor: unbound", () => { - const wrapper = createRenderer(); - expect(document.body.style.cursor).toEqual("default"); + const setCanvasCursor = jest.fn(); + const wrapper = createRenderer(); const actionGroups = wrapper.root.findAll(node => node.props.name == "action-group"); actRenderer(() => { actionGroups[actionGroups.length - 1]?.props.onPointerMove(); }); - expect(document.body.style.cursor).toEqual("not-allowed"); - document.body.style.cursor = "default"; + expect(setCanvasCursor).toHaveBeenCalledWith("not-allowed"); }); it("renders: off", () => { diff --git a/frontend/settings/pin_bindings/model.tsx b/frontend/settings/pin_bindings/model.tsx index 242c1f4234..40cd329bf4 100644 --- a/frontend/settings/pin_bindings/model.tsx +++ b/frontend/settings/pin_bindings/model.tsx @@ -118,7 +118,11 @@ interface ButtonOrLedItem { ref?: React.RefObject; } -export const Model = (props: BoxTopBaseProps) => { +interface ModelProps extends BoxTopBaseProps { + setCanvasCursor?(cursor: string): void; +} + +export const Model = (props: ModelProps) => { const box = useGLTF(ASSETS.models.box, LIB_DIR) as unknown as Box; const btn = useGLTF(ASSETS.models.btn, LIB_DIR) as unknown as Btn; const led = useGLTF(ASSETS.models.led, LIB_DIR) as unknown as Led; @@ -258,7 +262,7 @@ export const Model = (props: BoxTopBaseProps) => { const leave = (e: ThreeEvent) => { setHovered(undefined); setZForAllInGroup(e, Z); - document.body.style.cursor = "default"; + props.setCanvasCursor?.(""); }; return @@ -299,7 +303,9 @@ export const Model = (props: BoxTopBaseProps) => { const isHovered = hovered == pinNumber; const click = debounce(clickBinding(pinNumber)); const setCursor = () => - document.body.style.cursor = binding ? "pointer" : "not-allowed"; + props.setCanvasCursor?.(binding + ? "pointer" + : "not-allowed"); const enter = () => { !props.isEditing && setHovered(pinNumber); setCursor(); @@ -388,9 +394,10 @@ export const Model = (props: BoxTopBaseProps) => { }; export const ElectronicsBoxModel = (props: BoxTopBaseProps) => { + const [cursor, setCanvasCursor] = React.useState(""); return
- - + +
; }; diff --git a/frontend/three_d_garden/bot/components/electronics_box.tsx b/frontend/three_d_garden/bot/components/electronics_box.tsx index fdd1b32918..3702b88396 100644 --- a/frontend/three_d_garden/bot/components/electronics_box.tsx +++ b/frontend/three_d_garden/bot/components/electronics_box.tsx @@ -144,7 +144,7 @@ const LedIndicators = () => { instanceColor={lightAttributes.instanceColor} instanceMatrix={lightAttributes.instanceMatrix}> - +
; }; @@ -179,7 +179,7 @@ const ButtonInstances = (props: { instanceColor={buttonAttributes.instanceColor} instanceMatrix={buttonAttributes.instanceMatrix}> - + { botOnline={props.botOnline} arduinoBusy={props.arduinoBusy} locked={!!props.bot?.hardware.informational_settings.locked} /> -
+
+