From d1bc4cfecc347256208f22e8ca100de69ef33749 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 6 Nov 2025 13:52:42 -0800 Subject: [PATCH 1/8] add soil moisture map to 3D garden --- frontend/__test_support__/three_d_mocks.tsx | 2 + .../__tests__/calculate_move_test.ts | 2 +- frontend/demo/lua_runner/stubs.ts | 2 +- .../__tests__/three_d_garden_map_test.tsx | 8 +- frontend/farm_designer/index.tsx | 2 + .../sensor_readings/sensor_readings_layer.tsx | 21 +++-- .../map/legend/garden_map_legend.tsx | 1 - frontend/farm_designer/three_d_garden_map.tsx | 8 +- frontend/settings/three_d_settings.tsx | 13 +-- .../__tests__/garden_model_test.tsx | 12 ++- .../__tests__/triangles_test.ts | 79 +++++++++++++++-- .../three_d_garden/bed/__tests__/bed_test.tsx | 3 +- frontend/three_d_garden/bed/bed.tsx | 85 ++++++++++++++----- frontend/three_d_garden/config.ts | 16 ++-- frontend/three_d_garden/config_overlays.tsx | 2 +- .../garden/__tests__/height_material_test.tsx | 18 ++++ .../__tests__/moisture_texture_test.tsx | 18 ++++ .../three_d_garden/garden/height_material.tsx | 56 ++++++++++++ .../garden/moisture_texture.tsx | 34 ++++++++ frontend/three_d_garden/garden_model.tsx | 36 +++++--- frontend/three_d_garden/index.tsx | 6 ++ frontend/three_d_garden/triangles.ts | 70 ++++++++++++--- 22 files changed, 414 insertions(+), 80 deletions(-) create mode 100644 frontend/three_d_garden/garden/__tests__/height_material_test.tsx create mode 100644 frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx create mode 100644 frontend/three_d_garden/garden/height_material.tsx create mode 100644 frontend/three_d_garden/garden/moisture_texture.tsx diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 9860b0ea78..a84b4d1e6c 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -64,6 +64,7 @@ jest.mock("@react-three/fiber", () => ({ pointer: { x: 0, y: 0 }, camera: new THREE.PerspectiveCamera(), })), + extend: jest.fn(), })); jest.mock("@react-spring/three", () => ({ @@ -559,6 +560,7 @@ jest.mock("@react-three/drei", () => { return { ...jest.requireActual("@react-three/drei"), useGLTF, + shaderMaterial: jest.fn(), RoundedBox: ({ name }: { name: string }) =>
{name}
, Plane: (props: React.ComponentProps) => diff --git a/frontend/demo/lua_runner/__tests__/calculate_move_test.ts b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts index a7020bc92a..600265d29d 100644 --- a/frontend/demo/lua_runner/__tests__/calculate_move_test.ts +++ b/frontend/demo/lua_runner/__tests__/calculate_move_test.ts @@ -420,7 +420,7 @@ describe("calculateMove()", () => { }); it("handles soil height z axis overwrite: triangle data", () => { - sessionStorage.setItem("triangles", "[\"foo\"]"); + sessionStorage.setItem("soilSurfaceTriangles", "[\"foo\"]"); const command: Move = { kind: "move", args: {}, diff --git a/frontend/demo/lua_runner/stubs.ts b/frontend/demo/lua_runner/stubs.ts index 23f2ade38d..0d3251b2ef 100644 --- a/frontend/demo/lua_runner/stubs.ts +++ b/frontend/demo/lua_runner/stubs.ts @@ -59,7 +59,7 @@ export const getSafeZ = (): number => { export const getSoilHeight = (x: number, y: number): number => { const triangles = JSON.parse( - sessionStorage.getItem("triangles") || "[]") as TriangleData[]; + sessionStorage.getItem("soilSurfaceTriangles") || "[]") as TriangleData[]; const getZ = getZFunc(triangles, -500); return getZ(x, y); }; 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 1f59c99af4..98612f4deb 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -20,7 +20,7 @@ import { fakePlant } from "../../__test_support__/fake_state/resources"; import { render } from "@testing-library/react"; import { ThreeDGarden } from "../../three_d_garden"; import { clone } from "lodash"; -import { INITIAL } from "../../three_d_garden/config"; +import { INITIAL, SurfaceDebugOption } from "../../three_d_garden/config"; import { FirmwareHardware } from "farmbot"; import { CROPS } from "../../crops/constants"; import { fakeDevice } from "../../__test_support__/resource_index_builder"; @@ -32,6 +32,8 @@ const EMPTY_PROPS = { allPoints: [], groups: [], images: [], + sensors: [], + sensorReadings: [], }; describe("", () => { @@ -56,6 +58,8 @@ describe("", () => { allPoints: [], groups: [], images: [], + sensors: [], + sensorReadings: [], cameraCalibrationData: fakeCameraCalibrationData(), }); @@ -97,7 +101,7 @@ describe("", () => { expectedConfig.cableDebug = true; expectedConfig.eventDebug = true; expectedConfig.lightsDebug = true; - expectedConfig.surfaceDebug = true; + expectedConfig.surfaceDebug = SurfaceDebugOption.normals; expectedConfig.lowDetail = true; expectedConfig.solar = true; expectedConfig.stats = true; diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index 7ad6c3a254..2037b67a95 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -236,6 +236,8 @@ export class RawFarmDesigner allPoints={this.props.allPoints} groups={this.props.groups} images={this.props.latestImages} + sensorReadings={this.props.sensorReadings} + sensors={this.props.sensors} cameraCalibrationData={this.props.cameraCalibrationData} getWebAppConfigValue={this.props.getConfigValue} /> :
{ + const sensorNameByPinLookup: { [x: number]: string } = {}; + sensors.map(x => { sensorNameByPinLookup[x.body.pin || 0] = x.body.label; }); + const readings = sensorReadings + .filter(r => + (sensorNameByPinLookup[r.body.pin] || "").toLowerCase().includes("soil") + && r.body.mode == ANALOG); + return { readings, sensorNameByPinLookup }; +}; + export interface SensorReadingsLayerProps { visible: boolean; overlayVisible: boolean; @@ -25,13 +38,9 @@ export function SensorReadingsLayer(props: SensorReadingsLayerProps) { visible, sensorReadings, mapTransformProps, timeSettings, sensors } = props; const mostRecentSensorReading = last(sensorReadings); - const sensorNameByPinLookup: { [x: number]: string } = {}; - sensors.map(x => { sensorNameByPinLookup[x.body.pin || 0] = x.body.label; }); const options = fetchInterpolationOptions(props.farmwareEnvs); - const moistureReadings = sensorReadings - .filter(r => - (sensorNameByPinLookup[r.body.pin] || "").toLowerCase().includes("soil") - && r.body.mode == ANALOG); + const { readings: moistureReadings, sensorNameByPinLookup } = + filterMoistureReadings(sensorReadings, sensors); generateData({ kind: "SensorReading", points: moistureReadings, mapTransformProps, getColor: getMoistureColor, diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index 2f61fd7262..f169e5739b 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -212,7 +212,6 @@ const LayerToggles = (props: LayerTogglesProps) => { onClick={toggle(BooleanSetting.show_sensor_readings)} />} {props.hasSensorReadings && { config.eventDebug = !!getValue("eventDebug"); config.cableDebug = !!getValue("cableDebug"); config.lightsDebug = !!getValue("lightsDebug"); - config.surfaceDebug = !!getValue("surfaceDebug"); + config.surfaceDebug = getValue("surfaceDebug"); config.sun = getValue("sun"); config.ambient = getValue("ambient"); config.heading = getValue("heading"); @@ -176,6 +178,8 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { allPoints={props.allPoints} groups={props.groups} images={props.images} + sensorReadings={props.sensorReadings} + sensors={props.sensors} addPlantProps={{ gridSize: props.mapTransformProps.gridSize, dispatch: props.dispatch, diff --git a/frontend/settings/three_d_settings.tsx b/frontend/settings/three_d_settings.tsx index b1a5cb1178..857a6c76ec 100644 --- a/frontend/settings/three_d_settings.tsx +++ b/frontend/settings/three_d_settings.tsx @@ -12,8 +12,9 @@ import { TaggedFarmwareEnv } from "farmbot"; import { isUndefined } from "lodash"; import { edit, initSave, save } from "../api/crud"; import { getModifiedClassNameSpecifyDefault } from "./default_values"; +import { Config, SurfaceDebugOption } from "../three_d_garden/config"; -const DEFAULTS: Record = { +const DEFAULTS: Partial> = { bedWallThickness: 40, bedHeight: 300, ccSupportSize: 50, @@ -38,7 +39,7 @@ const DEFAULTS: Record = { eventDebug: 0, cableDebug: 0, lightsDebug: 0, - surfaceDebug: 0, + surfaceDebug: SurfaceDebugOption.none, ambient: 75, sun: 75, heading: 0, @@ -69,7 +70,7 @@ const find = envs.filter(env => env.body.key == namespace3D(key))[0]; export const get3DConfigValueFunction = (envs: TaggedFarmwareEnv[]) => - (key: string): number => { + (key: keyof Config): number => { const maybe = find(envs, key); const raw = isUndefined(maybe) ? DEFAULTS[key] : maybe.body.value; return parseFloat("" + raw); @@ -77,7 +78,7 @@ export const get3DConfigValueFunction = (envs: TaggedFarmwareEnv[]) => export const findOrCreate3DConfigFunction = (dispatch: Function, envs: TaggedFarmwareEnv[]) => - (key: string, value: string) => { + (key: keyof Config, value: string) => { const maybe = find(envs, key); if (isUndefined(maybe)) { if (value != "" + DEFAULTS[key]) { @@ -93,7 +94,7 @@ interface ThreeDConfigProps { dispatch: Function; distanceIndicator?: string; setting: DeviceSetting; - configKey: string; + configKey: keyof Config; tooltip: string; getValue(key: string): number; findOrCreate(key: string, value: string): void; @@ -115,7 +116,7 @@ export const ThreeDConfig = (props: ThreeDConfigProps) => { dispatch({ type: Actions.SET_DISTANCE_INDICATOR, diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 04fd8f2143..3b506aaa50 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -9,7 +9,7 @@ import React from "react"; import { mount } from "enzyme"; import { GardenModelProps, GardenModel } from "../garden_model"; import { clone } from "lodash"; -import { INITIAL } from "../config"; +import { INITIAL, SurfaceDebugOption } from "../config"; import { render, screen } from "@testing-library/react"; import { fakePlant, fakePoint, fakeWeed, @@ -108,7 +108,7 @@ describe("", () => { p.config.viewCube = true; p.config.lab = true; p.config.lightsDebug = true; - p.config.surfaceDebug = true; + p.config.surfaceDebug = SurfaceDebugOption.normals; p.activeFocus = "plant"; p.addPlantProps = undefined; const { container } = render(); @@ -116,6 +116,14 @@ describe("", () => { expect(container).toContainHTML("stats"); }); + it("renders debug options", () => { + mockIsDesktop = false; + const p = fakeProps(); + p.config.surfaceDebug = SurfaceDebugOption.height; + const { container } = render(); + expect(container).toContainHTML("gray"); + }); + it("sets hover", () => { const p = fakeProps(); p.config.labelsOnHover = true; diff --git a/frontend/three_d_garden/__tests__/triangles_test.ts b/frontend/three_d_garden/__tests__/triangles_test.ts index 319eeac646..e42f956ce7 100644 --- a/frontend/three_d_garden/__tests__/triangles_test.ts +++ b/frontend/three_d_garden/__tests__/triangles_test.ts @@ -1,7 +1,15 @@ -import { computeSurface } from "../triangles"; +import { + computeSurface, + filterMoisturePoints, + FilterMoisturePointsProps, + filterSoilPoints, + FilterSoilPointsProps, +} from "../triangles"; import { INITIAL } from "../config"; import { clone } from "lodash"; -import { fakePoint } from "../../__test_support__/fake_state/resources"; +import { + fakePoint, fakeSensor, fakeSensorReading, +} from "../../__test_support__/fake_state/resources"; import { tagAsSoilHeight } from "../../points/soil_height"; const zs = (items: [number, number, number][]) => items.map(i => i[2]); @@ -12,7 +20,8 @@ describe("computeSurface()", () => { config.soilHeight = 300; config.columnLength = 1000; config.bedHeight = 1; - const { vertexList } = computeSurface([], config); + const pts = filterSoilPoints({ points: [], config }); + const { vertexList } = computeSurface(pts); expect(zs(vertexList)).toEqual([-900, -900, -900, -900, -300, -300]); }); @@ -21,14 +30,16 @@ describe("computeSurface()", () => { config.soilHeight = 500; config.columnLength = 100; config.bedHeight = 100; - const { vertexList } = computeSurface([], config); + const pts = filterSoilPoints({ points: [], config }); + const { vertexList } = computeSurface(pts); expect(zs(vertexList)).toEqual([-100, -100, -100, -100, -500, -500]); }); it("computes surface: no soil points", () => { const config = clone(INITIAL); config.soilHeight = 500; - const { vertexList } = computeSurface(undefined, config); + const pts = filterSoilPoints({ points: undefined, config }); + const { vertexList } = computeSurface(pts); expect(zs(vertexList)).toEqual([-500, -500, -500, -500, -500, -500]); }); @@ -46,7 +57,8 @@ describe("computeSurface()", () => { const soilPoints = [point0, point1]; const config = clone(INITIAL); config.soilHeight = 500; - const { vertexList } = computeSurface(soilPoints, config); + const pts = filterSoilPoints({ points: soilPoints, config }); + const { vertexList } = computeSurface(pts); expect(zs(vertexList)).toEqual([-600, 0, -500, -500, -500, -500]); }); @@ -66,7 +78,60 @@ describe("computeSurface()", () => { config.soilHeight = 500; config.exaggeratedZ = true; config.perspective = true; - const { vertexList } = computeSurface(soilPoints, config); + const pts = filterSoilPoints({ points: soilPoints, config }); + const { vertexList } = computeSurface(pts); expect(zs(vertexList)).toEqual([-1500, 4500, -500, -500, -500, -500]); }); }); + +describe("filterSoilPoints()", () => { + const fakeProps = (): FilterSoilPointsProps => ({ + config: clone(INITIAL), + points: [], + }); + + it("filters points", () => { + const p = fakeProps(); + const point0 = fakePoint(); + point0.body.x = -1000; + tagAsSoilHeight(point0); + const point1 = fakePoint(); + point1.body.x = 1000; + tagAsSoilHeight(point1); + p.points = [point0, point1]; + expect(filterSoilPoints(p).length).toEqual(5); + }); +}); + +describe("filterMoisturePoints()", () => { + const fakeProps = (): FilterMoisturePointsProps => ({ + config: clone(INITIAL), + sensors: [], + readings: [], + }); + + it("filters points", () => { + const p = fakeProps(); + const sensor = fakeSensor(); + sensor.body.label = "soil moisture"; + sensor.body.id = 1; + p.sensors = [sensor]; + const reading0 = fakeSensorReading(); + reading0.body.pin = 1; + reading0.body.x = 0; + reading0.body.y = 0; + reading0.body.mode = 1; + const reading1 = fakeSensorReading(); + reading1.body.pin = 1; + reading1.body.x = undefined; + reading1.body.y = 0; + reading1.body.mode = 1; + const reading2 = fakeSensorReading(); + reading2.body.pin = 2; + reading2.body.x = 0; + reading2.body.y = 0; + reading2.body.mode = 1; + p.readings = [reading0, reading1, reading2]; + expect(filterMoisturePoints(p).length).toEqual(9); + }); +}); diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 5c5cf43c68..a347f329d1 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -99,7 +99,8 @@ describe("", () => { config: clone(INITIAL), activeFocus: "", mapPoints: [], - geometry: new BufferGeometry(), + soilSurfaceGeometry: new BufferGeometry(), + moistureSurfaceGeometry: new BufferGeometry(), getZ: () => 0, }); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 5309a21196..79bb49172d 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -10,16 +10,21 @@ import { BufferGeometry, Mesh as MeshType, BackSide, + Color, } from "three"; import { range } from "lodash"; import { threeSpace, getColorFromBrightness, zZero } from "../helpers"; -import { Config, detailLevels } from "../config"; +import { Config, detailLevels, SurfaceDebugOption } from "../config"; import { ASSETS } from "../constants"; import { DistanceIndicator } from "../elements"; import { FarmbotAxes, Caster, UtilitiesPost, Packaging } from "./objects"; -import { Group, Mesh, MeshNormalMaterial, MeshPhongMaterial } from "../components"; +import { + Group, Mesh, MeshNormalMaterial, MeshPhongMaterial, +} from "../components"; import { AxisNumberProperty } from "../../farm_designer/map/interfaces"; -import { TaggedCurve, TaggedGenericPointer, TaggedImage } from "farmbot"; +import { + TaggedCurve, TaggedGenericPointer, TaggedImage, +} from "farmbot"; import { GetWebAppConfigValue } from "../../config_storage/actions"; import { DesignerState } from "../../farm_designer/interfaces"; import { useNavigate } from "react-router"; @@ -34,6 +39,9 @@ import { import { ThreeElements } from "@react-three/fiber"; import { ImageTexture } from "../garden"; import { VertexNormalsHelper } from "three/examples/jsm/Addons"; +import { BooleanSetting } from "../../session_keys"; +import { MoistureTexture } from "../garden/moisture_texture"; +import { HeightMaterial } from "../garden/height_material"; const soil = ( Type: typeof LinePath | typeof Shape, @@ -99,7 +107,8 @@ export interface BedProps { addPlantProps?: AddPlantProps; getZ(x: number, y: number): number; images?: TaggedImage[]; - geometry: BufferGeometry; + soilSurfaceGeometry: BufferGeometry; + moistureSurfaceGeometry: BufferGeometry; } export const Bed = (props: BedProps) => { @@ -209,7 +218,7 @@ export const Bed = (props: BedProps) => { castShadow={true} receiveShadow={true} config={props.config} - geometry={props.geometry} + geometry={props.soilSurfaceGeometry} position={[ threeSpace(0, bedLengthOuter) + bedXOffset, threeSpace(0, bedWidthOuter) + bedYOffset, @@ -234,9 +243,39 @@ export const Bed = (props: BedProps) => { z={0} />, [props.images, props.config, props.addPlantProps]); - const SoilMaterial = props.config.surfaceDebug - ? MeshNormalMaterial - : MeshPhongMaterial; + const moistureVisible = !!props.addPlantProps?.getConfigValue( + BooleanSetting.show_moisture_interpolation_map); + + const moistureTexture = React.useMemo(() => + , [ + props.config, + props.moistureSurfaceGeometry, + ]); + + const SurfaceHeightMaterial = (props: { children: React.ReactNode }) => + ; + + const getSurfaceMaterial = () => { + switch (props.config.surfaceDebug) { + case SurfaceDebugOption.normals: + return MeshNormalMaterial; + case SurfaceDebugOption.height: + return SurfaceHeightMaterial; + default: + return MeshPhongMaterial; + } + }; + + const SurfaceMaterial = getSurfaceMaterial(); + const surfaceTexture = moistureVisible + ? moistureTexture + : soilTexture; return @@ -333,20 +372,22 @@ export const Bed = (props: BedProps) => { config={props.config} addPlantProps={props.addPlantProps} mapPoints={props.mapPoints} />} - - - - {soilTexture} - - - - - - + + + + + {surfaceTexture} + + + + + + + {legXPositions.map((x, index) => {legYPositions(index).map(y => diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index 4e7fe97e2e..5410ccecd0 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -74,7 +74,7 @@ export interface Config { cableDebug: boolean; zoomBeaconDebug: boolean; lightsDebug: boolean; - surfaceDebug: boolean; + surfaceDebug: number; sun: number; ambient: number; animate: boolean; @@ -99,6 +99,12 @@ export interface Config { imgCenterY: number; } +export enum SurfaceDebugOption { + none, + normals, + height, +} + export const INITIAL: Config = { sizePreset: "Genesis", bedType: "Standard", @@ -175,7 +181,7 @@ export const INITIAL: Config = { cableDebug: false, zoomBeaconDebug: false, lightsDebug: false, - surfaceDebug: false, + surfaceDebug: SurfaceDebugOption.none, sun: 75, ambient: 75, animate: true, @@ -213,7 +219,7 @@ export const NUMBER_KEYS = [ "soilBrightness", "soilHeight", "sunInclination", "sunAzimuth", "heading", "soilSurfacePointCount", "soilSurfaceVariance", "sun", "ambient", "rotary", "imgScale", "imgRotation", "imgOffsetX", "imgOffsetY", "imgCalZ", - "imgCenterX", "imgCenterY", + "imgCenterX", "imgCenterY", "surfaceDebug", ]; export const BOOLEAN_KEYS = [ @@ -222,7 +228,7 @@ export const BOOLEAN_KEYS = [ "viewCube", "stats", "config", "zoom", "pan", "rotate", "bounds", "threeAxes", "xyDimensions", "zDimension", "promoInfo", "settingsBar", "zoomBeacons", "solar", "utilitiesPost", "packaging", "lab", "people", "lowDetail", - "eventDebug", "cableDebug", "zoomBeaconDebug", "lightsDebug", "surfaceDebug", + "eventDebug", "cableDebug", "zoomBeaconDebug", "lightsDebug", "animate", "animateSeasons", "negativeZ", "waterFlow", "exaggeratedZ", "showSoilPoints", "urlParamAutoAdd", "light", "vacuum", "north", "desk", @@ -410,7 +416,7 @@ export const PRESETS: Record = { cableDebug: true, zoomBeaconDebug: true, lightsDebug: true, - surfaceDebug: true, + surfaceDebug: SurfaceDebugOption.normals, animate: true, animateSeasons: false, distanceIndicator: "", diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 479426d260..44bbe56130 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -392,7 +392,7 @@ export const PrivateOverlay = (props: OverlayProps) => { - + diff --git a/frontend/three_d_garden/garden/__tests__/height_material_test.tsx b/frontend/three_d_garden/garden/__tests__/height_material_test.tsx new file mode 100644 index 0000000000..747e1c0550 --- /dev/null +++ b/frontend/three_d_garden/garden/__tests__/height_material_test.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { HeightMaterial, HeightMaterialProps } from "../height_material"; +import { Color } from "three"; + +describe("", () => { + const fakeProps = (): HeightMaterialProps => ({ + min: 0, + max: 1, + lowColor: new Color(0, 0, 0), + highColor: new Color(1, 0, 0), + }); + + it("renders", () => { + const { container } = render(); + expect(container).toContainHTML("heightshadermaterial"); + }); +}); diff --git a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx new file mode 100644 index 0000000000..16573a95ef --- /dev/null +++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { MoistureTexture, MoistureTextureProps } from "../moisture_texture"; +import { clone } from "lodash"; +import { INITIAL } from "../../config"; +import { BufferGeometry } from "three"; + +describe("", () => { + const fakeProps = (): MoistureTextureProps => ({ + config: clone(INITIAL), + geometry: new BufferGeometry(), + }); + + it("renders", () => { + const { container } = render(); + expect(container).toContainHTML("render-texture"); + }); +}); diff --git a/frontend/three_d_garden/garden/height_material.tsx b/frontend/three_d_garden/garden/height_material.tsx new file mode 100644 index 0000000000..36ad2c8e64 --- /dev/null +++ b/frontend/three_d_garden/garden/height_material.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { shaderMaterial } from "@react-three/drei"; +import { extend } from "@react-three/fiber"; +import { Color, DoubleSide } from "three"; + +const HeightShaderMaterial = shaderMaterial( + { + uZMin: -1, + uZMax: 1, + uLowColor: new Color(0, 0, 1), + uHighColor: new Color(1, 0, 0), + }, + ` + varying float vZ; + + void main() { + vec4 worldPos = modelMatrix * vec4(position, 1.0); + vZ = worldPos.z; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + ` + uniform float uZMin; + uniform float uZMax; + uniform vec3 uLowColor; + uniform vec3 uHighColor; + varying float vZ; + + void main() { + float t = smoothstep(uZMin, uZMax, vZ); + + vec3 color = mix(uLowColor, uHighColor, t); + gl_FragColor = vec4(color, 1.0); + } + `, +); + +extend({ HeightShaderMaterial }); + +export interface HeightMaterialProps { + children?: React.ReactNode; + min: number; + max: number; + lowColor: Color; + highColor: Color; +} + +export const HeightMaterial = (props: HeightMaterialProps) => + // @ts-expect-error Property does not exist on type JSX.IntrinsicElements + ; diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx new file mode 100644 index 0000000000..7d36604dc0 --- /dev/null +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { soilSurfaceExtents } from "../triangles"; +import { Config } from "../config"; +import { OrthographicCamera, RenderTexture } from "@react-three/drei"; +import { Mesh } from "../components"; +import { BufferGeometry, Color } from "three"; +import { HeightMaterial } from "./height_material"; + +export interface MoistureTextureProps { + config: Config; + geometry: BufferGeometry; +} + +export const MoistureTexture = (props: MoistureTextureProps) => { + const extents = soilSurfaceExtents(props.config); + const width = extents.x.max - extents.x.min; + const height = extents.y.max - extents.y.min; + return + + + + + ; +}; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 4b8da258e4..b0bcdf659e 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -27,15 +27,17 @@ import { import { ICON_URLS } from "../crops/constants"; import { TaggedGenericPointer, TaggedImage, TaggedPoint, TaggedPointGroup, + TaggedSensor, + TaggedSensorReading, TaggedWeedPointer, } from "farmbot"; import { BooleanSetting } from "../session_keys"; import { SlotWithTool } from "../resources/interfaces"; import { cameraInit } from "./camera"; import { isMobile } from "../screen_size"; -import { computeSurface, getGeometry } from "./triangles"; +import { filterMoisturePoints, filterSoilPoints, getSurface } from "./triangles"; import { BigDistance } from "./constants"; -import { precomputeTriangles, getZFunc } from "./triangle_functions"; +import { getZFunc } from "./triangle_functions"; import { Visualization } from "./visualization"; import { GroupOrderVisual } from "./group_order_visual"; @@ -55,6 +57,8 @@ export interface GardenModelProps { allPoints?: TaggedPoint[]; groups?: TaggedPointGroup[]; images?: TaggedImage[]; + sensorReadings?: TaggedSensorReading[]; + sensors?: TaggedSensor[]; } // eslint-disable-next-line complexity @@ -100,16 +104,23 @@ export const GardenModel = (props: GardenModelProps) => { || !!addPlantProps?.getConfigValue(BooleanSetting.show_points); const showWeeds = !!addPlantProps?.getConfigValue(BooleanSetting.show_weeds); - const { vertices, vertexList, uvs, faces } = React.useMemo(() => - computeSurface(props.mapPoints, config), [props.mapPoints, config]); - const geometry = React.useMemo(() => - getGeometry(vertices, uvs), [vertices, uvs]); - const triangles = React.useMemo(() => - precomputeTriangles(vertexList, faces), [vertexList, faces]); + const soilPoints = filterSoilPoints({ points: props.mapPoints, config }); + const soilSurface = React.useMemo(() => + getSurface(soilPoints), [soilPoints]); React.useEffect(() => { - sessionStorage.setItem("triangles", JSON.stringify(triangles)); - }, [triangles]); - const getZ = getZFunc(triangles, -config.soilHeight); + sessionStorage.setItem("soilSurfaceTriangles", + JSON.stringify(soilSurface.triangles)); + }, [soilSurface.triangles]); + const getZ = getZFunc(soilSurface.triangles, -config.soilHeight); + + const moisturePoints = + filterMoisturePoints({ + config: props.config, + sensors: props.sensors || [], + readings: props.sensorReadings || [], + }); + const moistureSurface = React.useMemo(() => + getSurface(moisturePoints), [moisturePoints]); // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); @@ -160,11 +171,12 @@ export const GardenModel = (props: GardenModelProps) => { {showFarmbot && { @@ -62,6 +66,8 @@ export const ThreeDGarden = (props: ThreeDGardenProps) => { allPoints={props.allPoints} groups={props.groups} images={props.images} + sensorReadings={props.sensorReadings} + sensors={props.sensors} addPlantProps={props.addPlantProps} /> diff --git a/frontend/three_d_garden/triangles.ts b/frontend/three_d_garden/triangles.ts index 45d396fa97..8104cd9c3a 100644 --- a/frontend/three_d_garden/triangles.ts +++ b/frontend/three_d_garden/triangles.ts @@ -1,9 +1,41 @@ import Delaunator from "delaunator"; -import { TaggedGenericPointer } from "farmbot"; +import { TaggedGenericPointer, TaggedSensor, TaggedSensorReading } from "farmbot"; import { Config } from "./config"; import { soilHeightPoint } from "../points/soil_height"; import { zZero } from "./helpers"; import { BufferGeometry, Float32BufferAttribute } from "three"; +import { precomputeTriangles } from "./triangle_functions"; +import { filterMoistureReadings } from "../farm_designer/map/layers"; +import { selectMostRecentPoints } from "../farm_designer/location_info"; +import { isUndefined } from "lodash"; + +export interface FilterMoisturePointsProps { + config: Config; + sensors: TaggedSensor[]; + readings: TaggedSensorReading[]; +} + +export const filterMoisturePoints = (props: FilterMoisturePointsProps) => { + const { readings: moistureReadings } = + filterMoistureReadings(props.readings, props.sensors); + const recentReadings = selectMostRecentPoints(moistureReadings); + const moisturePoints = recentReadings + .filter(p => + !isUndefined(p.body.x) && + !isUndefined(p.body.y)) + .map(p => [p.body.x, p.body.y, p.body.value]) as [number, number, number][]; + const params = soilSurfaceExtents(props.config); + const outerPoints = [ + { x: params.x.min, y: params.y.min }, + { x: params.x.min, y: params.y.max }, + { x: params.x.max, y: params.y.min }, + { x: params.x.max, y: params.y.max }, + ]; + [...outerPoints, ...outerPoints].map(p => { + moisturePoints.push([p.x, p.y, 0]); + }); + return moisturePoints; +}; export const soilSurfaceExtents = (config: Config) => ({ x: { @@ -16,10 +48,13 @@ export const soilSurfaceExtents = (config: Config) => ({ }, }); -export const computeSurface = ( - mapPoints: TaggedGenericPointer[] | undefined, - config: Config, -) => { +export interface FilterSoilPointsProps { + config: Config; + points: TaggedGenericPointer[] | undefined; +} + +export const filterSoilPoints = (props: FilterSoilPointsProps) => { + const { config } = props; const outerBoundaryParams = soilSurfaceExtents(config); const boundaryParams = { outer: outerBoundaryParams, @@ -35,7 +70,7 @@ export const computeSurface = ( }, }; - const soilHeightPoints = (mapPoints || []) + const soilHeightPoints: [number, number, number][] = (props.points || []) .filter(p => soilHeightPoint(p) && p.body.x > boundaryParams.outer.x.min && p.body.x < boundaryParams.outer.x.max && @@ -81,17 +116,21 @@ export const computeSurface = ( }); }); - const soilPoints = soilHeightPoints; + return soilHeightPoints; +}; - const projected2D = soilPoints.map(([x, y, _z]) => [x, y]); +export const computeSurface = ( + points: [number, number, number][], +) => { + const projected2D = points.map(([x, y, _z]) => [x, y]); const delaunay = Delaunator.from(projected2D); const triangles = delaunay.triangles; const vertices: number[] = []; const vertexList: [number, number, number][] = []; const faces: number[] = []; const uvs: number[] = []; - const xs = soilPoints.map(p => p[0]); - const ys = soilPoints.map(p => p[1]); + const xs = points.map(p => p[0]); + const ys = points.map(p => p[1]); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); @@ -102,7 +141,7 @@ export const computeSurface = ( for (let j = 0; j < 3; j++) { const index: number = triangles[i + j]; faces.push(i + j); - const [x, y, z] = soilPoints[index]; + const [x, y, z] = points[index]; vertices.push(x, y, z); vertexList.push([x, y, z]); const u = (x - minX) / width; @@ -128,3 +167,12 @@ export const getGeometry = (vertices: number[], uvs: number[]) => { } return geom; }; + +export const getSurface = ( + points: [number, number, number][], +) => { + const { vertices, vertexList, uvs, faces } = computeSurface(points); + const geometry = getGeometry(vertices, uvs); + const triangles = precomputeTriangles(vertexList, faces); + return { geometry, triangles }; +}; From aa9ae9180df1b91a361a85a8140d1ca36205f146 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 7 Nov 2025 16:43:42 -0800 Subject: [PATCH 2/8] fix 3D soil moisture map --- .../__tests__/three_d_garden_map_test.tsx | 1 + frontend/farm_designer/three_d_garden_map.tsx | 1 + .../dev/__tests__/dev_settings_test.tsx | 62 ++++++++++++++ frontend/settings/dev/dev_settings.tsx | 33 ++++++++ frontend/settings/three_d_settings.tsx | 1 + .../__tests__/garden_model_test.tsx | 30 ++++++- .../three_d_garden/bed/__tests__/bed_test.tsx | 2 + frontend/three_d_garden/bed/bed.tsx | 27 ++++-- frontend/three_d_garden/config.ts | 7 +- frontend/three_d_garden/config_overlays.tsx | 1 + .../__tests__/moisture_texture_test.tsx | 1 + .../garden/moisture_texture.tsx | 84 +++++++++++++++++-- frontend/three_d_garden/garden_model.tsx | 13 +++ frontend/three_d_garden/triangles.ts | 38 +++++---- 14 files changed, 271 insertions(+), 30 deletions(-) diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index 98612f4deb..762504481a 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -101,6 +101,7 @@ describe("", () => { expectedConfig.cableDebug = true; expectedConfig.eventDebug = true; expectedConfig.lightsDebug = true; + expectedConfig.moistureDebug = true; expectedConfig.surfaceDebug = SurfaceDebugOption.normals; expectedConfig.lowDetail = true; expectedConfig.solar = true; diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 487a5319f8..6990b0d94f 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -106,6 +106,7 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { config.eventDebug = !!getValue("eventDebug"); config.cableDebug = !!getValue("cableDebug"); config.lightsDebug = !!getValue("lightsDebug"); + config.moistureDebug = !!getValue("moistureDebug"); config.surfaceDebug = getValue("surfaceDebug"); config.sun = getValue("sun"); config.ambient = getValue("ambient"); diff --git a/frontend/settings/dev/__tests__/dev_settings_test.tsx b/frontend/settings/dev/__tests__/dev_settings_test.tsx index 0f0234a1b6..4f89a481f2 100644 --- a/frontend/settings/dev/__tests__/dev_settings_test.tsx +++ b/frontend/settings/dev/__tests__/dev_settings_test.tsx @@ -4,6 +4,21 @@ jest.mock("../../../config_storage/actions", () => ({ getWebAppConfigValue: jest.fn(() => () => JSON.stringify(mockDevSettings)), })); +jest.mock("../../../api/crud", () => ({ + initSave: jest.fn(), + edit: jest.fn(), + save: jest.fn(), +})); + +import { fakeState } from "../../../__test_support__/fake_state"; +const mockState = fakeState(); +jest.mock("../../../redux/store", () => ({ + store: { + dispatch: jest.fn(), + getState: () => mockState, + } +})); + import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import { mount, shallow } from "enzyme"; @@ -13,9 +28,15 @@ import { DevWidget3dCameraRow, DevWidgetAllOrderOptionsRow, DevWidgetChunkingDisabledRow, + Dev3dDebugSettings, } from "../dev_settings"; import { DevSettings } from "../dev_support"; import { setWebAppConfigValue } from "../../../config_storage/actions"; +import { edit, initSave, save } from "../../../api/crud"; +import { + buildResourceIndex, +} from "../../../__test_support__/resource_index_builder"; +import { fakeFarmwareEnv } from "../../../__test_support__/fake_state/resources"; describe("", () => { it("changes override value", () => { @@ -160,3 +181,44 @@ describe("", () => { localStorage.removeItem("DISABLE_CHUNKING"); }); }); + +describe("", () => { + it("adds env", () => { + mockState.resources = buildResourceIndex([]); + render(); + const toggle = screen.getAllByText("no")[0]; + fireEvent.click(toggle); + expect(initSave).toHaveBeenCalledWith("FarmwareEnv", { + key: "3D_eventDebug", + value: 1, + }); + expect(edit).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + }); + + it("edits env", () => { + const env = fakeFarmwareEnv(); + env.body.key = "3D_eventDebug"; + env.body.value = 0; + mockState.resources = buildResourceIndex([env]); + render(); + const toggle = screen.getAllByText("no")[0]; + fireEvent.click(toggle); + expect(initSave).not.toHaveBeenCalled(); + expect(edit).toHaveBeenCalledWith(env, { value: 1 }); + expect(save).toHaveBeenCalled(); + }); + + it("turns off setting", () => { + const env = fakeFarmwareEnv(); + env.body.key = "3D_eventDebug"; + env.body.value = 1; + mockState.resources = buildResourceIndex([env]); + render(); + const toggle = screen.getAllByText("yes")[0]; + fireEvent.click(toggle); + expect(initSave).not.toHaveBeenCalled(); + expect(edit).toHaveBeenCalledWith(env, { value: 0 }); + expect(save).toHaveBeenCalled(); + }); +}); diff --git a/frontend/settings/dev/dev_settings.tsx b/frontend/settings/dev/dev_settings.tsx index 472da3655e..698144b706 100644 --- a/frontend/settings/dev/dev_settings.tsx +++ b/frontend/settings/dev/dev_settings.tsx @@ -2,6 +2,9 @@ import React from "react"; import { Row, BlurableInput, ToggleButton } from "../../ui"; import { DevSettings } from "./dev_support"; import { store } from "../../redux/store"; +import { INITIAL } from "../../three_d_garden/config"; +import { edit, initSave, save } from "../../api/crud"; +import { selectAllFarmwareEnvs } from "../../resources/selectors_by_kind"; export const DevWidgetFERow = () => @@ -102,6 +105,35 @@ export const DevWidgetChunkingDisabledRow = () => : () => localStorage.setItem("DISABLE_CHUNKING", "true")} /> ; +export const Dev3dDebugSettings = () => { + const dispatch = store.dispatch as Function; + const farmwareEnvs = selectAllFarmwareEnvs(store.getState().resources.index); + return <> + {Object.keys(INITIAL) + .filter(key => key.includes("Debug")) + .map(key => "3D_" + key) + .map(key => { + const farmwareEnv = farmwareEnvs.filter(e => e.body.key == key)[0]; + const value = farmwareEnv?.body.value ? 1 : 0; + return + + { + if (farmwareEnv) { + dispatch(edit(farmwareEnv, { value: value == 0 ? 1 : 0 })); + dispatch(save(farmwareEnv.uuid)); + } else { + dispatch(initSave("FarmwareEnv", { key, value: 1 })); + } + }} /> + ; + })} + ; +}; + export const DevSettingsRows = () =>
@@ -111,5 +143,6 @@ export const DevSettingsRows = () => +

Demo Queue Length: {store.getState().bot.demoQueueLength}

; diff --git a/frontend/settings/three_d_settings.tsx b/frontend/settings/three_d_settings.tsx index 857a6c76ec..d3f0e300d3 100644 --- a/frontend/settings/three_d_settings.tsx +++ b/frontend/settings/three_d_settings.tsx @@ -39,6 +39,7 @@ const DEFAULTS: Partial> = { eventDebug: 0, cableDebug: 0, lightsDebug: 0, + moistureDebug: 0, surfaceDebug: SurfaceDebugOption.none, ambient: 75, sun: 75, diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 3b506aaa50..e060040669 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -12,7 +12,7 @@ import { clone } from "lodash"; import { INITIAL, SurfaceDebugOption } from "../config"; import { render, screen } from "@testing-library/react"; import { - fakePlant, fakePoint, fakeWeed, + fakePlant, fakePoint, fakeSensor, fakeSensorReading, fakeWeed, } from "../../__test_support__/fake_state/resources"; import { fakeAddPlantProps } from "../../__test_support__/fake_props"; import { ASSETS } from "../constants"; @@ -109,6 +109,7 @@ describe("", () => { p.config.lab = true; p.config.lightsDebug = true; p.config.surfaceDebug = SurfaceDebugOption.normals; + p.config.moistureDebug = true; p.activeFocus = "plant"; p.addPlantProps = undefined; const { container } = render(); @@ -119,7 +120,34 @@ describe("", () => { it("renders debug options", () => { mockIsDesktop = false; const p = fakeProps(); + const sensor = fakeSensor(); + sensor.body.id = 1; + sensor.body.label = "soil moisture"; + p.sensors = [sensor]; + const reading0 = fakeSensorReading(); + reading0.body.pin = 1; + reading0.body.x = 100; + reading0.body.y = 100; + reading0.body.z = 100; + reading0.body.value = 1000; + const reading1 = fakeSensorReading(); + reading1.body.pin = 1; + reading1.body.x = 0; + reading1.body.y = 0; + reading1.body.z = 0; + reading1.body.value = 1000; + p.sensorReadings = [reading0, reading1]; p.config.surfaceDebug = SurfaceDebugOption.height; + p.config.moistureDebug = true; + const { container } = render(); + expect(container).toContainHTML("gray"); + }); + + it("renders without sensor readings", () => { + mockIsDesktop = false; + const p = fakeProps(); + p.sensorReadings = undefined; + p.config.moistureDebug = true; const { container } = render(); expect(container).toContainHTML("gray"); }); diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index a347f329d1..fa442b0618 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -102,6 +102,8 @@ describe("", () => { soilSurfaceGeometry: new BufferGeometry(), moistureSurfaceGeometry: new BufferGeometry(), getZ: () => 0, + showMoistureMap: true, + sensorReadings: [], }); it("renders bed", () => { diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 79bb49172d..2c8a747044 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -24,6 +24,7 @@ import { import { AxisNumberProperty } from "../../farm_designer/map/interfaces"; import { TaggedCurve, TaggedGenericPointer, TaggedImage, + TaggedSensorReading, } from "farmbot"; import { GetWebAppConfigValue } from "../../config_storage/actions"; import { DesignerState } from "../../farm_designer/interfaces"; @@ -39,8 +40,7 @@ import { import { ThreeElements } from "@react-three/fiber"; import { ImageTexture } from "../garden"; import { VertexNormalsHelper } from "three/examples/jsm/Addons"; -import { BooleanSetting } from "../../session_keys"; -import { MoistureTexture } from "../garden/moisture_texture"; +import { MoistureSurface, MoistureTexture } from "../garden/moisture_texture"; import { HeightMaterial } from "../garden/height_material"; const soil = ( @@ -109,6 +109,8 @@ export interface BedProps { images?: TaggedImage[]; soilSurfaceGeometry: BufferGeometry; moistureSurfaceGeometry: BufferGeometry; + showMoistureMap: boolean; + sensorReadings: TaggedSensorReading[]; } export const Bed = (props: BedProps) => { @@ -243,14 +245,13 @@ export const Bed = (props: BedProps) => { z={0} />, [props.images, props.config, props.addPlantProps]); - const moistureVisible = !!props.addPlantProps?.getConfigValue( - BooleanSetting.show_moisture_interpolation_map); - const moistureTexture = React.useMemo(() => , [ props.config, + props.sensorReadings, props.moistureSurfaceGeometry, ]); @@ -273,7 +274,7 @@ export const Bed = (props: BedProps) => { }; const SurfaceMaterial = getSurfaceMaterial(); - const surfaceTexture = moistureVisible + const surfaceTexture = props.showMoistureMap ? moistureTexture : soilTexture; @@ -388,6 +389,20 @@ export const Bed = (props: BedProps) => {
+ {props.config.moistureDebug && + } {legXPositions.map((x, index) => {legYPositions(index).map(y => diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index 5410ccecd0..000f69bd39 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -74,6 +74,7 @@ export interface Config { cableDebug: boolean; zoomBeaconDebug: boolean; lightsDebug: boolean; + moistureDebug: boolean; surfaceDebug: number; sun: number; ambient: number; @@ -181,6 +182,7 @@ export const INITIAL: Config = { cableDebug: false, zoomBeaconDebug: false, lightsDebug: false, + moistureDebug: false, surfaceDebug: SurfaceDebugOption.none, sun: 75, ambient: 75, @@ -228,7 +230,7 @@ export const BOOLEAN_KEYS = [ "viewCube", "stats", "config", "zoom", "pan", "rotate", "bounds", "threeAxes", "xyDimensions", "zDimension", "promoInfo", "settingsBar", "zoomBeacons", "solar", "utilitiesPost", "packaging", "lab", "people", "lowDetail", - "eventDebug", "cableDebug", "zoomBeaconDebug", "lightsDebug", + "eventDebug", "cableDebug", "zoomBeaconDebug", "lightsDebug", "moistureDebug", "animate", "animateSeasons", "negativeZ", "waterFlow", "exaggeratedZ", "showSoilPoints", "urlParamAutoAdd", "light", "vacuum", "north", "desk", @@ -416,6 +418,7 @@ export const PRESETS: Record = { cableDebug: true, zoomBeaconDebug: true, lightsDebug: true, + moistureDebug: true, surfaceDebug: SurfaceDebugOption.normals, animate: true, animateSeasons: false, @@ -447,7 +450,7 @@ const OTHER_CONFIG_KEYS: (keyof Config)[] = [ "threeAxes", "xyDimensions", "zDimension", "labelsOnHover", "promoInfo", "settingsBar", "zoomBeacons", "pan", "rotate", "solar", "utilitiesPost", "packaging", "lab", - "people", "scene", "lowDetail", "sun", "ambient", + "people", "scene", "lowDetail", "sun", "ambient", "moistureDebug", "eventDebug", "cableDebug", "zoomBeaconDebug", "lightsDebug", "surfaceDebug", "animate", "distanceIndicator", "kitVersion", "negativeZ", "waterFlow", "light", "vacuum", "rotary", "animateSeasons", diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx index 44bbe56130..1f9f282815 100644 --- a/frontend/three_d_garden/config_overlays.tsx +++ b/frontend/three_d_garden/config_overlays.tsx @@ -392,6 +392,7 @@ export const PrivateOverlay = (props: OverlayProps) => { + diff --git a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx index 16573a95ef..560adcfb56 100644 --- a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx @@ -9,6 +9,7 @@ describe("", () => { const fakeProps = (): MoistureTextureProps => ({ config: clone(INITIAL), geometry: new BufferGeometry(), + sensorReadings: [], }); it("renders", () => { diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index 7d36604dc0..bf046952f0 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -1,34 +1,104 @@ import React from "react"; import { soilSurfaceExtents } from "../triangles"; import { Config } from "../config"; -import { OrthographicCamera, RenderTexture } from "@react-three/drei"; -import { Mesh } from "../components"; +import { OrthographicCamera, RenderTexture, Sphere } from "@react-three/drei"; +import { Group, Mesh, MeshBasicMaterial } from "../components"; import { BufferGeometry, Color } from "three"; import { HeightMaterial } from "./height_material"; +import { TaggedSensorReading } from "farmbot"; +import { threeSpace, zZero } from "../helpers"; export interface MoistureTextureProps { config: Config; geometry: BufferGeometry; + sensorReadings: TaggedSensorReading[]; } export const MoistureTexture = (props: MoistureTextureProps) => { const extents = soilSurfaceExtents(props.config); const width = extents.x.max - extents.x.min; const height = extents.y.max - extents.y.min; + const { bedXOffset, bedYOffset } = props.config; return - + + ; +}; + +export interface MoistureSurfaceProps { + geometry: BufferGeometry; + position: [number, number, number]; + sensorReadings: TaggedSensorReading[]; + config: Config; + color: string; + radius: number; + readingZOverride?: number; +} + +export const MoistureSurface = (props: MoistureSurfaceProps) => + + + - ; + ; + +export interface MoistureReadingsProps { + readings: TaggedSensorReading[]; + config: Config; + color: string; + radius: number; + applyOffset?: boolean; + readingZOverride?: number; +} + +export const MoistureReadings = (props: MoistureReadingsProps) => { + const { bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset } = props.config; + return + {props.readings.map(reading => + + + )} + ; }; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index b0bcdf659e..7933d26b62 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -40,6 +40,7 @@ import { BigDistance } from "./constants"; import { getZFunc } from "./triangle_functions"; import { Visualization } from "./visualization"; import { GroupOrderVisual } from "./group_order_visual"; +import { MoistureReadings } from "./garden/moisture_texture"; const AnimatedGroup = animated(Group); @@ -122,6 +123,9 @@ export const GardenModel = (props: GardenModelProps) => { const moistureSurface = React.useMemo(() => getSurface(moisturePoints), [moisturePoints]); + const showMoistureMap = !!props.addPlantProps?.getConfigValue( + BooleanSetting.show_moisture_interpolation_map); + // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); @@ -177,7 +181,16 @@ export const GardenModel = (props: GardenModelProps) => { activeFocus={props.activeFocus} mapPoints={props.mapPoints || []} moistureSurfaceGeometry={moistureSurface.geometry} + showMoistureMap={showMoistureMap} + sensorReadings={props.sensorReadings || []} addPlantProps={addPlantProps} /> + {showMoistureMap && props.config.moistureDebug && + } {showFarmbot && { !isUndefined(p.body.x) && !isUndefined(p.body.y)) .map(p => [p.body.x, p.body.y, p.body.value]) as [number, number, number][]; - const params = soilSurfaceExtents(props.config); + const params = boundaryPoints(props.config); const outerPoints = [ - { x: params.x.min, y: params.y.min }, - { x: params.x.min, y: params.y.max }, - { x: params.x.max, y: params.y.min }, - { x: params.x.max, y: params.y.max }, + { x: params.outer.x.min, y: params.outer.y.min }, + { x: params.outer.x.min, y: params.outer.y.max }, + { x: params.outer.x.max, y: params.outer.y.min }, + { x: params.outer.x.max, y: params.outer.y.max }, ]; - [...outerPoints, ...outerPoints].map(p => { + const innerPoints = [ + { x: params.inner.x.min, y: params.inner.y.min }, + { x: params.inner.x.min, y: params.inner.y.max }, + { x: params.inner.x.max, y: params.inner.y.min }, + { x: params.inner.x.max, y: params.inner.y.max }, + ]; + [...outerPoints, ...innerPoints].map(p => { moisturePoints.push([p.x, p.y, 0]); }); return moisturePoints; @@ -48,15 +54,9 @@ export const soilSurfaceExtents = (config: Config) => ({ }, }); -export interface FilterSoilPointsProps { - config: Config; - points: TaggedGenericPointer[] | undefined; -} - -export const filterSoilPoints = (props: FilterSoilPointsProps) => { - const { config } = props; +export const boundaryPoints = (config: Config) => { const outerBoundaryParams = soilSurfaceExtents(config); - const boundaryParams = { + return { outer: outerBoundaryParams, inner: { x: { @@ -69,6 +69,16 @@ export const filterSoilPoints = (props: FilterSoilPointsProps) => { }, }, }; +}; + +export interface FilterSoilPointsProps { + config: Config; + points: TaggedGenericPointer[] | undefined; +} + +export const filterSoilPoints = (props: FilterSoilPointsProps) => { + const { config } = props; + const boundaryParams = boundaryPoints(config); const soilHeightPoints: [number, number, number][] = (props.points || []) .filter(p => soilHeightPoint(p) && From 4b0d8063dfdaba702108d309dd4a3fb1ff7880c6 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 12 Nov 2025 14:49:46 -0800 Subject: [PATCH 3/8] fix 3D layer toggles and support demo pin readings --- .../demo/lua_runner/__tests__/index_test.ts | 9 +++++++ frontend/demo/lua_runner/actions.ts | 24 +++++++++++++++++++ frontend/demo/lua_runner/interfaces.ts | 2 ++ frontend/demo/lua_runner/run.ts | 1 + frontend/devices/__tests__/actions_test.ts | 11 +++++++++ frontend/devices/actions.ts | 5 +++- .../map/legend/garden_map_legend.tsx | 17 +++++++------ .../three_d_garden/bed/__tests__/bed_test.tsx | 1 + frontend/three_d_garden/bed/bed.tsx | 4 ++++ .../__tests__/moisture_texture_test.tsx | 8 +++++++ .../garden/moisture_texture.tsx | 16 ++++++++----- frontend/three_d_garden/garden_model.tsx | 3 +++ 12 files changed, 85 insertions(+), 16 deletions(-) diff --git a/frontend/demo/lua_runner/__tests__/index_test.ts b/frontend/demo/lua_runner/__tests__/index_test.ts index 5ca0472039..cab9577da1 100644 --- a/frontend/demo/lua_runner/__tests__/index_test.ts +++ b/frontend/demo/lua_runner/__tests__/index_test.ts @@ -1410,6 +1410,15 @@ describe("runDemoLuaCode()", () => { expect(error).not.toHaveBeenCalled(); expect(console.log).toHaveBeenCalledWith("0"); expect(info).not.toHaveBeenCalled(); + expect(initSave).toHaveBeenCalledWith("SensorReading", { + pin: 5, + mode: 1, + x: 1, + y: 2, + z: 0, + value: 0, + read_at: expect.any(String), + }); }); it("runs move_relative", () => { diff --git a/frontend/demo/lua_runner/actions.ts b/frontend/demo/lua_runner/actions.ts index b93fa773e5..d18df84861 100644 --- a/frontend/demo/lua_runner/actions.ts +++ b/frontend/demo/lua_runner/actions.ts @@ -235,6 +235,18 @@ export const expandActions = ( setCurrent(homeTarget); }); break; + case "read_pin": + const pin = action.args[0] as number; + expanded.push({ + type: "sensor_reading", + args: [ + pin, + current.x, + current.y, + current.z, + ], + }); + break; default: expanded.push(action); break; @@ -361,6 +373,18 @@ export const runActions = ( payload: action.args[0] as number, }); }; + case "sensor_reading": + return () => { + store.dispatch(initSave("SensorReading", { + pin: action.args[0] as number, + mode: 1, + x: action.args[1] as number, + y: action.args[2] as number, + z: action.args[3] as number, + value: random(0, 1024), + read_at: (new Date()).toISOString(), + }) as unknown as UnknownAction); + }; case "write_pin": const pin = action.args[0] as number; const mode = action.args[1] as string; diff --git a/frontend/demo/lua_runner/interfaces.ts b/frontend/demo/lua_runner/interfaces.ts index a533ab0616..da080dddf4 100644 --- a/frontend/demo/lua_runner/interfaces.ts +++ b/frontend/demo/lua_runner/interfaces.ts @@ -8,6 +8,8 @@ export interface Action { | "move" | "_move" | "toggle_pin" + | "read_pin" + | "sensor_reading" | "emergency_lock" | "emergency_unlock" | "find_home" diff --git a/frontend/demo/lua_runner/run.ts b/frontend/demo/lua_runner/run.ts index 53ce5827c2..7c5f27de2a 100644 --- a/frontend/demo/lua_runner/run.ts +++ b/frontend/demo/lua_runner/run.ts @@ -536,6 +536,7 @@ export const runLua = jsToLua(L, toolMounted ? 0 : 1); return 1; } + actions.push({ type: "read_pin", args: [pin] }); jsToLua(L, 0); return 1; }); diff --git a/frontend/devices/__tests__/actions_test.ts b/frontend/devices/__tests__/actions_test.ts index a74f50f095..096af4c2d0 100644 --- a/frontend/devices/__tests__/actions_test.ts +++ b/frontend/devices/__tests__/actions_test.ts @@ -610,6 +610,10 @@ describe("pinToggle()", () => { }); describe("readPin()", () => { + afterEach(() => { + localStorage.removeItem("myBotIs"); + }); + it("calls readPin", async () => { await actions.readPin(1, "label", 0); expect(mockDevice.current.readPin).toHaveBeenCalledWith({ @@ -617,6 +621,13 @@ describe("readPin()", () => { }); expect(success).not.toHaveBeenCalled(); }); + + it("reads demo account pin", async () => { + localStorage.setItem("myBotIs", "online"); + await actions.readPin(1, "label", 0); + expect(mockDevice.current.readPin).not.toHaveBeenCalled(); + expect(runDemoLuaCode).toHaveBeenCalledWith("read_pin(1)"); + }); }); describe("writePin()", () => { diff --git a/frontend/devices/actions.ts b/frontend/devices/actions.ts index a16539179f..6e9045aed0 100644 --- a/frontend/devices/actions.ts +++ b/frontend/devices/actions.ts @@ -447,7 +447,10 @@ export function readPin( pin_number: number, label: string, pin_mode: ALLOWED_PIN_MODES, ) { const noun = t("Read pin"); - maybeNoop(); + if (forceOnline()) { + runDemoLuaCode(`read_pin(${pin_number})`); + return; + } return getDevice() .readPin({ pin_number, label, pin_mode }) .then(maybeNoop, commandErr(noun)); diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index f169e5739b..d9a97b092e 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -129,8 +129,8 @@ interface LayerTogglesProps extends GardenMapLegendProps { } const LayerToggles = (props: LayerTogglesProps) => { const { toggle, getConfigValue, dispatch, firmwareConfig } = props; const subMenuProps = { dispatch, getConfigValue, firmwareConfig }; - const only2DClass = - getConfigValue(BooleanSetting.three_d_garden) ? "disabled" : ""; + const is3D = getConfigValue(BooleanSetting.three_d_garden); + const only2DClass = is3D ? "disabled" : ""; return
{ value={props.showPoints} label={DeviceSetting.showPoints} onClick={toggle(BooleanSetting.show_points)} /> - + {!is3D && + } { onClick={toggle(BooleanSetting.show_zones)} /> {props.hasSensorReadings && ", () => { getZ: () => 0, showMoistureMap: true, sensorReadings: [], + showMoistureReadings: true, }); it("renders bed", () => { diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 2c8a747044..287563d02e 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -110,6 +110,7 @@ export interface BedProps { soilSurfaceGeometry: BufferGeometry; moistureSurfaceGeometry: BufferGeometry; showMoistureMap: boolean; + showMoistureReadings: boolean; sensorReadings: TaggedSensorReading[]; } @@ -249,9 +250,11 @@ export const Bed = (props: BedProps) => { , [ props.config, props.sensorReadings, + props.showMoistureReadings, props.moistureSurfaceGeometry, ]); @@ -393,6 +396,7 @@ export const Bed = (props: BedProps) => { ", () => { config: clone(INITIAL), geometry: new BufferGeometry(), sensorReadings: [], + showMoistureReadings: true, }); it("renders", () => { const { container } = render(); expect(container).toContainHTML("render-texture"); }); + + it("renders without readings", () => { + const p = fakeProps(); + p.showMoistureReadings = false; + const { container } = render(); + expect(container).toContainHTML("render-texture"); + }); }); diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index bf046952f0..ec6b04d024 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -12,6 +12,7 @@ export interface MoistureTextureProps { config: Config; geometry: BufferGeometry; sensorReadings: TaggedSensorReading[]; + showMoistureReadings: boolean; } export const MoistureTexture = (props: MoistureTextureProps) => { @@ -36,6 +37,7 @@ export const MoistureTexture = (props: MoistureTextureProps) => { color={"black"} radius={10} sensorReadings={props.sensorReadings} + showMoistureReadings={props.showMoistureReadings} position={[ props.config.bedXOffset, props.config.bedYOffset, @@ -53,16 +55,18 @@ export interface MoistureSurfaceProps { color: string; radius: number; readingZOverride?: number; + showMoistureReadings: boolean; } export const MoistureSurface = (props: MoistureSurfaceProps) => - + {props.showMoistureReadings && + } { const showMoistureMap = !!props.addPlantProps?.getConfigValue( BooleanSetting.show_moisture_interpolation_map); + const showMoistureReadings = !!props.addPlantProps?.getConfigValue( + BooleanSetting.show_sensor_readings); // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); @@ -182,6 +184,7 @@ export const GardenModel = (props: GardenModelProps) => { mapPoints={props.mapPoints || []} moistureSurfaceGeometry={moistureSurface.geometry} showMoistureMap={showMoistureMap} + showMoistureReadings={showMoistureReadings} sensorReadings={props.sensorReadings || []} addPlantProps={addPlantProps} /> {showMoistureMap && props.config.moistureDebug && From 553213a3fbb991a8035df967d16533370f8bafa9 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 12 Nov 2025 14:50:44 -0800 Subject: [PATCH 4/8] improve release notifications --- lib/tasks/hook.rake | 59 +++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/lib/tasks/hook.rake b/lib/tasks/hook.rake index 22f0d42855..1a73d55e06 100644 --- a/lib/tasks/hook.rake +++ b/lib/tasks/hook.rake @@ -18,7 +18,7 @@ def open_json(url) end end -def last_deploy_commit +def last_deploy_sha if LAST_DEPLOY_COMMIT_OVERRIDE return LAST_DEPLOY_COMMIT_OVERRIDE end @@ -29,22 +29,46 @@ def last_deploy_commit (data[deploy_index] || {}).fetch("sha", nil) end -def commits_since_last_deploy - last_sha_deployed = last_deploy_commit() - deploy_commit_found = false - commits = [] - open_json(COMMITS_URL_API + "?per_page=100").map do |commit| - if commit.fetch("sha") == last_sha_deployed - deploy_commit_found = true - break - end - commits.push([commit["commit"]["message"].gsub("\n", " "), commit["sha"]]) +def branch + ENVIRONMENT.include?("production") ? "main" : "staging" +end + +def first_branch_sha + open_json(COMMITS_URL_API + "?per_page=1&sha=#{branch}")[0].fetch("sha") +end + +def commits_since(sha) + compare_url = "#{COMPARE_URL_API}#{sha}...#{COMMIT_SHA}" + open_json(compare_url)["commits"] +end + +def commit_list(commits) + commits.map { |commit| + [commit["commit"]["message"].gsub("\n", " "), commit["sha"]] + } +end + +def compare_link(sha) + web_compare_url = "#{COMPARE_URL_WEB}#{sha}...#{COMMIT_SHA}" + "<#{web_compare_url}|compare>" +end + +def commits_and_compare_link + commits = commits_since(last_deploy_sha) + compare = compare_link(last_deploy_sha) + if commits.nil? + commits = commits_since(first_branch_sha) + compare = compare_link(first_branch_sha) end - if !deploy_commit_found - commits = [commits.first] - commits.push(["[Last deploy commit not found. Most recent commit below.]", "0000000"]) + if commits.nil? + commits = [] + compare = "" end - commits + return {commits: commit_list(commits), compare: compare} +end + +def block_data + @block_data ||= commits_and_compare_link end def intro_block(start_text, environment) @@ -53,14 +77,13 @@ def intro_block(start_text, environment) if !DESCRIPTION.nil? output += "#{DESCRIPTION}\n\n" end - web_compare_url = "#{COMPARE_URL_WEB}#{last_deploy_commit}...#{COMMIT_SHA}" - output += "<#{web_compare_url}|compare>" + output += block_data[:compare] output end def commit_blocks(environment) outputs = [] - commits_since_last_deploy.reverse.each_slice(50) do |commits| + block_data[:commits].reverse.each_slice(50) do |commits| output = "" commits.map do |commit| output += "\n + #{commit[0]} | ##{commit[1][0..5]}" From d69b7c9ae8cc94ad1d39591f894c4610783c3e2e Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 14 Nov 2025 11:16:38 -0800 Subject: [PATCH 5/8] use IDW in 3D --- frontend/__test_support__/three_d_mocks.tsx | 8 ++- .../__tests__/three_d_garden_map_test.tsx | 1 + frontend/farm_designer/index.tsx | 1 + frontend/farm_designer/map/interfaces.ts | 4 +- .../map/layers/points/interpolation_map.tsx | 8 +-- .../map/layers/points/point_layer.tsx | 6 +- .../sensor_readings/sensor_readings_layer.tsx | 6 +- frontend/farm_designer/three_d_garden_map.tsx | 10 +++- .../three_d_garden/bed/__tests__/bed_test.tsx | 2 +- frontend/three_d_garden/bed/bed.tsx | 11 ++-- frontend/three_d_garden/config.ts | 14 ++++- .../__tests__/moisture_texture_test.tsx | 20 +++++-- .../garden/moisture_texture.tsx | 60 +++++++++++++------ frontend/three_d_garden/garden_model.tsx | 13 +--- 14 files changed, 112 insertions(+), 52 deletions(-) diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index a84b4d1e6c..f1a84d54fc 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -10,7 +10,9 @@ import * as THREE from "three"; import React, { ReactNode } from "react"; import { TransitionFn, UseSpringProps } from "@react-spring/three"; import { ThreeElements, ThreeEvent } from "@react-three/fiber"; -import { Cloud, Clouds, Image, Plane, Trail, Tube } from "@react-three/drei"; +import { + Cloud, Clouds, Image, Instance, Instances, Plane, Trail, Tube, +} from "@react-three/drei"; const GroupForTests = (props: ThreeElements["group"]) => // @ts-expect-error Property does not exist on type JSX.IntrinsicElements @@ -561,6 +563,10 @@ jest.mock("@react-three/drei", () => { ...jest.requireActual("@react-three/drei"), useGLTF, shaderMaterial: jest.fn(), + Instances: (props: React.ComponentProps) => +
{props.children}
, + Instance: (props: React.ComponentProps) => +
{props.name}
, RoundedBox: ({ name }: { name: string }) =>
{name}
, Plane: (props: React.ComponentProps) => 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 762504481a..1f1c0b12e9 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -61,6 +61,7 @@ describe("", () => { sensors: [], sensorReadings: [], cameraCalibrationData: fakeCameraCalibrationData(), + farmwareEnvs: [], }); it("converts props", () => { diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index 2037b67a95..7bbe47ce6e 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -238,6 +238,7 @@ export class RawFarmDesigner images={this.props.latestImages} sensorReadings={this.props.sensorReadings} sensors={this.props.sensors} + farmwareEnvs={this.props.farmwareEnvs} cameraCalibrationData={this.props.cameraCalibrationData} getWebAppConfigValue={this.props.getConfigValue} /> :
{ const points = selectMostRecentPoints(props.points); - const { gridSize } = props.mapTransformProps; + const { gridSize } = props; const { stepSize } = props.options; const hash = [ - JSON.stringify(points), + JSON.stringify(points.map(p => p.uuid)), JSON.stringify(gridSize), JSON.stringify(props.options), ].join(""); diff --git a/frontend/farm_designer/map/layers/points/point_layer.tsx b/frontend/farm_designer/map/layers/points/point_layer.tsx index ce6cbffb30..64dca51876 100644 --- a/frontend/farm_designer/map/layers/points/point_layer.tsx +++ b/frontend/farm_designer/map/layers/points/point_layer.tsx @@ -36,7 +36,11 @@ export function PointLayer(props: PointLayerProps) { props.interactions ? {} : { pointerEvents: "none" }; const options = fetchInterpolationOptions(props.farmwareEnvs); generateData({ - kind: "Point", points: soilHeightPoints, mapTransformProps, getColor, options, + kind: "Point", + points: soilHeightPoints, + gridSize: mapTransformProps.gridSize, + getColor, + options, }); return {props.overlayVisible && diff --git a/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx b/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx index 909141e327..b83f14c896 100644 --- a/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx +++ b/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx @@ -43,7 +43,9 @@ export function SensorReadingsLayer(props: SensorReadingsLayerProps) { filterMoistureReadings(sensorReadings, sensors); generateData({ kind: "SensorReading", - points: moistureReadings, mapTransformProps, getColor: getMoistureColor, + points: moistureReadings, + gridSize: mapTransformProps.gridSize, + getColor: getMoistureColor, options, }); return @@ -66,7 +68,7 @@ export function SensorReadingsLayer(props: SensorReadingsLayerProps) { ; } -const getMoistureColor = (value: number) => { +export const getMoistureColor = (value: number) => { const normalizedValue = round(255 * value / 1024); if (value > 900) { return "rgb(255, 255, 255)"; } return `rgb(0, 0, ${normalizedValue})`; diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 6990b0d94f..358977643b 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -7,7 +7,8 @@ import { import { clone } from "lodash"; import { BotPosition, SourceFbosConfig } from "../devices/interfaces"; import { - ConfigurationName, TaggedCurve, TaggedGenericPointer, TaggedImage, TaggedPoint, + ConfigurationName, TaggedCurve, TaggedFarmwareEnv, TaggedGenericPointer, + TaggedImage, TaggedPoint, TaggedPointGroup, TaggedSensor, TaggedSensorReading, TaggedWeedPointer, } from "farmbot"; import { CameraCalibrationData, DesignerState } from "./interfaces"; @@ -22,6 +23,7 @@ import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources"; import { SCENES } from "../settings/three_d_settings"; import { get3DTime, latLng } from "../three_d_garden/time_travel"; import { parseCalibrationData } from "./map/layers/images/map_image"; +import { fetchInterpolationOptions } from "./map/layers/points/interpolation_map"; export interface ThreeDGardenMapProps { botSize: BotSize; @@ -48,6 +50,7 @@ export interface ThreeDGardenMapProps { sensorReadings: TaggedSensorReading[]; sensors: TaggedSensor[]; cameraCalibrationData: CameraCalibrationData; + farmwareEnvs: TaggedFarmwareEnv[]; } export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { @@ -162,6 +165,11 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { config.imgCenterX = camCalData.centerX; config.imgCenterY = camCalData.centerY; + const options = fetchInterpolationOptions(props.farmwareEnvs); + config.interpolationStepSize = options.stepSize; + config.interpolationUseNearest = options.useNearest; + config.interpolationPower = options.power; + config.zoom = true; config.pan = true; config.rotate = !props.designer.threeDTopDownView; diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 02a2ca223c..4d575e1f08 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -100,9 +100,9 @@ describe("", () => { activeFocus: "", mapPoints: [], soilSurfaceGeometry: new BufferGeometry(), - moistureSurfaceGeometry: new BufferGeometry(), getZ: () => 0, showMoistureMap: true, + sensors: [], sensorReadings: [], showMoistureReadings: true, }); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 287563d02e..fe1a42f635 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -24,6 +24,7 @@ import { import { AxisNumberProperty } from "../../farm_designer/map/interfaces"; import { TaggedCurve, TaggedGenericPointer, TaggedImage, + TaggedSensor, TaggedSensorReading, } from "farmbot"; import { GetWebAppConfigValue } from "../../config_storage/actions"; @@ -108,9 +109,9 @@ export interface BedProps { getZ(x: number, y: number): number; images?: TaggedImage[]; soilSurfaceGeometry: BufferGeometry; - moistureSurfaceGeometry: BufferGeometry; showMoistureMap: boolean; showMoistureReadings: boolean; + sensors: TaggedSensor[]; sensorReadings: TaggedSensorReading[]; } @@ -249,13 +250,13 @@ export const Bed = (props: BedProps) => { const moistureTexture = React.useMemo(() => , [ + showMoistureReadings={props.showMoistureReadings} />, [ props.config, + props.sensors, props.sensorReadings, props.showMoistureReadings, - props.moistureSurfaceGeometry, ]); const SurfaceHeightMaterial = (props: { children: React.ReactNode }) => @@ -394,7 +395,7 @@ export const Bed = (props: BedProps) => { {props.config.moistureDebug && = { @@ -457,7 +464,8 @@ const OTHER_CONFIG_KEYS: (keyof Config)[] = [ "exaggeratedZ", "soilSurface", "soilSurfaceVariance", "showSoilPoints", "urlParamAutoAdd", "north", "desk", "imgScale", "imgRotation", "imgOffsetX", "imgOffsetY", "imgOrigin", "imgCalZ", - "imgCenterX", "imgCenterY", + "imgCenterX", "imgCenterY", "interpolationStepSize", "interpolationUseNearest", + "interpolationPower", ]; export const modifyConfig = (config: Config, update: Partial) => { diff --git a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx index b0ad6ab253..cf3886b80a 100644 --- a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx @@ -3,18 +3,30 @@ import { render } from "@testing-library/react"; import { MoistureTexture, MoistureTextureProps } from "../moisture_texture"; import { clone } from "lodash"; import { INITIAL } from "../../config"; -import { BufferGeometry } from "three"; +import { + fakeSensor, fakeSensorReading, +} from "../../../__test_support__/fake_state/resources"; describe("", () => { const fakeProps = (): MoistureTextureProps => ({ config: clone(INITIAL), - geometry: new BufferGeometry(), + sensors: [], sensorReadings: [], showMoistureReadings: true, }); - it("renders", () => { - const { container } = render(); + it("renders with readings", () => { + const p = fakeProps(); + p.showMoistureReadings = true; + const reading = fakeSensorReading(); + reading.body.pin = 1; + reading.body.mode = 1; + p.sensorReadings = [reading]; + const sensor = fakeSensor(); + sensor.body.pin = 1; + sensor.body.label = "soil moisture"; + p.sensors = [sensor]; + const { container } = render(); expect(container).toContainHTML("render-texture"); }); diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index ec6b04d024..c3d34b3f12 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -1,16 +1,22 @@ import React from "react"; import { soilSurfaceExtents } from "../triangles"; import { Config } from "../config"; -import { OrthographicCamera, RenderTexture, Sphere } from "@react-three/drei"; -import { Group, Mesh, MeshBasicMaterial } from "../components"; -import { BufferGeometry, Color } from "three"; -import { HeightMaterial } from "./height_material"; -import { TaggedSensorReading } from "farmbot"; +import { + Instance, Instances, OrthographicCamera, RenderTexture, Sphere, +} from "@react-three/drei"; +import { BoxGeometry, Group, MeshBasicMaterial } from "../components"; +import { TaggedSensor, TaggedSensorReading } from "farmbot"; import { threeSpace, zZero } from "../helpers"; +import { + generateData, getInterpolationData, +} from "../../farm_designer/map/layers/points/interpolation_map"; +import { + filterMoistureReadings, getMoistureColor, +} from "../../farm_designer/map/layers/sensor_readings/sensor_readings_layer"; export interface MoistureTextureProps { config: Config; - geometry: BufferGeometry; + sensors: TaggedSensor[]; sensorReadings: TaggedSensorReading[]; showMoistureReadings: boolean; } @@ -32,10 +38,10 @@ export const MoistureTexture = (props: MoistureTextureProps) => { scale={[1, 1, 1]} up={[0, 0, 1]} /> { }; export interface MoistureSurfaceProps { - geometry: BufferGeometry; position: [number, number, number]; + sensors: TaggedSensor[]; sensorReadings: TaggedSensorReading[]; config: Config; color: string; @@ -58,8 +64,23 @@ export interface MoistureSurfaceProps { showMoistureReadings: boolean; } -export const MoistureSurface = (props: MoistureSurfaceProps) => - +export const MoistureSurface = (props: MoistureSurfaceProps) => { + const { readings: moistureReadings } = + filterMoistureReadings(props.sensorReadings, props.sensors); + const options = { + stepSize: props.config.interpolationStepSize, + useNearest: props.config.interpolationUseNearest, + power: props.config.interpolationPower, + }; + generateData({ + kind: "SensorReading", + points: moistureReadings, + gridSize: { x: props.config.bedLengthOuter, y: props.config.bedWidthOuter }, + getColor: getMoistureColor, + options, + }); + const data = getInterpolationData("SensorReading"); + return {props.showMoistureReadings && radius={props.radius} readingZOverride={props.readingZOverride} readings={props.sensorReadings} />} - - - + + + + {data.map(p => { + const { x, y, z } = p; + return ; + })} + ; +}; export interface MoistureReadingsProps { readings: TaggedSensorReading[]; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 3a0ab4ebd4..2f73b930a9 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -35,7 +35,7 @@ import { BooleanSetting } from "../session_keys"; import { SlotWithTool } from "../resources/interfaces"; import { cameraInit } from "./camera"; import { isMobile } from "../screen_size"; -import { filterMoisturePoints, filterSoilPoints, getSurface } from "./triangles"; +import { filterSoilPoints, getSurface } from "./triangles"; import { BigDistance } from "./constants"; import { getZFunc } from "./triangle_functions"; import { Visualization } from "./visualization"; @@ -114,15 +114,6 @@ export const GardenModel = (props: GardenModelProps) => { }, [soilSurface.triangles]); const getZ = getZFunc(soilSurface.triangles, -config.soilHeight); - const moisturePoints = - filterMoisturePoints({ - config: props.config, - sensors: props.sensors || [], - readings: props.sensorReadings || [], - }); - const moistureSurface = React.useMemo(() => - getSurface(moisturePoints), [moisturePoints]); - const showMoistureMap = !!props.addPlantProps?.getConfigValue( BooleanSetting.show_moisture_interpolation_map); const showMoistureReadings = !!props.addPlantProps?.getConfigValue( @@ -182,9 +173,9 @@ export const GardenModel = (props: GardenModelProps) => { images={props.images} activeFocus={props.activeFocus} mapPoints={props.mapPoints || []} - moistureSurfaceGeometry={moistureSurface.geometry} showMoistureMap={showMoistureMap} showMoistureReadings={showMoistureReadings} + sensors={props.sensors || []} sensorReadings={props.sensorReadings || []} addPlantProps={addPlantProps} /> {showMoistureMap && props.config.moistureDebug && From 32f655c2223058ea10371ae6026ffd9fd27524a4 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 18 Nov 2025 22:27:11 -0800 Subject: [PATCH 6/8] combine texture renders and change moisture colors --- frontend/__test_support__/three_d_mocks.tsx | 7 ++ frontend/farm_designer/location_info.tsx | 4 +- frontend/farm_designer/map/interfaces.ts | 3 +- .../points/__tests__/garden_point_test.tsx | 4 +- .../map/layers/points/garden_point.tsx | 2 +- .../map/layers/points/interpolation_map.tsx | 9 +- .../__tests__/sensor_readings_layer_test.tsx | 15 +++ .../sensor_readings/sensor_readings_layer.tsx | 18 +++- .../points/__tests__/soil_height_test.tsx | 2 +- frontend/points/point_inventory.tsx | 5 +- frontend/points/soil_height.tsx | 5 +- .../three_d_garden/bed/__tests__/bed_test.tsx | 12 ++- frontend/three_d_garden/bed/bed.tsx | 33 +++--- .../garden/__tests__/images_test.tsx | 4 + .../__tests__/moisture_texture_test.tsx | 18 ++-- frontend/three_d_garden/garden/images.tsx | 40 +++++-- .../garden/moisture_texture.tsx | 100 ++++++++---------- 17 files changed, 177 insertions(+), 104 deletions(-) diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index f1a84d54fc..522ce8797b 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -49,6 +49,13 @@ jest.mock("../three_d_garden/components", () => ({ props.visible === false ? <> : , + MeshBasicMaterial: (props: THREE.MeshBasicMaterial) => { + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + props.onBeforeCompile?.({} as any, {} as any); + // @ts-expect-error Property does not exist on type JSX.IntrinsicElements + return
; + }, })); jest.mock("three/examples/jsm/Addons.js", () => ({ diff --git a/frontend/farm_designer/location_info.tsx b/frontend/farm_designer/location_info.tsx index f577b9ecb1..c2ac74ff89 100644 --- a/frontend/farm_designer/location_info.tsx +++ b/frontend/farm_designer/location_info.tsx @@ -38,7 +38,7 @@ import { TableRow } from "../sensors/sensor_readings/table"; import { unselectPlant } from "./map/actions"; import { EmptyStateGraphic, EmptyStateWrapper, ExpandableHeader } from "../ui"; import { - fetchInterpolationOptions, interpolatedZ, + fetchInterpolationOptions, GetColor, interpolatedZ, } from "./map/layers/points/interpolation_map"; import { Collapse } from "@blueprintjs/core"; import { ImageFlipper } from "../photos/images/image_flipper"; @@ -250,7 +250,7 @@ interface ItemListWrapperProps { items: Item[]; dispatch: Function; title: string; - getColorOverride(z: number): string; + getColorOverride: GetColor; showZ?: boolean; timeSettings: TimeSettings; sensorNameByPinLookup: Record, diff --git a/frontend/farm_designer/map/interfaces.ts b/frontend/farm_designer/map/interfaces.ts index 38ea21e79d..a8a3c98d1b 100644 --- a/frontend/farm_designer/map/interfaces.ts +++ b/frontend/farm_designer/map/interfaces.ts @@ -17,6 +17,7 @@ import { GetWebAppConfigValue } from "../../config_storage/actions"; import { TimeSettings } from "../../interfaces"; import { UUID } from "../../resources/interfaces"; import { PeripheralValues } from "./layers/farmbot/bot_trail"; +import { GetColor } from "./layers/points/interpolation_map"; export type TaggedPlant = TaggedPlantPointer | TaggedPlantTemplate; @@ -103,7 +104,7 @@ export interface GardenPointProps { hovered: boolean; dispatch: Function; soilHeightLabels: boolean; - getSoilHeightColor(z: number): string; + getSoilHeightColor: GetColor; animate: boolean; } diff --git a/frontend/farm_designer/map/layers/points/__tests__/garden_point_test.tsx b/frontend/farm_designer/map/layers/points/__tests__/garden_point_test.tsx index 80ccaff177..b426626297 100644 --- a/frontend/farm_designer/map/layers/points/__tests__/garden_point_test.tsx +++ b/frontend/farm_designer/map/layers/points/__tests__/garden_point_test.tsx @@ -28,7 +28,7 @@ describe("", () => { cropPhotos: false, showUncroppedArea: false, soilHeightLabels: false, - getSoilHeightColor: () => "rgb(128, 128, 128)", + getSoilHeightColor: () => ({ rgb: "rgb(128, 128, 128)", a: 1 }), current: false, animate: false, }); @@ -125,7 +125,7 @@ describe("", () => { const wrapper = svgMount(); expect(wrapper.text()).toContain("-100"); expect(wrapper.find("text").first().props().fill) - .toEqual(p.getSoilHeightColor(-100)); + .toEqual(p.getSoilHeightColor(-100).rgb); expect(wrapper.find("text").first().props().stroke).toEqual(Color.black); }); diff --git a/frontend/farm_designer/map/layers/points/garden_point.tsx b/frontend/farm_designer/map/layers/points/garden_point.tsx index 1e5a28b4fc..f56c16c972 100644 --- a/frontend/farm_designer/map/layers/points/garden_point.tsx +++ b/frontend/farm_designer/map/layers/points/garden_point.tsx @@ -46,7 +46,7 @@ export const GardenPoint = (props: GardenPointProps) => { {props.soilHeightLabels && soilHeightPoint(point) && { rgb: string, a: number }; + export enum InterpolationKey { data = "interpolationData", hash = "interpolationHash", @@ -81,7 +83,7 @@ interface GenerateInterpolationMapDataProps { kind: "Point" | "SensorReading"; points: (TaggedGenericPointer | TaggedSensorReading)[]; gridSize: AxisNumberProperty; - getColor(z: number): string; + getColor: GetColor; options: InterpolationOptions; } @@ -160,7 +162,7 @@ interface InterpolationMapProps { kind: "Point" | "SensorReading"; points: (TaggedGenericPointer | TaggedSensorReading)[]; mapTransformProps: MapTransformProps; - getColor(z: number): string; + getColor: GetColor; options: InterpolationOptions; } @@ -174,11 +176,12 @@ export const InterpolationMap = (props: InterpolationMapProps) => { const { quadrant } = props.mapTransformProps; const xOffset = [1, 4].includes(quadrant); const yOffset = [3, 4].includes(quadrant); + const colorInfo = props.getColor(z); return ; + fill={colorInfo.rgb} fillOpacity={colorInfo.a} />; })} ; diff --git a/frontend/farm_designer/map/layers/sensor_readings/__tests__/sensor_readings_layer_test.tsx b/frontend/farm_designer/map/layers/sensor_readings/__tests__/sensor_readings_layer_test.tsx index 203430cdea..9742169b99 100644 --- a/frontend/farm_designer/map/layers/sensor_readings/__tests__/sensor_readings_layer_test.tsx +++ b/frontend/farm_designer/map/layers/sensor_readings/__tests__/sensor_readings_layer_test.tsx @@ -1,5 +1,6 @@ import React from "react"; import { + getMoistureColor, SensorReadingsLayer, SensorReadingsLayerProps, } from "../sensor_readings_layer"; import { @@ -58,3 +59,17 @@ describe("", () => { expect(layer.find("rect").length).toEqual(1800); }); }); + +describe("getMoistureColor()", () => { + it.each<[number, string, number]>([ + [0, "rgb(255, 255, 255)", 0], + [200, "rgb(198, 198, 255)", 0.11], + [700, "rgb(57, 57, 255)", 0.39], + [900, "rgb(0, 0, 255)", 0.5], + [1024, "rgb(0, 0, 0)", 0], + ])("returns color for %s: %s %s", (value, color, alpha) => { + const c = getMoistureColor(value); + expect(c.rgb).toEqual(color); + expect(c.a).toEqual(alpha); + }); +}); diff --git a/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx b/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx index b83f14c896..929b2dc457 100644 --- a/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx +++ b/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx @@ -7,7 +7,7 @@ import { GardenSensorReading } from "./garden_sensor_reading"; import { last, round } from "lodash"; import { TimeSettings } from "../../../../interfaces"; import { - fetchInterpolationOptions, generateData, InterpolationMap, + fetchInterpolationOptions, generateData, GetColor, InterpolationMap, } from "../points/interpolation_map"; export const filterMoistureReadings = ( @@ -68,8 +68,16 @@ export function SensorReadingsLayer(props: SensorReadingsLayerProps) { ; } -export const getMoistureColor = (value: number) => { - const normalizedValue = round(255 * value / 1024); - if (value > 900) { return "rgb(255, 255, 255)"; } - return `rgb(0, 0, ${normalizedValue})`; +export const getMoistureColor: GetColor = (value: number) => { + const maxValue = 900; + if (value > maxValue) { return { rgb: "rgb(0, 0, 0)", a: 0 }; } + const normalizedValue = round(255 * value / maxValue); + const r = 255 - normalizedValue; + const g = 255 - normalizedValue; + const b = 255; + const a = round(0 + 0.5 * value / maxValue, 2); + return { + rgb: `rgb(${r}, ${g}, ${b})`, + a: a, + }; }; diff --git a/frontend/points/__tests__/soil_height_test.tsx b/frontend/points/__tests__/soil_height_test.tsx index bbd62e4741..090bdf097b 100644 --- a/frontend/points/__tests__/soil_height_test.tsx +++ b/frontend/points/__tests__/soil_height_test.tsx @@ -42,7 +42,7 @@ describe("getSoilHeightColor()", () => { tagAsSoilHeight(point1); point1.body.z = 100; const getColor = getSoilHeightColor([point0, point1]); - expect(getColor(50)).toEqual("rgb(128, 128, 128)"); + expect(getColor(50).rgb).toEqual("rgb(128, 128, 128)"); }); }); diff --git a/frontend/points/point_inventory.tsx b/frontend/points/point_inventory.tsx index e181c29b98..638959cc0e 100644 --- a/frontend/points/point_inventory.tsx +++ b/frontend/points/point_inventory.tsx @@ -40,6 +40,7 @@ import { pointGroupSubset } from "../plants/select_plants"; import { Path } from "../internal_urls"; import { deleteAllIds } from "../api/delete_points_handler"; import { NavigationContext } from "../routes_helpers"; +import { GetColor } from "../farm_designer/map/layers/points/interpolation_map"; interface PointsSectionProps { title: string; @@ -52,7 +53,7 @@ interface PointsSectionProps { hoveredPoint: UUID | undefined; dispatch: Function; metaQuery: Record; - getColorOverride?(z: number): string; + getColorOverride?: GetColor; averageZ?: number; sourceFbosConfig?: SourceFbosConfig; } @@ -89,7 +90,7 @@ const PointsSection = (props: PointsSectionProps) => { {genericPoints.map(p => )} diff --git a/frontend/points/soil_height.tsx b/frontend/points/soil_height.tsx index 0791159a92..36209084fd 100644 --- a/frontend/points/soil_height.tsx +++ b/frontend/points/soil_height.tsx @@ -45,7 +45,10 @@ export const getSoilHeightColor = const max = Math.max(...soilHeights); return (z: number) => { const normalizedZ = round(255 * (max > min ? (z - min) / (max - min) : 1)); - return `rgb(${normalizedZ}, ${normalizedZ}, ${normalizedZ})`; + return { + rgb: `rgb(${normalizedZ}, ${normalizedZ}, ${normalizedZ})`, + a: 1, + }; }; }; diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 4d575e1f08..832689c9e9 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -35,6 +35,9 @@ interface MockXCrosshairRefCurrent { interface MockYCrosshairRefCurrent { position: { set: Function; }; } +interface MockInstancesRefCurrent { + geometry: { setAttribute: Function; }; +} interface MockPlantRef { current: MockPlantRefCurrent | undefined; } @@ -56,6 +59,9 @@ interface MockXCrosshairRef { interface MockYCrosshairRef { current: MockYCrosshairRefCurrent | undefined; } +interface MockInstancesRef { + current: MockInstancesRefCurrent | undefined; +} const mockPlantRef: MockPlantRef = { current: undefined }; const mockRadiusRef: MockRadiusRef = { current: undefined }; const mockTorusRef: MockTorusRef = { current: undefined }; @@ -63,6 +69,8 @@ const mockBillboardRef: MockBillboardRef = { current: undefined }; const mockImageRef: MockImageRef = { current: undefined }; const mockXCrosshairRef: MockXCrosshairRef = { current: undefined }; const mockYCrosshairRef: MockYCrosshairRef = { current: undefined }; +const mockInstancesRef: MockInstancesRef = + { current: { geometry: { setAttribute: jest.fn() } } }; jest.mock("react", () => ({ ...jest.requireActual("react"), useRef: jest.fn(), @@ -92,7 +100,8 @@ describe("", () => { .mockImplementationOnce(() => mockBillboardRef) .mockImplementationOnce(() => mockImageRef) .mockImplementationOnce(() => mockXCrosshairRef) - .mockImplementationOnce(() => mockYCrosshairRef); + .mockImplementationOnce(() => mockYCrosshairRef) + .mockImplementation(() => mockInstancesRef); }); const fakeProps = (): BedProps => ({ @@ -248,6 +257,7 @@ describe("", () => { mockPlantRef.current = undefined; mockXCrosshairRef.current = undefined; mockYCrosshairRef.current = undefined; + mockInstancesRef.current = undefined; const p = fakeProps(); p.addPlantProps = fakeAddPlantProps(); render(); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index fe1a42f635..55ef082b4f 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -41,7 +41,7 @@ import { import { ThreeElements } from "@react-three/fiber"; import { ImageTexture } from "../garden"; import { VertexNormalsHelper } from "three/examples/jsm/Addons"; -import { MoistureSurface, MoistureTexture } from "../garden/moisture_texture"; +import { MoistureSurface } from "../garden/moisture_texture"; import { HeightMaterial } from "../garden/height_material"; const soil = ( @@ -242,22 +242,22 @@ export const Bed = (props: BedProps) => { images={props.images} config={props.config} addPlantProps={props.addPlantProps} + sensors={props.sensors} + sensorReadings={props.sensorReadings} + showMoistureReadings={props.showMoistureReadings} + showMoistureMap={props.showMoistureMap} xOffset={props.config.bedXOffset - props.config.bedLengthOuter / 2} yOffset={props.config.bedYOffset - props.config.bedWidthOuter / 2} z={0} />, - [props.images, props.config, props.addPlantProps]); - - const moistureTexture = React.useMemo(() => - , [ - props.config, - props.sensors, - props.sensorReadings, - props.showMoistureReadings, - ]); + [ + props.images, + props.config, + props.addPlantProps, + props.sensors, + props.sensorReadings, + props.showMoistureReadings, + props.showMoistureMap, + ]); const SurfaceHeightMaterial = (props: { children: React.ReactNode }) => { }; const SurfaceMaterial = getSurfaceMaterial(); - const surfaceTexture = props.showMoistureMap - ? moistureTexture - : soilTexture; + const surfaceTexture = soilTexture; return @@ -398,6 +396,7 @@ export const Bed = (props: BedProps) => { sensors={props.sensors} sensorReadings={props.sensorReadings} showMoistureReadings={true} + showMoistureMap={true} config={props.config} color={"black"} radius={50} diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx index 5b0554c3a3..40c9c22dfc 100644 --- a/frontend/three_d_garden/garden/__tests__/images_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx @@ -20,6 +20,10 @@ describe("", () => { z: 0, xOffset: 0, yOffset: 0, + sensors: [], + sensorReadings: [], + showMoistureReadings: true, + showMoistureMap: true, }); it("renders", () => { diff --git a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx index cf3886b80a..2910dd0211 100644 --- a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx @@ -1,18 +1,22 @@ import React from "react"; import { render } from "@testing-library/react"; -import { MoistureTexture, MoistureTextureProps } from "../moisture_texture"; +import { MoistureSurface, MoistureSurfaceProps } from "../moisture_texture"; import { clone } from "lodash"; import { INITIAL } from "../../config"; import { fakeSensor, fakeSensorReading, } from "../../../__test_support__/fake_state/resources"; -describe("", () => { - const fakeProps = (): MoistureTextureProps => ({ +describe("", () => { + const fakeProps = (): MoistureSurfaceProps => ({ config: clone(INITIAL), sensors: [], sensorReadings: [], showMoistureReadings: true, + showMoistureMap: true, + position: [0, 0, 0], + color: "black", + radius: 10, }); it("renders with readings", () => { @@ -26,14 +30,14 @@ describe("", () => { sensor.body.pin = 1; sensor.body.label = "soil moisture"; p.sensors = [sensor]; - const { container } = render(); - expect(container).toContainHTML("render-texture"); + const { container } = render(); + expect(container).toContainHTML("moisture-layer"); }); it("renders without readings", () => { const p = fakeProps(); p.showMoistureReadings = false; - const { container } = render(); - expect(container).toContainHTML("render-texture"); + const { container } = render(); + expect(container).toContainHTML("moisture-layer"); }); }); diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx index d47020d7df..1530e9ddd1 100644 --- a/frontend/three_d_garden/garden/images.tsx +++ b/frontend/three_d_garden/garden/images.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { TaggedImage } from "farmbot"; +import { TaggedImage, TaggedSensor, TaggedSensorReading } from "farmbot"; import { Config } from "../config"; import { isNumber } from "lodash"; import { @@ -9,7 +9,7 @@ import { DoubleSide } from "three"; import { ASSETS } from "../constants"; import { MeshBasicMaterial } from "../components"; import { soilSurfaceExtents } from "../triangles"; -import { getColorFromBrightness } from "../helpers"; +import { getColorFromBrightness, zZero } from "../helpers"; import { filterImages, TaggedImagePlus, } from "../../farm_designer/map/layers/images/image_layer"; @@ -17,6 +17,7 @@ import { AddPlantProps } from "../bed"; import { BooleanSetting } from "../../session_keys"; import { imageSizeCheck } from "../../farm_designer/map/layers/images/map_image"; import { forceOnline } from "../../devices/must_be_online"; +import { MoistureSurface } from "./moisture_texture"; interface BaseProps { config: Config; @@ -28,12 +29,17 @@ interface BaseProps { export interface ImageTextureProps extends BaseProps { images?: TaggedImage[]; addPlantProps?: AddPlantProps; + sensors: TaggedSensor[]; + sensorReadings: TaggedSensorReading[]; + showMoistureReadings: boolean; + showMoistureMap: boolean; } export const ImageTexture = (props: ImageTextureProps) => { const extents = soilSurfaceExtents(props.config); const width = extents.x.max - extents.x.min; const height = extents.y.max - extents.y.min; + const { bedXOffset, bedYOffset, bedWallThickness } = props.config; const soilTexture = useTexture(ASSETS.textures.soil + "?=soilT"); const color = getColorFromBrightness(props.config.soilBrightness); const { addPlantProps, images } = props; @@ -54,16 +60,24 @@ export const ImageTexture = (props: ImageTextureProps) => { ({ children, z }: { z: number, children: React.ReactNode }) => + position={[ + bedWallThickness + width / 2, + bedWallThickness + height / 2, + z, + ]} + scale={[1, 1, 1]}> {children} ; return @@ -78,6 +92,20 @@ export const ImageTexture = (props: ImageTextureProps) => { } + ; }; diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx index c3d34b3f12..8749dfc039 100644 --- a/frontend/three_d_garden/garden/moisture_texture.tsx +++ b/frontend/three_d_garden/garden/moisture_texture.tsx @@ -1,9 +1,6 @@ import React from "react"; -import { soilSurfaceExtents } from "../triangles"; import { Config } from "../config"; -import { - Instance, Instances, OrthographicCamera, RenderTexture, Sphere, -} from "@react-three/drei"; +import { Instance, Instances, Sphere } from "@react-three/drei"; import { BoxGeometry, Group, MeshBasicMaterial } from "../components"; import { TaggedSensor, TaggedSensorReading } from "farmbot"; import { threeSpace, zZero } from "../helpers"; @@ -13,45 +10,7 @@ import { import { filterMoistureReadings, getMoistureColor, } from "../../farm_designer/map/layers/sensor_readings/sensor_readings_layer"; - -export interface MoistureTextureProps { - config: Config; - sensors: TaggedSensor[]; - sensorReadings: TaggedSensorReading[]; - showMoistureReadings: boolean; -} - -export const MoistureTexture = (props: MoistureTextureProps) => { - const extents = soilSurfaceExtents(props.config); - const width = extents.x.max - extents.x.min; - const height = extents.y.max - extents.y.min; - const { bedXOffset, bedYOffset } = props.config; - return - - - ; -}; +import { InstancedBufferAttribute, InstancedMesh } from "three"; export interface MoistureSurfaceProps { position: [number, number, number]; @@ -62,6 +21,7 @@ export interface MoistureSurfaceProps { radius: number; readingZOverride?: number; showMoistureReadings: boolean; + showMoistureMap: boolean; } export const MoistureSurface = (props: MoistureSurfaceProps) => { @@ -80,7 +40,17 @@ export const MoistureSurface = (props: MoistureSurfaceProps) => { options, }); const data = getInterpolationData("SensorReading"); - return + // eslint-disable-next-line no-null/no-null + const ref = React.useRef(null); + React.useEffect(() => { + const opacities = new Float32Array(data.length); + data.map((d, i) => { + opacities[i] = getMoistureColor(d.z).a; + }); + ref.current?.geometry?.setAttribute("instanceOpacity", + new InstancedBufferAttribute(opacities, 1)); + }, [data]); + return {props.showMoistureReadings && { radius={props.radius} readingZOverride={props.readingZOverride} readings={props.sensorReadings} />} - - - - {data.map(p => { - const { x, y, z } = p; - return ; - })} - + {props.showMoistureMap && + + + { + shader.vertexShader = ` + attribute float instanceOpacity; + varying float vInstanceOpacity; + ` + shader.vertexShader; + shader.vertexShader = shader.vertexShader + .replace( + "#include ", + `vInstanceOpacity = instanceOpacity; + #include `); + shader.fragmentShader = ` + varying float vInstanceOpacity; + ` + shader.fragmentShader; + shader.fragmentShader = shader.fragmentShader + .replace( + "vec4 diffuseColor = vec4( diffuse, opacity );", + "vec4 diffuseColor = vec4( diffuse, opacity * vInstanceOpacity );"); + }} /> + {data.map(p => { + const { x, y, z } = p; + return ; + })} + } ; }; From 6724d9a77f3b8deca74f1639ebbe7ef39a84a3f2 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 20 Nov 2025 10:23:57 -0800 Subject: [PATCH 7/8] improve moisture map colors and add reading deletion --- frontend/api/__tests__/api_test.ts | 2 +- frontend/api/api.ts | 2 +- frontend/css/panels/sensors.scss | 17 +++++++++++++ frontend/farm_designer/location_info.tsx | 1 + .../__tests__/sensor_readings_layer_test.tsx | 10 ++++---- .../sensor_readings/sensor_readings_layer.tsx | 10 ++++---- .../__tests__/sensor_readings_test.tsx | 25 +++++++++++++++++++ .../sensor_readings/__tests__/table_test.tsx | 16 ++++++++++++ .../sensors/sensor_readings/interfaces.ts | 2 ++ .../sensor_readings/sensor_readings.tsx | 15 ++++++++++- frontend/sensors/sensor_readings/table.tsx | 21 +++++++++++++--- 11 files changed, 104 insertions(+), 17 deletions(-) diff --git a/frontend/api/__tests__/api_test.ts b/frontend/api/__tests__/api_test.ts index 5edfcce360..8d4c28c2f7 100644 --- a/frontend/api/__tests__/api_test.ts +++ b/frontend/api/__tests__/api_test.ts @@ -10,7 +10,7 @@ describe("API", () => { [ [API.current.pointSearchPath, BASE + "/api/points/search"], [API.current.allPointsPath, BASE + "/api/points/?filter=all"], - [API.current.sensorReadingPath, BASE + "/api/sensor_readings"], + [API.current.sensorReadingPath, BASE + "/api/sensor_readings/"], [API.current.farmwareEnvPath, BASE + "/api/farmware_envs/"], [API.current.plantTemplatePath, BASE + "/api/plant_templates/"], [API.current.farmwareInstallationPath, BASE + "/api/farmware_installations/"], diff --git a/frontend/api/api.ts b/frontend/api/api.ts index edb1f9c44b..45b66b96ec 100644 --- a/frontend/api/api.ts +++ b/frontend/api/api.ts @@ -139,7 +139,7 @@ export class API { /** /api/firmware_config */ get firmwareConfigPath() { return `${this.baseUrl}/api/firmware_config/`; } /** /api/sensor_readings */ - get sensorReadingPath() { return `${this.baseUrl}/api/sensor_readings`; } + get sensorReadingPath() { return `${this.baseUrl}/api/sensor_readings/`; } /** /api/sensors/ */ get sensorPath() { return `${this.baseUrl}/api/sensors/`; } /** /api/farmware_envs/:id */ diff --git a/frontend/css/panels/sensors.scss b/frontend/css/panels/sensors.scss index f6c63c499e..4dc31728c3 100644 --- a/frontend/css/panels/sensors.scss +++ b/frontend/css/panels/sensors.scss @@ -23,8 +23,16 @@ th, td { width: 1%; + padding: 0.25rem; + &:first-of-type { + padding-left: 0.5rem; + } + &:last-of-type { + padding: 0; + } } tr { + height: 3rem; &.previous { color: $medium_gray; } @@ -32,6 +40,15 @@ background: $translucent1_white; } } + label { + font-size: 1.1rem; + } + .fa-trash { + color: $transparent; + &:hover { + color: unset; // sass-lint:disable-line variable-for-property + } + } .sensor-history-table-contents { max-height: 20rem; overflow-y: auto; diff --git a/frontend/farm_designer/location_info.tsx b/frontend/farm_designer/location_info.tsx index c2ac74ff89..6a834fe177 100644 --- a/frontend/farm_designer/location_info.tsx +++ b/frontend/farm_designer/location_info.tsx @@ -397,6 +397,7 @@ const ReadingsListItem = (props: ReadingsListItemProps) => const sensorName = `${props.sensorNameByPinLookup[pin]} (pin ${pin})`; return ", () => { p.sensorReadings[0].body.mode = ANALOG; const reading = fakeSensorReading(); reading.body.mode = ANALOG; - reading.body.value = 1000; + reading.body.value = 800; reading.body.x = 100; reading.body.y = 200; p.sensorReadings.push(reading); @@ -62,10 +62,10 @@ describe("", () => { describe("getMoistureColor()", () => { it.each<[number, string, number]>([ - [0, "rgb(255, 255, 255)", 0], - [200, "rgb(198, 198, 255)", 0.11], - [700, "rgb(57, 57, 255)", 0.39], - [900, "rgb(0, 0, 255)", 0.5], + [0, "rgb(0, 0, 255)", 0], + [200, "rgb(0, 0, 255)", 0], + [700, "rgb(0, 0, 255)", 0.2], + [900, "rgb(0, 0, 255)", 0.42], [1024, "rgb(0, 0, 0)", 0], ])("returns color for %s: %s %s", (value, color, alpha) => { const c = getMoistureColor(value); diff --git a/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx b/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx index 929b2dc457..020b7e818b 100644 --- a/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx +++ b/frontend/farm_designer/map/layers/sensor_readings/sensor_readings_layer.tsx @@ -19,7 +19,8 @@ export const filterMoistureReadings = ( const readings = sensorReadings .filter(r => (sensorNameByPinLookup[r.body.pin] || "").toLowerCase().includes("soil") - && r.body.mode == ANALOG); + && r.body.mode == ANALOG) + .filter(r => r.body.value <= 900); return { readings, sensorNameByPinLookup }; }; @@ -71,11 +72,10 @@ export function SensorReadingsLayer(props: SensorReadingsLayerProps) { export const getMoistureColor: GetColor = (value: number) => { const maxValue = 900; if (value > maxValue) { return { rgb: "rgb(0, 0, 0)", a: 0 }; } - const normalizedValue = round(255 * value / maxValue); - const r = 255 - normalizedValue; - const g = 255 - normalizedValue; + const r = 0; + const g = 0; const b = 255; - const a = round(0 + 0.5 * value / maxValue, 2); + const a = round((0.75 * value / maxValue) ** 3, 2); return { rgb: `rgb(${r}, ${g}, ${b})`, a: a, diff --git a/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx b/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx index 45d3682d13..6d9237ef9c 100644 --- a/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx +++ b/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx @@ -1,3 +1,7 @@ +jest.mock("../../../api/crud", () => ({ + destroy: jest.fn(), +})); + import React from "react"; import { mount } from "enzyme"; import moment from "moment"; @@ -7,6 +11,7 @@ import { fakeSensorReading, fakeSensor, } from "../../../__test_support__/fake_state/resources"; import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings"; +import { destroy } from "../../../api/crud"; describe("", () => { const fakeProps = (): SensorReadingsProps => ({ @@ -98,4 +103,24 @@ describe("", () => { expect(wrapper.instance().state.xyzLocation).toEqual(undefined); expect(wrapper.instance().state.sensor).toEqual(undefined); }); + + it("deletes selected readings", () => { + window.confirm = () => true; + const p = fakeProps(); + const wrapper = mount(); + const reading = fakeSensorReading(); + reading.uuid = "uuid0"; + wrapper.instance().deleteSelected([reading])(); + expect(destroy).toHaveBeenCalledWith("uuid0"); + }); + + it("doesn't delete selected readings", () => { + window.confirm = () => false; + const p = fakeProps(); + const wrapper = mount(); + const reading = fakeSensorReading(); + reading.uuid = "uuid0"; + wrapper.instance().deleteSelected([reading])(); + expect(destroy).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/sensors/sensor_readings/__tests__/table_test.tsx b/frontend/sensors/sensor_readings/__tests__/table_test.tsx index 33e1abb232..36f139b711 100644 --- a/frontend/sensors/sensor_readings/__tests__/table_test.tsx +++ b/frontend/sensors/sensor_readings/__tests__/table_test.tsx @@ -1,3 +1,7 @@ +jest.mock("../../../api/crud", () => ({ + destroy: jest.fn(), +})); + import React from "react"; import { mount } from "enzyme"; import { SensorReadingsTable } from "../table"; @@ -6,6 +10,7 @@ import { fakeSensorReading, fakeSensor, } from "../../../__test_support__/fake_state/resources"; import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings"; +import { destroy } from "../../../api/crud"; describe("", () => { const fakeProps = (sr = fakeSensorReading()): SensorReadingsTableProps => ({ @@ -14,6 +19,7 @@ describe("", () => { timeSettings: fakeTimeSettings(), hover: jest.fn(), hovered: undefined, + dispatch: jest.fn(), }); it("renders", () => { @@ -68,4 +74,14 @@ describe("", () => { const wrapper = mount(); expect(wrapper.find("tr").last().hasClass("selected")).toEqual(true); }); + + it("deletes reading", () => { + const sr = fakeSensorReading(); + const p = fakeProps(sr); + p.hovered = sr.uuid; + const wrapper = mount(); + expect(wrapper.find("tr").last().hasClass("selected")).toEqual(true); + wrapper.find(".fa-trash").first().simulate("click"); + expect(destroy).toHaveBeenCalledWith(sr.uuid); + }); }); diff --git a/frontend/sensors/sensor_readings/interfaces.ts b/frontend/sensors/sensor_readings/interfaces.ts index 610c08bfa4..b515dcf8c5 100644 --- a/frontend/sensors/sensor_readings/interfaces.ts +++ b/frontend/sensors/sensor_readings/interfaces.ts @@ -34,6 +34,7 @@ export interface SensorReadingsTableProps { /** TaggedSensorReading UUID */ hovered: string | undefined; hover: (hovered: string | undefined) => void; + dispatch: Function; } export interface TableRowProps { @@ -46,6 +47,7 @@ export interface TableRowProps { hover: (hovered: string | undefined) => void; hideLocation?: boolean; distance?: number; + dispatch: Function; } export interface SensorSelectionProps { diff --git a/frontend/sensors/sensor_readings/sensor_readings.tsx b/frontend/sensors/sensor_readings/sensor_readings.tsx index 674779e94d..a906e322e1 100644 --- a/frontend/sensors/sensor_readings/sensor_readings.tsx +++ b/frontend/sensors/sensor_readings/sensor_readings.tsx @@ -9,11 +9,12 @@ import { } from "./time_period_selection"; import { LocationSelection, LocationDisplay } from "./location_selection"; import { SensorSelection } from "./sensor_selection"; -import { TaggedSensor } from "farmbot"; +import { TaggedSensor, TaggedSensorReading } from "farmbot"; import { AxisInputBoxGroupState } from "../../controls/interfaces"; import { SensorReadingsPlot } from "./graph"; import { Position } from "@blueprintjs/core"; import { AddSensorReadingMenu } from "./add_reading"; +import { destroy } from "../../api/crud"; export class SensorReadings extends React.Component { @@ -46,6 +47,12 @@ export class SensorReadings showPreviousPeriod: false, deviation: 0, }); + deleteSelected = (readings: TaggedSensorReading[]) => () => { + if (!confirm(t("Delete {{count}} sensor readings?", { + count: readings.length, + }))) { return; } + readings.map(reading => this.props.dispatch(destroy(reading.uuid))); + }; toggleAddReadingMenu = () => { this.setState({ addReadingMenuOpen: !this.state.addReadingMenuOpen }); @@ -61,6 +68,11 @@ export class SensorReadings

{t("History")}

+
; }; diff --git a/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx b/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx index 6d9237ef9c..4b64ef31f3 100644 --- a/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx +++ b/frontend/sensors/sensor_readings/__tests__/sensor_readings_test.tsx @@ -12,6 +12,7 @@ import { } from "../../../__test_support__/fake_state/resources"; import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings"; import { destroy } from "../../../api/crud"; +import { busy } from "../../../toast/toast"; describe("", () => { const fakeProps = (): SensorReadingsProps => ({ @@ -105,22 +106,27 @@ describe("", () => { }); it("deletes selected readings", () => { + jest.useFakeTimers(); window.confirm = () => true; const p = fakeProps(); const wrapper = mount(); const reading = fakeSensorReading(); reading.uuid = "uuid0"; wrapper.instance().deleteSelected([reading])(); + jest.runAllTimers(); expect(destroy).toHaveBeenCalledWith("uuid0"); + expect(busy).toHaveBeenCalledWith("Deleting 1 sensor readings..."); }); it("doesn't delete selected readings", () => { + jest.useFakeTimers(); window.confirm = () => false; const p = fakeProps(); const wrapper = mount(); const reading = fakeSensorReading(); reading.uuid = "uuid0"; wrapper.instance().deleteSelected([reading])(); + jest.runAllTimers(); expect(destroy).not.toHaveBeenCalled(); }); }); diff --git a/frontend/sensors/sensor_readings/sensor_readings.tsx b/frontend/sensors/sensor_readings/sensor_readings.tsx index a906e322e1..938e1f04a3 100644 --- a/frontend/sensors/sensor_readings/sensor_readings.tsx +++ b/frontend/sensors/sensor_readings/sensor_readings.tsx @@ -15,6 +15,7 @@ import { SensorReadingsPlot } from "./graph"; import { Position } from "@blueprintjs/core"; import { AddSensorReadingMenu } from "./add_reading"; import { destroy } from "../../api/crud"; +import { busy } from "../../toast/toast"; export class SensorReadings extends React.Component { @@ -51,7 +52,10 @@ export class SensorReadings if (!confirm(t("Delete {{count}} sensor readings?", { count: readings.length, }))) { return; } - readings.map(reading => this.props.dispatch(destroy(reading.uuid))); + busy(t("Deleting {{count}} sensor readings...", { count: readings.length })); + readings.map((reading, index) => { + setTimeout(() => this.props.dispatch(destroy(reading.uuid)), index * 250); + }); }; toggleAddReadingMenu = () => {