=> ({
...fakeStepParams({ kind: "execute", args: { sequence_id: 0 } }),
});
-// eslint-disable-next-line comma-spacing
-const findByType = (
- node: React.ReactNode,
- type: React.ComponentType
,
-): React.ReactElement
| undefined => {
- if (!node) { return undefined; }
- if (Array.isArray(node)) {
- for (const child of React.Children.toArray(node)) {
- const found = findByType(child, type);
- if (found) { return found; }
- }
- return undefined;
- }
- if (React.isValidElement(node)) {
- if (node.type === type) {
- return node as React.ReactElement
;
- }
- const elementWithChildren = node as React.ReactElement<{
- children?: React.ReactNode;
- }>;
- return findByType(elementWithChildren.props.children, type);
- }
- return undefined;
-};
-
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(crud, "editStep").mockImplementation(mockEditStep);
@@ -96,7 +75,8 @@ describe("", () => {
p.currentStep.args.sequence_id = mockSequence.body.id;
mockSequence.body.description = "description";
const element = new TileExecute(p).render();
- const wrapper = findByType(element, StepWrapper);
+ const wrapper = findElementByType(
+ element, StepWrapper);
expect(wrapper?.props.helpText).toEqual("description");
});
@@ -104,7 +84,8 @@ describe("", () => {
const p = fakeProps();
mockSequence.body.description = "";
const element = new TileExecute(p).render();
- const wrapper = findByType(element, StepWrapper);
+ const wrapper = findElementByType(
+ element, StepWrapper);
expect(wrapper?.props.helpText)
.toEqual(ToolTips.EXECUTE_SEQUENCE);
});
@@ -155,7 +136,8 @@ describe("", () => {
const p = fakeProps();
p.currentStep.args.sequence_id = 0;
const element = new TileExecute(p).render();
- expect(findByType(element, LocalsList)).toBeUndefined();
+ expect(findElementByType(
+ element, LocalsList)).toBeUndefined();
});
it("selects a location", () => {
@@ -181,7 +163,8 @@ describe("", () => {
}
}
};
- const localsList = findByType(element, LocalsList);
+ const localsList = findElementByType(
+ element, LocalsList);
localsList?.props.onChange(variable, variable.args.label);
mockEditStep.mock.calls[0][0].executor(p.currentStep);
expect(p.currentStep).toEqual({
@@ -251,7 +234,8 @@ describe("", () => {
}
}
};
- const localsList = findByType(element, LocalsList);
+ const localsList = findElementByType(
+ element, LocalsList);
localsList?.props.onChange(variable, variable.args.label);
mockEditStep.mock.calls[0][0].executor(p.currentStep);
expect(p.currentStep).toEqual({
diff --git a/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx b/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx
index 252930c782..fa4a36806d 100644
--- a/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx
+++ b/frontend/sequences/step_tiles/__tests__/tile_move_absolute_test.tsx
@@ -22,15 +22,26 @@ import { ExpandableHeader } from "../../../ui/expandable_header";
import * as blueprintCore from "@blueprintjs/core";
import { CollapseProps } from "@blueprintjs/core";
import {
- actRenderer,
- createRenderer,
-} from "../../../__test_support__/test_renderer";
+ findElementByType,
+} from "../../../__test_support__/react_element_search";
let overwriteSpy: jest.SpyInstance;
let isDesktopSpy: jest.SpyInstance;
let collapseSpy: jest.SpyInstance;
let originalInnerWidth = window.innerWidth;
+const createBlock = (p: StepParams) => {
+ const block = new TileMoveAbsolute(p);
+ jest.spyOn(block, "setState").mockImplementation(update => {
+ Object.assign(block.state, update);
+ });
+ return block;
+};
+
+const getHeader = (block: TileMoveAbsolute) =>
+ findElementByType>(
+ block.render(), ExpandableHeader);
+
const setInnerWidth = (innerWidth: number) => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
@@ -115,17 +126,13 @@ describe("", () => {
it("renders options on wide screens", () => {
const p = fakeProps();
isDesktopSpy.mockReturnValue(true);
- const rendered = createRenderer();
- const header = rendered.root.findByType(ExpandableHeader);
- expect(header.props.title).toEqual("Options");
+ expect(getHeader(createBlock(p))?.props.title).toEqual("Options");
});
it("doesn't render options on narrow screens", () => {
const p = fakeProps();
isDesktopSpy.mockReturnValue(false);
- const rendered = createRenderer();
- const header = rendered.root.findByType(ExpandableHeader);
- expect(header.props.title).toEqual("");
+ expect(getHeader(createBlock(p))?.props.title).toEqual("");
});
it("expands form", () => {
@@ -133,28 +140,24 @@ describe("", () => {
p.expandStepOptions = false;
p.currentStep.args.offset.args = { x: 0, y: 0, z: 0 };
p.currentStep.args.speed = 100;
- const rendered = createRenderer();
- const header = rendered.root.findByType(ExpandableHeader);
- expect(header.props.expanded).toBeFalsy();
- actRenderer(() => {
- header.props.onClick();
- });
- expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy();
+ const block = createBlock(p);
+ const header = getHeader(block);
+ expect(header?.props.expanded).toBeFalsy();
+ header?.props.onClick();
+ expect(getHeader(block)?.props.expanded).toBeTruthy();
});
it("expands form by default", () => {
const p = fakeProps();
p.expandStepOptions = true;
- const rendered = createRenderer();
- expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy();
+ expect(getHeader(createBlock(p))?.props.expanded).toBeTruthy();
});
it("expands form when offset is present", () => {
const p = fakeProps();
p.expandStepOptions = false;
p.currentStep.args.offset.args.z = 100;
- const rendered = createRenderer();
- expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy();
+ expect(getHeader(createBlock(p))?.props.expanded).toBeTruthy();
});
it("not expanding form when speed is 100", () => {
@@ -162,8 +165,7 @@ describe("", () => {
p.expandStepOptions = false;
p.currentStep.args.offset.args = { x: 0, y: 0, z: 0 };
p.currentStep.args.speed = 100;
- const rendered = createRenderer();
- expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeFalsy();
+ expect(getHeader(createBlock(p))?.props.expanded).toBeFalsy();
});
it("expands form when speed is not 100", () => {
@@ -171,8 +173,7 @@ describe("", () => {
p.expandStepOptions = false;
p.currentStep.args.offset.args = { x: 0, y: 0, z: 0 };
p.currentStep.args.speed = 50;
- const rendered = createRenderer();
- expect(rendered.root.findByType(ExpandableHeader).props.expanded).toBeTruthy();
+ expect(getHeader(createBlock(p))?.props.expanded).toBeTruthy();
});
it("returns correct node", () => {
diff --git a/frontend/settings/pin_bindings/__tests__/model_test.tsx b/frontend/settings/pin_bindings/__tests__/model_test.tsx
index 201e62a815..86d399b496 100644
--- a/frontend/settings/pin_bindings/__tests__/model_test.tsx
+++ b/frontend/settings/pin_bindings/__tests__/model_test.tsx
@@ -32,6 +32,7 @@ import * as deviceActions from "../../../devices/actions";
import { ButtonPin } from "../list_and_label_support";
import { BoxTopBaseProps } from "../interfaces";
import { FirmwareHardware } from "farmbot";
+import * as ui from "../../../ui";
describe("setZForAllInGroup()", () => {
it("sets z", () => {
@@ -53,6 +54,7 @@ describe("", () => {
let execSequenceSpy: jest.SpyInstance;
let useFrameSpy: jest.SpyInstance;
let reactUseRefSpy: jest.SpyInstance;
+ let fbSelectSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
@@ -74,6 +76,8 @@ describe("", () => {
}));
execSequenceSpy = jest.spyOn(deviceActions, "execSequence")
.mockImplementation(jest.fn());
+ fbSelectSpy = jest.spyOn(ui, "FBSelect")
+ .mockImplementation((() => ) as never);
});
afterEach(() => {
@@ -82,6 +86,7 @@ describe("", () => {
document.body.style.cursor = "default";
reactUseRefSpy.mockRestore();
useFrameSpy.mockRestore();
+ fbSelectSpy.mockRestore();
});
const fakeProps = (): BoxTopBaseProps => {
diff --git a/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx b/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx
index e51f2ac134..412d6bf936 100644
--- a/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx
+++ b/frontend/settings/pin_bindings/__tests__/pin_binding_input_group_test.tsx
@@ -27,7 +27,8 @@ import {
PinBindingType, PinBindingSpecialAction,
} from "farmbot/dist/resources/api_resources";
import { error, warning } from "../../../toast/toast";
-import { FBSelect } from "../../../ui";
+import * as ui from "../../../ui";
+import { FBSelectProps } from "../../../ui";
import {
actRenderer,
createRenderer,
@@ -36,12 +37,15 @@ import {
let getDeviceSpy: jest.SpyInstance;
let initSaveSpy: jest.SpyInstance;
+let fbSelectMock: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
getDeviceSpy = jest.spyOn(deviceModule, "getDevice")
.mockImplementation(() => mockDevice as never);
initSaveSpy = jest.spyOn(crud, "initSave").mockImplementation(jest.fn());
+ fbSelectMock = jest.spyOn(ui, "FBSelect")
+ .mockImplementation(((_props: FBSelectProps) => ) as never);
mockDevice.registerGpio = jest.fn(() => Promise.resolve());
mockDevice.unregisterGpio = jest.fn(() => Promise.resolve());
});
@@ -49,6 +53,7 @@ beforeEach(() => {
afterEach(() => {
getDeviceSpy.mockRestore();
initSaveSpy.mockRestore();
+ fbSelectMock.mockRestore();
});
const AVAILABLE_PIN = 18;
@@ -72,10 +77,18 @@ describe("", () => {
};
};
+ const createInstance = (p = fakeProps()) => {
+ const instance = new PinBindingInputGroup(p);
+ jest.spyOn(instance, "setState").mockImplementation(update => {
+ Object.assign(instance.state, update);
+ });
+ return instance;
+ };
+
it("renders", () => {
const wrapper = createRenderer();
- const buttons = wrapper.root.findAllByType("button");
- expect(buttons.length).toBe(3);
+ const bindButton = wrapper.root.findByProps({ title: "BIND" });
+ expect(bindButton).toBeTruthy();
});
it("no pin selected", () => {
@@ -152,25 +165,19 @@ describe("", () => {
const key = Object.keys(p.resources.byKind.Sequence)[0];
const s = p.resources.references[key];
const id = s?.body.id;
- const wrapper = createRenderer();
- const instance =
- getRendererInstance(
- wrapper, PinBindingInputGroup);
+ const instance = createInstance(p);
expect(instance.state.sequenceIdInput).toEqual(undefined);
- actRenderer(() => instance.changeBinding({
+ instance.changeBinding({
label: "label", value: "" + id,
headingId: PinBindingType.standard
- }));
+ });
expect(instance.state.sequenceIdInput).toEqual(id);
});
it("attempts to set pin 99", () => {
- const wrapper = createRenderer();
- const instance =
- getRendererInstance(
- wrapper, PinBindingInputGroup);
+ const instance = createInstance();
expect(instance.state.pinNumberInput).toEqual(undefined);
- actRenderer(() => instance.setSelectedPin(99));
+ instance.setSelectedPin(99);
expect(error).toHaveBeenCalledWith(
"Invalid Raspberry Pi GPIO pin number.");
expect(warning).not.toHaveBeenCalled();
@@ -179,12 +186,9 @@ describe("", () => {
it("attempts to set pin 1", () => {
expect(validGpioPins.length).toBeGreaterThan(0);
- const wrapper = createRenderer();
- const instance =
- getRendererInstance(
- wrapper, PinBindingInputGroup);
+ const instance = createInstance();
expect(instance.state.pinNumberInput).toEqual(undefined);
- actRenderer(() => instance.setSelectedPin(1));
+ instance.setSelectedPin(1);
expect(error).not.toHaveBeenCalled();
expect(warning).toHaveBeenCalledWith(
"Reserved Raspberry Pi pin may not work as expected.");
@@ -193,13 +197,10 @@ describe("", () => {
it("rejects pin already in use", () => {
const p = fakeProps();
- const wrapper = createRenderer();
- const instance =
- getRendererInstance(
- wrapper, PinBindingInputGroup);
+ const instance = createInstance(p);
expect(instance.state.pinNumberInput).toEqual(undefined);
const { pin_number } = p.pinBindings[0];
- actRenderer(() => instance.setSelectedPin(pin_number));
+ instance.setSelectedPin(pin_number);
expect(error).toHaveBeenCalledWith(
"Raspberry Pi GPIO pin already bound or in use.");
expect(warning).not.toHaveBeenCalled();
@@ -208,27 +209,21 @@ describe("", () => {
it("changes pin number to available pin", () => {
expect(validGpioPins.length).toBeGreaterThan(0);
- const wrapper = createRenderer();
- const instance =
- getRendererInstance(
- wrapper, PinBindingInputGroup);
+ const instance = createInstance();
expect(instance.state.pinNumberInput).toEqual(undefined);
- actRenderer(() => instance.setSelectedPin(AVAILABLE_PIN));
+ instance.setSelectedPin(AVAILABLE_PIN);
expect(error).not.toHaveBeenCalled();
expect(warning).not.toHaveBeenCalled();
expect(instance.state.pinNumberInput).toEqual(AVAILABLE_PIN);
});
it("changes special action", () => {
- const wrapper = createRenderer();
- const instance =
- getRendererInstance(
- wrapper, PinBindingInputGroup);
- actRenderer(() => instance.changeBinding({
+ const instance = createInstance();
+ instance.changeBinding({
label: "",
value: PinBindingSpecialAction.sync,
headingId: PinBindingType.special,
- }));
+ });
expect(instance.state.specialActionInput)
.toEqual(PinBindingSpecialAction.sync);
});
@@ -244,8 +239,10 @@ describe("", () => {
it("sets pin", () => {
const p = fakeProps();
- const wrapper = createRenderer();
- const select = wrapper.root.findByType(FBSelect);
+ const row = PinNumberInputGroup(p) as React.ReactElement<{
+ children: React.ReactNode[];
+ }>;
+ const select = row.props.children[2] as React.ReactElement;
select.props.onChange({
label: "", value: AVAILABLE_PIN
});
@@ -254,6 +251,20 @@ describe("", () => {
});
describe("", () => {
+ let fbSelectSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ fbSelectSpy = jest.spyOn(ui, "FBSelect")
+ .mockImplementation((() => ) as never);
+ });
+
+ afterEach(() => {
+ fbSelectSpy.mockRestore();
+ });
+
+ const fbSelectProps = () =>
+ fbSelectSpy.mock.calls[0][0] as FBSelectProps;
+
const fakeProps = (): BindingTargetDropdownProps => {
const sequence0 = fakeSequence();
sequence0.body.id = undefined;
@@ -272,27 +283,33 @@ describe("", () => {
it("shows sequence selected", () => {
const p = fakeProps();
p.sequenceIdInput = 1;
- const wrapper = createRenderer();
- expect(wrapper.root.findByType(FBSelect).props.selected).toEqual(undefined);
+ createRenderer();
+ expect(fbSelectProps().selectedItem).toEqual({
+ label: "fake",
+ value: 1,
+ });
});
it("shows action selected", () => {
const p = fakeProps();
p.specialActionInput = PinBindingSpecialAction.sync;
- const wrapper = createRenderer();
- expect(wrapper.root.findByType(FBSelect).props.selected).toEqual(undefined);
+ createRenderer();
+ expect(fbSelectProps().selectedItem).toEqual({
+ label: "Sync",
+ value: "sync",
+ });
});
it("shows nothing selected", () => {
- const wrapper = createRenderer();
- expect(wrapper.root.findByType(FBSelect).props.selected).toEqual(undefined);
+ createRenderer();
+ expect(fbSelectProps().selectedItem).toEqual(undefined);
});
it("shows sequences", () => {
const p = fakeProps();
p.sequenceIdInput = 1;
- const wrapper = createRenderer();
- const { list } = wrapper.root.findByType(FBSelect).props;
+ createRenderer();
+ const { list } = fbSelectProps();
expect(list?.length).toEqual(11);
expect(list).toContainEqual({
isNull: true,
diff --git a/frontend/three_d_garden/__tests__/config_overlays_test.tsx b/frontend/three_d_garden/__tests__/config_overlays_test.tsx
index 38473186e7..c46ed4cf8d 100644
--- a/frontend/three_d_garden/__tests__/config_overlays_test.tsx
+++ b/frontend/three_d_garden/__tests__/config_overlays_test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { render, screen, fireEvent } from "@testing-library/react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import {
PublicOverlay, OverlayProps, PrivateOverlay, maybeAddParam,
} from "../config_overlays";
@@ -17,6 +17,7 @@ beforeEach(() => {
afterEach(() => {
setUrlParamSpy.mockRestore();
+ jest.useRealTimers();
});
describe("", () => {
@@ -34,6 +35,14 @@ describe("", () => {
expect(container.innerHTML).toContain("settings-bar");
});
+ it("marks settings bar as loaded", () => {
+ const p = fakeProps();
+ p.loadComplete = true;
+ const { container } = render();
+ expect(container.querySelector(".settings-bar-loaded")).toBeTruthy();
+ expect(container.querySelector(".settings-bar-content")).toBeTruthy();
+ });
+
it("changes preset", () => {
const p = fakeProps();
const { container } = render();
@@ -97,6 +106,23 @@ describe("", () => {
it("renders", () => {
const { container } = render();
expect(container.innerHTML).toContain("all-configs");
+ expect(container.querySelector("details")).toBeFalsy();
+ });
+
+ it("focuses the config search", () => {
+ const { getByPlaceholderText } = render();
+ expect(document.activeElement).toEqual(getByPlaceholderText("Search configs"));
+ });
+
+ it("filters configs", () => {
+ const { container, getByPlaceholderText } =
+ render();
+ fireEvent.change(getByPlaceholderText("Search configs"), {
+ target: { value: "promo" },
+ });
+ expect(container).toContainHTML("promoInfo");
+ expect(container).toContainHTML("promoSpread");
+ expect(container).not.toContainHTML("settingsBar");
});
it("changes value: number", () => {
@@ -167,6 +193,22 @@ describe("", () => {
});
});
+ it("closes the config menu with Escape", async () => {
+ const p = fakeProps();
+ render();
+ await waitFor(() =>
+ expect(document.activeElement).toEqual(screen.getByPlaceholderText(
+ "Search configs",
+ )));
+ fireEvent.keyDown(screen.getByPlaceholderText("Search configs"), {
+ key: "Escape",
+ });
+ expect(p.setConfig).toHaveBeenCalledWith({
+ ...p.config,
+ config: false,
+ });
+ });
+
it("removes url param", () => {
location.search = "?urlParamAutoAdd=true";
const p = fakeProps();
diff --git a/frontend/three_d_garden/__tests__/config_test.ts b/frontend/three_d_garden/__tests__/config_test.ts
index acc38bbc81..c2aac35f32 100644
--- a/frontend/three_d_garden/__tests__/config_test.ts
+++ b/frontend/three_d_garden/__tests__/config_test.ts
@@ -5,6 +5,10 @@ import {
} from "../config";
describe("modifyConfig()", () => {
+ it("enables labels on hover by default", () => {
+ expect(INITIAL.labelsOnHover).toEqual(true);
+ });
+
it("modifies config: lab", () => {
const initial = clone(INITIAL);
const result = modifyConfig(initial, { scene: "Lab" });
diff --git a/frontend/three_d_garden/__tests__/focus_transition_test.tsx b/frontend/three_d_garden/__tests__/focus_transition_test.tsx
new file mode 100644
index 0000000000..c3aa6d0baf
--- /dev/null
+++ b/frontend/three_d_garden/__tests__/focus_transition_test.tsx
@@ -0,0 +1,196 @@
+import React from "react";
+import { act, render, screen } from "@testing-library/react";
+import {
+ applyFocusMaterialOpacity,
+ applySmoothCameraState,
+ cameraTransitionValue,
+ createFocusMaterialBinding,
+ FOCUS_TRANSITION_MS,
+ FocusTransitionProvider,
+ FocusVisibilityDiv,
+ interpolateCameraState,
+ readSmoothCameraState,
+ shouldUnmountFocusVisibilityGroup,
+} from "../focus_transition";
+import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D } from "three";
+
+describe("focus transitions", () => {
+ it("keeps exiting DOM content mounted until the fade completes", () => {
+ jest.useFakeTimers();
+ const { rerender } = render(
+
+
+ panel
+
+ ,
+ );
+ rerender(
+
+
+ panel
+
+ ,
+ );
+ expect(screen.queryByText("panel")).not.toBeNull();
+ act(() => {
+ jest.advanceTimersByTime(FOCUS_TRANSITION_MS - 1);
+ });
+ expect(screen.queryByText("panel")).not.toBeNull();
+ act(() => {
+ jest.advanceTimersByTime(1);
+ });
+ expect(screen.queryByText("panel")).toBeNull();
+ jest.useRealTimers();
+ });
+
+ it("keeps expensive groups mounted when requested", () => {
+ expect(shouldUnmountFocusVisibilityGroup(false, false, true))
+ .toEqual(false);
+ expect(shouldUnmountFocusVisibilityGroup(false, false, false))
+ .toEqual(true);
+ expect(shouldUnmountFocusVisibilityGroup(false, true, false))
+ .toEqual(false);
+ expect(shouldUnmountFocusVisibilityGroup(true, false, false))
+ .toEqual(false);
+ });
+
+ it("isolates material opacity changes and restores originals", () => {
+ const root = new Object3D();
+ const material = new MeshBasicMaterial({
+ opacity: 0.5,
+ transparent: false,
+ depthWrite: true,
+ });
+ const mesh = new Mesh(new BoxGeometry(), material);
+ root.add(mesh);
+
+ const binding = createFocusMaterialBinding(root);
+ const clone = mesh.material;
+ expect(clone).not.toBe(material);
+
+ binding.apply(0.25);
+ expect(clone.opacity).toEqual(0.125);
+ expect(clone.transparent).toEqual(true);
+ expect(clone.depthWrite).toEqual(false);
+
+ binding.apply(1);
+ expect(clone.opacity).toEqual(0.5);
+ expect(clone.transparent).toEqual(false);
+ expect(clone.depthWrite).toEqual(true);
+
+ binding.restore();
+ expect(mesh.material).toBe(material);
+ });
+
+ it("applies material opacity without destroying original material state", () => {
+ const material = new MeshBasicMaterial({
+ opacity: 0.4,
+ transparent: true,
+ depthWrite: true,
+ });
+ applyFocusMaterialOpacity(material, {
+ opacity: 0.4,
+ transparent: true,
+ depthWrite: true,
+ }, 0.5);
+ expect(material.opacity).toEqual(0.2);
+ expect(material.transparent).toEqual(true);
+ expect(material.depthWrite).toEqual(false);
+ });
+
+ it("can preserve depth writing while fading material opacity", () => {
+ const material = new MeshBasicMaterial({
+ opacity: 0.4,
+ transparent: true,
+ depthWrite: true,
+ });
+ applyFocusMaterialOpacity(material, {
+ opacity: 0.4,
+ transparent: true,
+ depthWrite: true,
+ }, 0.5, true);
+ expect(material.opacity).toEqual(0.2);
+ expect(material.transparent).toEqual(true);
+ expect(material.depthWrite).toEqual(true);
+ });
+
+ it("keeps final camera coordinates unchanged", () => {
+ const fallback = {
+ position: [1, 2, 3] as [number, number, number],
+ target: [4, 5, 6] as [number, number, number],
+ zoom: 1,
+ };
+ const value = cameraTransitionValue({
+ position: [7, 8, 9],
+ target: [10, 11, 12],
+ zoom: 2,
+ }, fallback);
+ expect(value).toEqual({
+ position: [7, 8, 9],
+ target: [10, 11, 12],
+ zoom: 2,
+ });
+ });
+
+ it("interpolates camera state", () => {
+ const from = {
+ position: [0, 0, 0] as [number, number, number],
+ target: [10, 20, 30] as [number, number, number],
+ zoom: 1,
+ };
+ const to = {
+ position: [10, 20, 30] as [number, number, number],
+ target: [20, 40, 60] as [number, number, number],
+ zoom: 3,
+ };
+ expect(interpolateCameraState(from, to, 0.5)).toEqual({
+ position: [5, 10, 15],
+ target: [15, 30, 45],
+ zoom: 2,
+ });
+ });
+
+ it("applies camera state to camera and controls objects", () => {
+ const camera = {
+ position: { set: jest.fn() },
+ zoom: 1,
+ lookAt: jest.fn(),
+ updateProjectionMatrix: jest.fn(),
+ };
+ const controls = {
+ target: { set: jest.fn() },
+ update: jest.fn(),
+ };
+ applySmoothCameraState({
+ position: [1, 2, 3],
+ target: [4, 5, 6],
+ zoom: 2,
+ }, camera, controls);
+ expect(camera.position.set).toHaveBeenCalledWith(1, 2, 3);
+ expect(camera.zoom).toEqual(2);
+ expect(camera.lookAt).toHaveBeenCalledWith(4, 5, 6);
+ expect(camera.updateProjectionMatrix).toHaveBeenCalled();
+ expect(controls.target.set).toHaveBeenCalledWith(4, 5, 6);
+ expect(controls.update).toHaveBeenCalled();
+ });
+
+ it("reads live camera and controls state", () => {
+ const fallback = {
+ position: [1, 2, 3] as [number, number, number],
+ target: [4, 5, 6] as [number, number, number],
+ zoom: 1,
+ };
+ const camera = {
+ position: { x: 7, y: 8, z: 9, set: jest.fn() },
+ zoom: 2,
+ };
+ const controls = {
+ target: { x: 10, y: 11, z: 12, set: jest.fn() },
+ };
+ expect(readSmoothCameraState(fallback, camera, controls)).toEqual({
+ position: [7, 8, 9],
+ target: [10, 11, 12],
+ zoom: 2,
+ });
+ });
+});
diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx
index 36974f2ccb..bc98faff5d 100644
--- a/frontend/three_d_garden/__tests__/garden_model_test.tsx
+++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx
@@ -3,15 +3,17 @@ let mockIsMobile = false;
import React from "react";
import { OrbitControls, useTexture } from "@react-three/drei";
-import { GardenModelProps, GardenModel } from "../garden_model";
+import {
+ GardenModelProps, GardenModel, SMOOTH_XL_CAMERA_BED_SCALE,
+ SMOOTH_XL_CAMERA_HEIGHT_SCALE,
+} from "../garden_model";
import { clone } from "lodash";
import { INITIAL, INITIAL_POSITION, SurfaceDebugOption } from "../config";
-import { render } from "@testing-library/react";
+import { render, waitFor } from "@testing-library/react";
import {
fakePlant, fakePoint, fakeSensor, fakeSensorReading, fakeWeed,
} from "../../__test_support__/fake_state/resources";
import { fakeAddPlantProps } from "../../__test_support__/fake_props";
-import { ASSETS } from "../constants";
import { Path } from "../../internal_urls";
import { fakeDrawnPoint } from "../../__test_support__/fake_designer_state";
import { convertPlants } from "../../farm_designer/three_d_garden_map";
@@ -22,6 +24,8 @@ import {
unmountRenderer,
} from "../../__test_support__/test_renderer";
import { PLANT_ICON_ATLAS } from "../garden/plant_icon_atlas";
+import { cameraInit } from "../camera";
+import { getCamera } from "../zoom_beacons_constants";
let isDesktopSpy: jest.SpyInstance;
let isMobileSpy: jest.SpyInstance;
@@ -31,14 +35,14 @@ const mountedWrappers: ReturnType[] = [];
describe("", () => {
beforeEach(() => {
+ console.log = jest.fn();
mockIsDesktop = false;
mockIsMobile = false;
- let useStateCalls = 0;
useStateSpy = jest.spyOn(React, "useState")
// eslint-disable-next-line comma-spacing
.mockImplementation((initialState?: S | (() => S)) => {
- useStateCalls += 1;
- if (useStateCalls == 2) {
+ // eslint-disable-next-line no-null/no-null
+ if (initialState === null) {
return [{}, jest.fn()];
}
const value = typeof initialState == "function"
@@ -77,11 +81,35 @@ describe("", () => {
return wrapper;
};
- it("renders", () => {
+ it("renders", async () => {
const { container } = render();
- expect(container.innerHTML).toContain("zoom-beacons");
+ await waitFor(() =>
+ expect(container.innerHTML).toContain("zoom-beacons"));
expect(container.innerHTML).not.toContain("stats");
expect(container.innerHTML).toContain("darkgreen");
+ expect(container.innerHTML).toContain("bed-load-in");
+ expect(container.innerHTML).toContain("grid-load-in");
+ expect(container.innerHTML).toContain("plants-load-in");
+ expect(container.innerHTML).toContain("points-load-in");
+ expect(container.innerHTML).toContain("weeds-load-in");
+ expect(container.innerHTML).toContain("zoom-beacons-load-in");
+ expect(container.innerHTML).toContain("farmbot-scene-boundary");
+ expect(container.innerHTML).toContain("details-scene-boundary");
+ });
+
+ it("notifies when the progressive reveal completes", async () => {
+ const p = fakeProps();
+ p.onLoadComplete = jest.fn();
+ render();
+ await waitFor(() => expect(p.onLoadComplete).toHaveBeenCalled());
+ });
+
+ it("notifies when scene details begin revealing", async () => {
+ const p = fakeProps();
+ p.onDetailsRevealStart = jest.fn();
+ render();
+ await waitFor(() =>
+ expect(p.onDetailsRevealStart).toHaveBeenCalled());
});
it("renders top down view", () => {
@@ -124,12 +152,60 @@ describe("", () => {
expect(camera?.props.zoom).toEqual(0.5);
});
- it("renders camera selection view", () => {
+ it("keeps focused camera coordinates with smooth transitions disabled", () => {
+ const p = fakeProps();
+ p.activeFocus = "What you can grow";
+ p.smoothFocusTransitions = true;
+ p.config.animate = false;
+ const wrapper = createWrapper(p);
+ const camera = wrapper.root.findAll(node => node.props.name == "camera")[0];
+ const defaultCamera = cameraInit({
+ topDown: p.config.topDown,
+ viewpointHeading: p.config.viewpointHeading,
+ bedSize: { x: p.config.bedLengthOuter, y: p.config.bedWidthOuter },
+ });
+ const expectedCamera = getCamera(
+ p.config,
+ p.configPosition,
+ p.activeFocus,
+ defaultCamera,
+ );
+ expect(camera?.props.position).toEqual(expectedCamera.position);
+ expect(wrapper.root.findByType(OrbitControls).props.target)
+ .toEqual(expectedCamera.target);
+ });
+
+ it("moves the smooth XL default camera back and higher", () => {
+ const p = fakeProps();
+ p.smoothFocusTransitions = true;
+ p.config.animate = false;
+ p.config.sizePreset = "Genesis XL";
+ p.config.bedLengthOuter = 6000;
+ p.config.bedWidthOuter = 2860;
+ const wrapper = createWrapper(p);
+ const camera = wrapper.root.findAll(node => node.props.name == "camera")[0];
+ const expectedCamera = cameraInit({
+ topDown: p.config.topDown,
+ viewpointHeading: p.config.viewpointHeading,
+ bedSize: {
+ x: p.config.bedLengthOuter * SMOOTH_XL_CAMERA_BED_SCALE,
+ y: p.config.bedWidthOuter * SMOOTH_XL_CAMERA_BED_SCALE,
+ },
+ });
+ expect(camera?.props.position).toEqual([
+ expectedCamera.position[0],
+ expectedCamera.position[1],
+ expectedCamera.position[2] * SMOOTH_XL_CAMERA_HEIGHT_SCALE,
+ ]);
+ });
+
+ it("renders camera selection view", async () => {
const p = fakeProps();
p.config.cameraSelectionView = true;
p.config.viewpointHeading = 45;
const { container } = render();
- expect(container.innerHTML).toContain("camera-selection");
+ await waitFor(() =>
+ expect(container.innerHTML).toContain("camera-selection"));
});
it("renders no user plants", () => {
@@ -144,12 +220,26 @@ describe("", () => {
const p = fakeProps();
const plant = fakePlant();
plant.body.name = "Beet";
+ p.config.labels = true;
+ p.config.labelsOnHover = false;
p.threeDPlants = convertPlants(p.config, [plant]);
const { queryAllByText } = render();
const plantLabels = queryAllByText("Beet");
expect(plantLabels.length).toEqual(1);
});
+ it("doesn't build plant label nodes when labels are disabled", () => {
+ const p = fakeProps();
+ const plant = fakePlant();
+ plant.body.name = "Beet";
+ p.config.labels = false;
+ p.config.labelsOnHover = false;
+ p.threeDPlants = convertPlants(p.config, [plant]);
+ const { queryAllByText } = render();
+ const plantLabels = queryAllByText("Beet");
+ expect(plantLabels.length).toEqual(0);
+ });
+
it("preloads the atlas texture for mapped plant icons", () => {
PLANT_ICON_ATLAS["/crops/icons/beet.avif"] = {
atlasUrl: "/crops/icons/atlas.avif",
@@ -184,15 +274,14 @@ describe("", () => {
it("renders only the hovered label when labels on hover are enabled", () => {
useStateSpy.mockRestore();
- let useStateCalls = 0;
useStateSpy = jest.spyOn(React, "useState")
// eslint-disable-next-line comma-spacing
.mockImplementation((initialState?: S | (() => S)) => {
- useStateCalls += 1;
- if (useStateCalls == 1) {
+ if (initialState === undefined) {
return [0, jest.fn()];
}
- if (useStateCalls == 2) {
+ // eslint-disable-next-line no-null/no-null
+ if (initialState === null) {
return [{}, jest.fn()];
}
const value = typeof initialState == "function"
@@ -216,8 +305,8 @@ describe("", () => {
p.mapPoints = [fakePoint()];
p.weeds = [fakeWeed()];
const { container } = render();
- expect(container.innerHTML).toContain("cylinder");
- expect(container.innerHTML).toContain(ASSETS.other.weed);
+ expect(container.innerHTML).toContain("marker");
+ expect(container.innerHTML).toContain("weed-icons");
});
it("renders drawn point", () => {
@@ -238,7 +327,16 @@ describe("", () => {
expect(container.innerHTML).not.toContain('name="bot"');
});
- it("renders other options", () => {
+ it("completes the progressive reveal without FarmBot", async () => {
+ const p = fakeProps();
+ p.addPlantProps = fakeAddPlantProps();
+ p.addPlantProps.getConfigValue = () => false;
+ p.onLoadComplete = jest.fn();
+ render();
+ await waitFor(() => expect(p.onLoadComplete).toHaveBeenCalled());
+ });
+
+ it("renders other options", async () => {
mockIsDesktop = false;
const p = fakeProps();
p.config.perspective = false;
@@ -256,7 +354,7 @@ describe("", () => {
p.addPlantProps = undefined;
const { container } = render();
expect(container.innerHTML).toContain("gray");
- expect(container.innerHTML).toContain("stats");
+ await waitFor(() => expect(container.innerHTML).toContain("stats"));
});
it("renders debug options", () => {
diff --git a/frontend/three_d_garden/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx
index 2937660acc..66da3f09c1 100644
--- a/frontend/three_d_garden/__tests__/index_test.tsx
+++ b/frontend/three_d_garden/__tests__/index_test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react";
import {
ThreeDGardenProps, ThreeDGarden, ThreeDGardenToggle, ThreeDGardenToggleProps,
} from "../index";
+import * as reactThreeFiber from "@react-three/fiber";
import { INITIAL, INITIAL_POSITION } from "../config";
import { clone } from "lodash";
import { fakeAddPlantProps } from "../../__test_support__/fake_props";
@@ -14,12 +15,20 @@ import { BooleanSetting } from "../../session_keys";
import { fakeDevice } from "../../__test_support__/resource_index_builder";
beforeEach(() => {
+ console.log = jest.fn();
+ window.localStorage.clear();
+ delete window.__fbPerf;
jest.spyOn(configStorageActions, "getWebAppConfigValue")
.mockImplementation(() => () => false);
jest.spyOn(configStorageActions, "setWebAppConfigValue")
.mockImplementation(jest.fn());
});
+afterEach(() => {
+ window.localStorage.clear();
+ delete window.__fbPerf;
+});
+
describe("", () => {
const fakeProps = (): ThreeDGardenProps => ({
config: clone(INITIAL),
@@ -34,6 +43,24 @@ describe("", () => {
const { container } = render();
expect(container).toContainHTML("three-d-garden");
});
+
+ it("disables canvas shadows in low-detail mode", () => {
+ const canvasSpy = jest.spyOn(reactThreeFiber, "Canvas");
+ const p = fakeProps();
+ p.config.lowDetail = true;
+ render();
+ expect(canvasSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ shadows: false }),
+ undefined);
+ canvasSpy.mockRestore();
+ });
+
+ it("counts benchmark renders", () => {
+ window.localStorage.setItem("FB_PERF_BENCHMARK", "true");
+ render();
+ expect(window.__fbPerf?.counts["render.ThreeDGarden"]).toEqual(1);
+ expect(window.__fbPerf?.marks.three_d_garden_mounted.length).toEqual(1);
+ });
});
describe("", () => {
diff --git a/frontend/three_d_garden/__tests__/progressive_load_test.tsx b/frontend/three_d_garden/__tests__/progressive_load_test.tsx
new file mode 100644
index 0000000000..4ebb52e849
--- /dev/null
+++ b/frontend/three_d_garden/__tests__/progressive_load_test.tsx
@@ -0,0 +1,214 @@
+import React from "react";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import {
+ FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup,
+ THREE_D_LOAD_PROGRESS_FADE_MS, THREE_D_LOAD_STEPS,
+ ThreeDLoadProgressOverlay, useThreeDLoadProgress,
+} from "../progressive_load";
+
+describe("", () => {
+ it("renders children inside a named load-in group", () => {
+ const { container } = render(
+ content
+ );
+
+ expect(container.innerHTML).toContain("bed-load-in");
+ expect(screen.getByText("content")).toBeTruthy();
+ });
+
+ it("keeps children mounted without resting before reveal", () => {
+ const onRest = jest.fn();
+ const { rerender } = render(
+ content
+ );
+
+ expect(screen.getByText("content")).toBeTruthy();
+ expect(onRest).not.toHaveBeenCalled();
+
+ rerender(
+ content
+ );
+
+ expect(onRest).toHaveBeenCalled();
+ });
+});
+
+describe("", () => {
+ it("renders children inside a named load-in group", () => {
+ const { container } = render(
+ bot
+ );
+
+ expect(container.innerHTML).toContain("bot-load-in");
+ expect(screen.getByText("bot")).toBeTruthy();
+ });
+});
+
+describe("", () => {
+ it("renders children inside a named load-in group", () => {
+ const { container } = render(
+ grid
+ );
+
+ expect(container.innerHTML).toContain("grid-load-in");
+ expect(screen.getByText("grid")).toBeTruthy();
+ });
+});
+
+describe("3D load progress", () => {
+ const ProgressHarness = () => {
+ const progress = useThreeDLoadProgress();
+ const currentStep = progress.currentStep;
+ return
+
+
{currentStep?.id || "complete"}
+
+ {"" + progress.isStepAllowed("bed")}
+
+
+ {"" + progress.isStepAllowed("grid")}
+
+
+ {"" + progress.isStepAllowed("plants")}
+
+
+ {"" + progress.isStepAllowed("weeds")}
+
+
+ {"" + progress.isStepAllowed("points")}
+
+
+ {"" + progress.isStepAllowed("farmbot")}
+
+
+ {"" + progress.isStepAllowed("details")}
+
+
+
+
+
+
+
+
;
+ };
+
+ it("marks ready steps and hides the progress bar when complete", () => {
+ jest.useFakeTimers();
+ const consoleLog = jest.spyOn(console, "log").mockImplementation(jest.fn());
+ render();
+
+ expect(screen.getByTestId("current-step").textContent)
+ .toEqual("environment");
+ expect(screen.getByTestId("bed-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("grid-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("plants-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("points-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("details-allowed").textContent).toEqual("false");
+ expect(document.querySelector(".three-d-load-progress")).toBeTruthy();
+ THREE_D_LOAD_STEPS.forEach(step => {
+ expect(screen.getByTestId("current-step").textContent).toEqual(step.id);
+ fireEvent.click(screen.getByText("advance"));
+ });
+
+ expect(screen.getByTestId("current-step").textContent).toEqual("complete");
+ expect(screen.getByText("Enjoy!")).toBeTruthy();
+ expect(document.querySelector(".three-d-load-progress-complete"))
+ .toBeTruthy();
+ act(() => {
+ jest.advanceTimersByTime(THREE_D_LOAD_PROGRESS_FADE_MS);
+ });
+ expect(document.querySelector(".three-d-load-progress")).toBeFalsy();
+ expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining("Total"));
+ consoleLog.mockRestore();
+ jest.useRealTimers();
+ });
+
+ it("allows steps as soon as their dependencies are ready", () => {
+ render();
+
+ fireEvent.click(screen.getByText("mark environment"));
+ expect(screen.getByTestId("bed-allowed").textContent).toEqual("true");
+ expect(screen.getByTestId("grid-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("plants-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("points-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false");
+
+ fireEvent.click(screen.getByText("mark bed"));
+ expect(screen.getByTestId("grid-allowed").textContent).toEqual("true");
+ expect(screen.getByTestId("plants-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("weeds-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("points-allowed").textContent).toEqual("false");
+ expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("false");
+
+ fireEvent.click(screen.getByText("mark grid"));
+ expect(screen.getByTestId("plants-allowed").textContent).toEqual("true");
+ expect(screen.getByTestId("weeds-allowed").textContent).toEqual("true");
+ expect(screen.getByTestId("points-allowed").textContent).toEqual("true");
+ expect(screen.getByTestId("farmbot-allowed").textContent).toEqual("true");
+ expect(screen.getByTestId("details-allowed").textContent).toEqual("false");
+
+ fireEvent.click(screen.getByText("mark plants"));
+ expect(screen.getByTestId("details-allowed").textContent).toEqual("false");
+
+ fireEvent.click(screen.getByText("mark FarmBot"));
+ expect(screen.getByTestId("details-allowed").textContent).toEqual("true");
+ });
+
+ it("marks one step ready", () => {
+ const markStep = jest.fn();
+ render();
+ expect(markStep).toHaveBeenCalledWith("plants");
+ });
+
+ it("can hide before all progress steps are ready", () => {
+ jest.useFakeTimers();
+ const CompleteHarness = ({ complete }: { complete: boolean }) => {
+ const progress = useThreeDLoadProgress();
+ return ;
+ };
+
+ const { rerender } = render();
+ expect(document.querySelector(".three-d-load-progress")).toBeTruthy();
+
+ rerender();
+
+ expect(screen.getByText("Enjoy!")).toBeTruthy();
+ expect(document.querySelector(".three-d-load-progress-complete"))
+ .toBeTruthy();
+ act(() => {
+ jest.advanceTimersByTime(THREE_D_LOAD_PROGRESS_FADE_MS);
+ });
+ expect(document.querySelector(".three-d-load-progress")).toBeFalsy();
+ jest.useRealTimers();
+ });
+
+ it("doesn't block clicks outside the progress bar", () => {
+ render();
+
+ const html = document.querySelector(".html") as HTMLElement;
+ expect(html.style.pointerEvents).toEqual("none");
+ });
+});
diff --git a/frontend/three_d_garden/__tests__/texture_variants_test.ts b/frontend/three_d_garden/__tests__/texture_variants_test.ts
new file mode 100644
index 0000000000..56813922d5
--- /dev/null
+++ b/frontend/three_d_garden/__tests__/texture_variants_test.ts
@@ -0,0 +1,69 @@
+import { RepeatWrapping, Texture } from "three";
+import { renderHook } from "@testing-library/react";
+import { useTexture } from "@react-three/drei";
+import {
+ getTextureVariant, textureVariantKey, useTextureVariant,
+} from "../texture_variants";
+
+describe("texture variants", () => {
+ it("builds stable keys", () => {
+ expect(textureVariantKey({
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.02, 0.05],
+ offset: [0.25, 0.5],
+ rotation: Math.PI / 2,
+ })).toEqual("1000|1000|0.02,0.05|0.25,0.5|1.5707963267948966");
+ });
+
+ it("reuses identical variants for the same base texture", () => {
+ const baseTexture = new Texture();
+ const options = {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.02, 0.05] as [number, number],
+ };
+
+ const first = getTextureVariant(baseTexture, options);
+ const second = getTextureVariant(baseTexture, options);
+
+ expect(first).toBe(second);
+ expect(first).not.toBe(baseTexture);
+ expect(first.wrapS).toEqual(RepeatWrapping);
+ expect(first.wrapT).toEqual(RepeatWrapping);
+ expect(first.repeat.x).toEqual(0.02);
+ expect(first.repeat.y).toEqual(0.05);
+ });
+
+ it("keeps different variant options isolated", () => {
+ const baseTexture = new Texture();
+
+ const first = getTextureVariant(baseTexture, {
+ wrapS: RepeatWrapping,
+ repeat: [0.02, 0.05],
+ });
+ const second = getTextureVariant(baseTexture, {
+ wrapS: RepeatWrapping,
+ repeat: [0.3, 0.3],
+ });
+
+ expect(first).not.toBe(second);
+ expect(first.repeat.x).toEqual(0.02);
+ expect(second.repeat.x).toEqual(0.3);
+ });
+
+ it("loads a base texture and returns a cached variant", () => {
+ (useTexture as unknown as jest.Mock).mockClear();
+
+ const { result } = renderHook(() =>
+ useTextureVariant("/3D/textures/wood.avif", {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.02, 0.05],
+ }));
+
+ expect(useTexture).toHaveBeenCalledWith("/3D/textures/wood.avif");
+ expect(result.current.wrapS).toEqual(RepeatWrapping);
+ expect(result.current.wrapT).toEqual(RepeatWrapping);
+ });
+});
diff --git a/frontend/three_d_garden/__tests__/triangle_functions_test.ts b/frontend/three_d_garden/__tests__/triangle_functions_test.ts
index 1c3332409e..54899e17d4 100644
--- a/frontend/three_d_garden/__tests__/triangle_functions_test.ts
+++ b/frontend/three_d_garden/__tests__/triangle_functions_test.ts
@@ -1,4 +1,6 @@
-import { getZFunc, precomputeTriangles } from "../triangle_functions";
+import {
+ getZFunc, parseStoredTriangles, precomputeTriangles, serializeTriangles,
+} from "../triangle_functions";
describe("precomputeTriangles()", () => {
it("computes triangles: zero", () => {
@@ -19,6 +21,10 @@ describe("precomputeTriangles()", () => {
b: [4, 1, 0],
c: [2, 3, 0],
det: 6,
+ maxX: 4,
+ maxY: 3,
+ minX: 1,
+ minY: 1,
x1: 1,
x2: 4,
x3: 2,
@@ -40,6 +46,10 @@ describe("getZFunc()", () => {
b: [2, 0, 20],
c: [0, 2, 30],
det: 4,
+ maxX: 2,
+ maxY: 2,
+ minX: 0,
+ minY: 0,
x1: 0,
x2: 2,
x3: 0,
@@ -48,4 +58,102 @@ describe("getZFunc()", () => {
y3: 2,
}], -100)(1, 1)).toEqual(25);
});
+
+ it("caches Z by coordinate and invalidates with a new function", () => {
+ const triangle = {
+ a: [0, 0, 10] as [number, number, number],
+ b: [2, 0, 20] as [number, number, number],
+ c: [0, 2, 30] as [number, number, number],
+ det: 4,
+ maxX: 2,
+ maxY: 2,
+ minX: 0,
+ minY: 0,
+ x1: 0,
+ x2: 2,
+ x3: 0,
+ y1: 0,
+ y2: 0,
+ y3: 2,
+ };
+ const getZ = getZFunc([triangle], -100);
+ expect(getZ(1, 1)).toEqual(25);
+ triangle.c[2] = 300;
+ expect(getZ(1, 1)).toEqual(25);
+ expect(getZFunc([triangle], -100)(1, 1)).toEqual(160);
+ });
+
+ it("gets Z through the spatial index", () => {
+ const triangles = precomputeTriangles([
+ [0, 0, 10],
+ [10, 0, 20],
+ [0, 10, 30],
+ [20, 0, 40],
+ [30, 0, 50],
+ [20, 10, 60],
+ [0, 20, 70],
+ [10, 20, 80],
+ [0, 30, 90],
+ [20, 20, 100],
+ [30, 20, 110],
+ [20, 30, 120],
+ ], [
+ 0, 1, 2,
+ 3, 4, 5,
+ 6, 7, 8,
+ 9, 10, 11,
+ ]);
+ expect(getZFunc(triangles, -100)(25, 25)).toEqual(115);
+ expect(getZFunc(triangles, -100)(15, 15)).toEqual(-100);
+ });
+
+ it("keeps original triangle priority in indexed buckets", () => {
+ const triangles = precomputeTriangles([
+ [0, 0, 10],
+ [10, 0, 10],
+ [0, 10, 10],
+ [0, 0, 20],
+ [10, 0, 20],
+ [0, 10, 20],
+ [20, 0, 30],
+ [30, 0, 30],
+ [20, 10, 30],
+ [0, 20, 40],
+ [10, 20, 40],
+ [0, 30, 40],
+ ], [
+ 0, 1, 2,
+ 3, 4, 5,
+ 6, 7, 8,
+ 9, 10, 11,
+ ]);
+ expect(getZFunc(triangles, -100)(1, 1)).toEqual(10);
+ });
+});
+
+describe("stored triangles", () => {
+ it("serializes compact triangles", () => {
+ const triangles = precomputeTriangles([
+ [0, 0, 10],
+ [2, 0, 20],
+ [0, 2, 30],
+ ], [0, 1, 2]);
+ expect(parseStoredTriangles(serializeTriangles(triangles)))
+ .toEqual(triangles);
+ });
+
+ it("parses legacy triangle objects", () => {
+ const triangles = precomputeTriangles([
+ [0, 0, 10],
+ [2, 0, 20],
+ [0, 2, 30],
+ ], [0, 1, 2]);
+ expect(parseStoredTriangles(JSON.stringify(triangles)))
+ .toEqual(triangles);
+ });
+
+ it("ignores invalid stored triangles", () => {
+ expect(parseStoredTriangles("[\"foo\"]")).toEqual([]);
+ expect(parseStoredTriangles("not json")).toEqual([]);
+ });
});
diff --git a/frontend/three_d_garden/__tests__/visualization_test.tsx b/frontend/three_d_garden/__tests__/visualization_test.tsx
index 2b880c4645..9c6f04f523 100644
--- a/frontend/three_d_garden/__tests__/visualization_test.tsx
+++ b/frontend/three_d_garden/__tests__/visualization_test.tsx
@@ -19,6 +19,7 @@ let originalGetState: typeof store.getState;
describe("", () => {
beforeEach(() => {
+ console.log = jest.fn();
originalGetState = store.getState;
(store as unknown as { getState: () => { resources: typeof mockResources } })
.getState = () => ({ resources: mockResources });
diff --git a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx
index 06099b9c6c..6f7d0e610d 100644
--- a/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx
+++ b/frontend/three_d_garden/__tests__/zoom_beacons_constants_test.tsx
@@ -55,7 +55,10 @@ describe("getCameraOffset()", () => {
const config = clone(INITIAL);
const configPosition = clone(INITIAL_POSITION);
const focus = FOCI(config, configPosition)[0];
- expect(getCameraOffset(focus).position[1]).toEqual(0);
+ expect(getCameraOffset(focus).position[1])
+ .toEqual(getCameraOffset(focus).target[1] - 25);
+ expect(getCameraOffset(focus).position[0])
+ .toEqual(getCameraOffset(focus).target[0]);
});
it("returns camera offset: narrow", () => {
@@ -63,7 +66,10 @@ describe("getCameraOffset()", () => {
const config = clone(INITIAL);
const configPosition = clone(INITIAL_POSITION);
const focus = FOCI(config, configPosition)[0];
- expect(getCameraOffset(focus).position[1]).toEqual(-1000);
+ expect(getCameraOffset(focus).position[1])
+ .toEqual(getCameraOffset(focus).target[1] - 25);
+ expect(getCameraOffset(focus).position[0])
+ .toEqual(getCameraOffset(focus).target[0]);
});
});
@@ -75,8 +81,13 @@ describe("getCamera()", () => {
position: [0, 0, 0],
target: [0, 0, 0],
};
- expect(getCamera(config, configPosition, "What you can grow", fallback).position[0])
- .toEqual(0);
+ const camera = getCamera(
+ config,
+ configPosition,
+ "What you can grow",
+ fallback,
+ );
+ expect(camera.position[1]).not.toEqual(camera.target[1]);
});
});
@@ -99,14 +110,14 @@ describe("setUrlParam()", () => {
});
it("sets URL param", () => {
- history.replaceState(undefined, "", "/app/designer");
+ window.location.search = "";
setUrlParam("focus", "What you can grow");
const url = getPushedUrl();
expect(url.searchParams.get("focus")).toEqual("What you can grow");
});
it("removes URL param", () => {
- history.replaceState(undefined, "", "/app/designer?focus=What+you+can+grow");
+ window.location.search = "?focus=What+you+can+grow";
setUrlParam("focus", "");
const url = getPushedUrl();
expect(url.searchParams.get("focus")).toBeNull();
diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx
index 119f5523c6..a0e8c5956b 100644
--- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx
+++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx
@@ -132,7 +132,7 @@ describe("", () => {
afterEach(() => {
requestAnimationFrameSpy.mockRestore();
- history.replaceState(undefined, "", originalPathname);
+ location.pathname = originalPathname;
});
const fakeProps = (): BedProps => ({
@@ -169,7 +169,7 @@ describe("", () => {
["renders", SpecialStatus.SAVED],
])("%s pointer point", (title, gridPointSpecialStatus) => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.points("add"));
+ location.pathname = Path.mock(Path.points("add"));
mockIsMobile = false;
const p = fakeProps();
p.addPlantProps = fakeAddPlantProps();
@@ -194,7 +194,7 @@ describe("", () => {
it("adds a plant", () => {
getModeSpy.mockReturnValue(Mode.clickToAdd);
- history.replaceState(undefined, "", Path.cropSearch("mint"));
+ location.pathname = Path.mock(Path.cropSearch("mint"));
const p = fakeProps();
p.addPlantProps = fakeAddPlantProps();
const { container } = render();
@@ -207,7 +207,7 @@ describe("", () => {
it("doesn't add a drawn point", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.points("add"));
+ location.pathname = Path.mock(Path.points("add"));
const p = fakeProps();
const addPlantProps = fakeAddPlantProps();
addPlantProps.designer.drawnPoint = undefined;
@@ -220,7 +220,7 @@ describe("", () => {
it("adds a drawn point: xy", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.points("add"));
+ location.pathname = Path.mock(Path.points("add"));
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
const p = fakeProps();
const addPlantProps = fakeAddPlantProps();
@@ -242,7 +242,7 @@ describe("", () => {
it("adds a drawn point: radius", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.points("add"));
+ location.pathname = Path.mock(Path.points("add"));
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
const p = fakeProps();
const addPlantProps = fakeAddPlantProps();
@@ -264,7 +264,7 @@ describe("", () => {
it("updates pointer plant position", () => {
getModeSpy.mockReturnValue(Mode.clickToAdd);
- history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint")));
+ location.pathname = Path.mock(Path.cropSearch("mint"));
mockIsMobile = false;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } };
@@ -281,7 +281,7 @@ describe("", () => {
it("handles missing ref", () => {
getModeSpy.mockReturnValue(Mode.clickToAdd);
- history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint")));
+ location.pathname = Path.mock(Path.cropSearch("mint"));
mockIsMobile = false;
mockPlantRef.current = undefined;
mockXCrosshairRef.current = undefined;
@@ -297,7 +297,7 @@ describe("", () => {
it("handles missing crosshair refs", () => {
getModeSpy.mockReturnValue(Mode.clickToAdd);
- history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint")));
+ location.pathname = Path.mock(Path.cropSearch("mint"));
mockIsMobile = false;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockXCrosshairRef.current = undefined;
@@ -314,7 +314,7 @@ describe("", () => {
it("doesn't update pointer plant position: mobile", () => {
getModeSpy.mockReturnValue(Mode.clickToAdd);
- history.replaceState(undefined, "", Path.mock(Path.cropSearch("mint")));
+ location.pathname = Path.mock(Path.cropSearch("mint"));
mockIsMobile = true;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } };
@@ -329,7 +329,7 @@ describe("", () => {
it("doesn't update pointer point position", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.mock(Path.points("add")));
+ location.pathname = Path.mock(Path.points("add"));
mockIsMobile = false;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } };
@@ -345,7 +345,7 @@ describe("", () => {
it("updates pointer point position", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.mock(Path.points("add")));
+ location.pathname = Path.mock(Path.points("add"));
mockIsMobile = false;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockXCrosshairRef.current = { position: { set: mockSetXCrosshairPosition } };
@@ -367,7 +367,7 @@ describe("", () => {
it("updates pointer point radius", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.mock(Path.points("add")));
+ location.pathname = Path.mock(Path.points("add"));
mockIsMobile = false;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockRadiusRef.current = { scale: { set: mockSetRadiusScale } };
@@ -393,7 +393,7 @@ describe("", () => {
it("doesn't update pointer point radius: no ref", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.mock(Path.points("add")));
+ location.pathname = Path.mock(Path.points("add"));
mockIsMobile = false;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockRadiusRef.current = undefined;
@@ -419,7 +419,7 @@ describe("", () => {
it("doesn't update pointer point radius: already set", () => {
getModeSpy.mockReturnValue(Mode.createPoint);
- history.replaceState(undefined, "", Path.mock(Path.points("add")));
+ location.pathname = Path.mock(Path.points("add"));
mockIsMobile = false;
mockPlantRef.current = { position: { set: mockSetPlantPosition } };
mockRadiusRef.current = { scale: { set: mockSetRadiusScale } };
diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx
index 5a6b47bd3b..4f1eda4664 100644
--- a/frontend/three_d_garden/bed/bed.tsx
+++ b/frontend/three_d_garden/bed/bed.tsx
@@ -1,6 +1,6 @@
import React from "react";
import {
- Box, Detailed, Extrude, Plane, useHelper, useTexture,
+ Box, Detailed, Extrude, Plane, useHelper,
} from "@react-three/drei";
import {
DoubleSide,
@@ -46,6 +46,8 @@ import { VertexNormalsHelper } from "three/examples/jsm/Addons.js";
import { MoistureSurface } from "../garden/moisture_texture";
import { HeightMaterial } from "../garden/height_material";
import { soilSurfaceExtents } from "../triangles";
+import { FocusVisibilityGroup } from "../focus_transition";
+import { useTextureVariant } from "../texture_variants";
const soil = (
Type: typeof LinePath | typeof Shape,
@@ -205,22 +207,16 @@ export const Bed = (props: BedProps) => {
];
const casterHeight = legSize * 1.375;
- const bedWoodTextureBase = useTexture(ASSETS.textures.wood + "?=bedWood");
- const bedWoodTexture = React.useMemo(() => {
- const texture = bedWoodTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.0003, 0.003);
- return texture;
- }, [bedWoodTextureBase]);
- const legWoodTextureBase = useTexture(ASSETS.textures.wood + "?=legWood");
- const legWoodTexture = React.useMemo(() => {
- const texture = legWoodTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.02, 0.05);
- return texture;
- }, [legWoodTextureBase]);
+ const bedWoodTexture = useTextureVariant(ASSETS.textures.wood, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.0003, 0.003],
+ });
+ const legWoodTexture = useTextureVariant(ASSETS.textures.wood, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.02, 0.05],
+ });
// eslint-disable-next-line no-null/no-null
const pointerPlantRef: PointerPlantRef = React.useRef(null);
@@ -382,7 +378,8 @@ export const Bed = (props: BedProps) => {
]}>
-
{
y: threeSpace(bedWidthOuter, bedWidthOuter),
z: groundZ,
}} />
-
+
{
gardenCoords: { x: 1360, y: 660 },
}));
});
+
+ it("doesn't create a plant after a drag", () => {
+ location.pathname = Path.mock(Path.cropSearch("mint"));
+ mockIsMobile = false;
+ const p = fakeProps();
+ const e = {
+ stopPropagation: jest.fn(),
+ point: { x: 1, y: 2 },
+ delta: 3,
+ } as unknown as ThreeEvent;
+ soilClick(p)(e);
+ expect(e.stopPropagation).toHaveBeenCalled();
+ expect(dropPlantSpy).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ["point", Path.points("add")],
+ ["weed", Path.weeds("add")],
+ ])("doesn't set %s location after a drag", (_label, path) => {
+ location.pathname = Path.mock(path);
+ mockIsMobile = false;
+ const p = fakeProps();
+ const point = fakeDrawnPoint();
+ point.cx = undefined;
+ point.cy = undefined;
+ point.r = 0;
+ p.addPlantProps.designer.drawnPoint = point;
+ const e = {
+ stopPropagation: jest.fn(),
+ point: { x: 1, y: 2 },
+ delta: 3,
+ } as unknown as ThreeEvent;
+ soilClick(p)(e);
+ expect(e.stopPropagation).toHaveBeenCalled();
+ expect(p.addPlantProps.dispatch).not.toHaveBeenCalled();
+ });
});
describe("soilPointerMove()", () => {
diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx
index 2993f9048f..e856b368ee 100644
--- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx
+++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx
@@ -41,6 +41,7 @@ import {
getPlantIconTexture,
getPlantIconTextureUrl,
} from "../../garden/plant_icon_atlas";
+import { clickWasDragged } from "../../click_event";
export type PointerPlantRef = React.RefObject;
export type RadiusRef = React.RefObject;
@@ -183,6 +184,7 @@ export const soilClick = (props: SoilClickProps) =>
const { config, navigate, addPlantProps, pointerPlantRef } = props;
const getGardenPosition = getGardenPositionFunc(config);
e.stopPropagation();
+ if (clickWasDragged(e)) { return; }
if (addPlantProps) {
if (getMode() == Mode.clickToAdd) {
dropPlant({
diff --git a/frontend/three_d_garden/bed/objects/utilities_post.tsx b/frontend/three_d_garden/bed/objects/utilities_post.tsx
index 686ae32759..f4da9248b3 100644
--- a/frontend/three_d_garden/bed/objects/utilities_post.tsx
+++ b/frontend/three_d_garden/bed/objects/utilities_post.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { Box, Cylinder, RoundedBox, Tube, useTexture } from "@react-three/drei";
+import { Box, Cylinder, RoundedBox, Tube } from "@react-three/drei";
import { RepeatWrapping } from "three";
import { ASSETS } from "../../constants";
import { Config } from "../../config";
@@ -9,6 +9,8 @@ import {
import { outletDepth } from "../../bot";
import * as THREE from "three";
import { Group, MeshPhongMaterial } from "../../components";
+import { FocusVisibilityGroup } from "../../focus_transition";
+import { useTextureVariant } from "../../texture_variants";
export interface UtilitiesPostProps {
config: Config;
@@ -41,17 +43,15 @@ export const UtilitiesPost = (props: UtilitiesPostProps) => {
new THREE.Vector3(barbX, barbY, barbZ),
);
- const postWoodTextureBase = useTexture(ASSETS.textures.wood + "?=post");
- const postWoodTexture = React.useMemo(() => {
- const texture = postWoodTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.02, 0.05);
- return texture;
- }, [postWoodTextureBase]);
+ const postWoodTexture = useTextureVariant(ASSETS.textures.wood, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.02, 0.05],
+ });
- return {
- ;
+ ;
};
diff --git a/frontend/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx
index 6f388a1c69..a4d9d85707 100644
--- a/frontend/three_d_garden/bot/__tests__/bot_test.tsx
+++ b/frontend/three_d_garden/bot/__tests__/bot_test.tsx
@@ -4,6 +4,10 @@ import { Bot, FarmbotModelProps } from "../bot";
import { INITIAL, INITIAL_POSITION } from "../../config";
import { clone } from "lodash";
import { SVGLoader } from "three/examples/jsm/Addons.js";
+import {
+ createRenderer,
+ unmountRenderer,
+} from "../../../__test_support__/test_renderer";
describe("", () => {
afterEach(() => {
@@ -65,6 +69,15 @@ describe("", () => {
expect(container.querySelectorAll("[name='button-group']").length).toEqual(3);
});
+ it("hides FarmBot in Planter bed focus", () => {
+ const p = fakeProps();
+ p.activeFocus = "Planter bed";
+ const wrapper = createRenderer();
+ const bot = wrapper.root.findAll(node => node.props.name == "bot")[0];
+ expect(bot.props.visible).toEqual(false);
+ unmountRenderer(wrapper);
+ });
+
it("renders watering animation", () => {
const p = fakeProps();
p.config.waterFlow = true;
diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx
index 527fee6841..b3d7b68482 100644
--- a/frontend/three_d_garden/bot/bot.tsx
+++ b/frontend/three_d_garden/bot/bot.tsx
@@ -2,9 +2,11 @@
import React, { useEffect, useState } from "react";
import * as THREE from "three";
import {
- Cylinder, Extrude, Trail, Tube, useGLTF, useTexture,
+ Cylinder, Extrude, Trail, Tube, useGLTF,
} from "@react-three/drei";
-import { DoubleSide, Shape, RepeatWrapping } from "three";
+import {
+ DoubleSide, ExtrudeGeometryOptions, Shape, RepeatWrapping,
+} from "three";
import {
easyCubicBezierCurve3, get3DPositionNoMirrorFunc,
zDir as zDirFunc,
@@ -34,6 +36,8 @@ import {
} from "./components";
import { SlotWithTool } from "../../resources/interfaces";
import { WateringAnimations } from "./components/watering_animations";
+import { FocusVisibilityGroup } from "../focus_transition";
+import { useTextureVariant } from "../texture_variants";
export const extrusionWidth = 20;
const utmRadius = 35;
@@ -91,13 +95,12 @@ type XAxisCCMount = GLTF & {
materials: never;
}
-Object.values(ASSETS.models).map(model => useGLTF.preload(model, LIB_DIR));
-
export interface FarmbotModelProps {
config: Config;
configPosition: PositionConfig;
activeFocus: string;
getZ(x: number, y: number): number;
+ trailReady?: boolean;
toolSlots?: SlotWithTool[];
mountedToolName?: string | undefined;
dispatch?: Function;
@@ -182,16 +185,13 @@ export const Bot = (props: FarmbotModelProps) => {
});
}
});
- const aluminumTextureBase = useTexture(ASSETS.textures.aluminum + "?=bot");
- const aluminumTexture = React.useMemo(() => {
- const texture = aluminumTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.01, 0.0003);
- return texture;
- }, [aluminumTextureBase]);
+ const aluminumTexture = useTextureVariant(ASSETS.textures.aluminum, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.01, 0.0003],
+ });
- const yBeltPath = () => {
+ const yBeltPath = React.useCallback(() => {
const radius = 12;
const path = new Shape();
path.moveTo(0, 0);
@@ -211,10 +211,15 @@ export const Bot = (props: FarmbotModelProps) => {
path.arc(-2, -radius, radius, Math.PI / 2, Math.PI);
path.lineTo(-2, 0);
return path;
- };
+ }, [botSizeY, y]);
+ const yBeltArgs = React.useMemo(() => [
+ yBeltPath(),
+ { steps: 1, depth: 6, bevelEnabled: false },
+ ] as [Shape, ExtrudeGeometryOptions], [yBeltPath]);
const distanceToSoil = -props.getZ(x, y) - zDir * z;
const defaultTrailWidth = config.perspective ? 500 : 0.1;
+ const trailReady = props.trailReady !== false;
const airTubeEndPosition = (kitVersion: string): [number, number, number] => {
switch (kitVersion) {
@@ -250,8 +255,22 @@ export const Bot = (props: FarmbotModelProps) => {
...gardenXY(x + cameraMountOffset.x, y + cameraMountOffset.y),
zZero - zDir * z - 140 + zGantryOffset + 20,
);
+ const utmComponent =
+
+ ;
- return
{[0 - extrusionWidth, bedWidthOuter].map((y, index) => {
const bedColumnYOffset =
@@ -566,29 +585,19 @@ export const Bot = (props: FarmbotModelProps) => {
configPosition={props.configPosition}
cameraMountPosition={cameraMountPosition}
distanceToSoil={distanceToSoil} />
- Math.pow(t, 3)}
- color={"red"}
- length={100}
- decay={0.5}
- local={false}
- stride={0}
- interval={1}>
-
-
-
-
+ {trailReady && trail
+ ? Math.pow(t, 3)}
+ color={"red"}
+ length={100}
+ decay={0.5}
+ local={false}
+ stride={0}
+ interval={1}>
+ {utmComponent}
+
+ : utmComponent}
{
{
- ;
+ ;
};
diff --git a/frontend/three_d_garden/bot/components/cable_carriers.tsx b/frontend/three_d_garden/bot/components/cable_carriers.tsx
index f7639b5264..52346b33d0 100644
--- a/frontend/three_d_garden/bot/components/cable_carriers.tsx
+++ b/frontend/three_d_garden/bot/components/cable_carriers.tsx
@@ -71,15 +71,16 @@ export const CableCarrierX = (props: CableCarrierXProps) => {
x: botSizeX / 2,
y: (tracks ? 0 : extrusionWidth) - 15 - bedYOffset,
});
+ const args = React.useMemo(() => [
+ ccPath(
+ botSizeX / 2, botSizeX / 2 - x + 20,
+ bedCCSupportHeight - 40,
+ true),
+ { steps: 1, depth: 22, bevelEnabled: false },
+ ] as [Shape, THREE.ExtrudeGeometryOptions], [bedCCSupportHeight, botSizeX, x]);
return {
const position = get3DPosition({ x: x - 28, y: 20 });
return [position.x, position.y, columnLength + 150];
};
+ const args = React.useMemo(() => [
+ ccPath(botSizeY, y + 40, 70),
+ { steps: 1, depth: ccDepth(kitVersion), bevelEnabled: false },
+ ] as [Shape, THREE.ExtrudeGeometryOptions], [botSizeY, kitVersion, y]);
return
@@ -140,12 +142,13 @@ export const CableCarrierZ = (props: CableCarrierZProps) => {
const zDir = zDirFunc(props.config);
const get3DPosition = get3DPositionNoMirrorFunc(props.config);
const position = get3DPosition({ x: x - 41, y: y - 25 });
+ const args = React.useMemo(() => [
+ ccPath(botSizeZ + zGantryOffset - 100, zDir * z + zGantryOffset - 15, 87),
+ { steps: 1, depth: 60, bevelEnabled: false },
+ ] as [Shape, THREE.ExtrudeGeometryOptions], [botSizeZ, z, zDir, zGantryOffset]);
return range((zAxisLength - 350) / 200), [zAxisLength]);
const verticalRef = React.useRef(undefined);
+ const verticalGeometry = React.useMemo(() => {
+ const shape = new THREE.Shape();
+ shape.moveTo(0, 0);
+ shape.lineTo(0, 20);
+ shape.lineTo(15, 20);
+ shape.lineTo(20, 1.5);
+ shape.lineTo(28.5, 1.5);
+ shape.lineTo(28.5, -61);
+ shape.lineTo(24, -63);
+ shape.lineTo(24, -61.5);
+ shape.lineTo(27, -60);
+ shape.lineTo(27, 0);
+ shape.lineTo(0, 0);
+ return new THREE.ExtrudeGeometry(shape, {
+ depth: zAxisLength - 350,
+ bevelEnabled: false,
+ });
+ }, [zAxisLength]);
+ React.useEffect(() => () => verticalGeometry.dispose(), [verticalGeometry]);
React.useEffect(() => {
if (!verticalRef.current || verticalInstances.length === 0) { return; }
const temp = new THREE.Object3D();
@@ -222,27 +244,7 @@ export const CableCarrierSupportVertical =
{
- const shape = new THREE.Shape();
- shape.moveTo(0, 0);
- shape.lineTo(0, 20);
- shape.lineTo(15, 20);
- shape.lineTo(20, 1.5);
- shape.lineTo(28.5, 1.5);
- shape.lineTo(28.5, -61);
- shape.lineTo(24, -63);
- shape.lineTo(24, -61.5);
- shape.lineTo(27, -60);
- shape.lineTo(27, 0);
- shape.lineTo(0, 0);
- return shape;
- })(),
- {
- depth: zAxisLength - 350,
- bevelEnabled: false,
- },
- )}>
+ geometry={verticalGeometry}>
@@ -268,6 +270,23 @@ export const CableCarrierSupportHorizontal =
const horizontalInstances = React.useMemo(() => range((botSizeY - 10) / 300), [botSizeY]);
const horizontalRef =
React.useRef(undefined);
+ const horizontalGeometry = React.useMemo(() => {
+ const shape = new THREE.Shape();
+ shape.moveTo(0, 0);
+ shape.lineTo(0, 20);
+ shape.lineTo(-40, 20);
+ shape.lineTo(-41, 22.5);
+ shape.lineTo(-42.5, 22.5);
+ shape.lineTo(-41.5, 18.5);
+ shape.lineTo(-30, 18.5);
+ shape.lineTo(-25, 0);
+ shape.lineTo(0, 0);
+ return new THREE.ExtrudeGeometry(shape, {
+ depth: botSizeY - 30,
+ bevelEnabled: false,
+ });
+ }, [botSizeY]);
+ React.useEffect(() => () => horizontalGeometry.dispose(), [horizontalGeometry]);
React.useEffect(() => {
if (!horizontalRef.current || horizontalInstances.length === 0) { return; }
const temp = new THREE.Object3D();
@@ -313,26 +332,7 @@ export const CableCarrierSupportHorizontal =
columnLength + 60,
]}
rotation={[Math.PI / 2, 0, 0]}
- geometry={new THREE.ExtrudeGeometry(
- (() => {
- const shape = new THREE.Shape();
-
- shape.moveTo(0, 0);
- shape.lineTo(0, 20);
- shape.lineTo(-40, 20);
- shape.lineTo(-41, 22.5);
- shape.lineTo(-42.5, 22.5);
- shape.lineTo(-41.5, 18.5);
- shape.lineTo(-30, 18.5);
- shape.lineTo(-25, 0);
- shape.lineTo(0, 0);
- return shape;
- })(),
- {
- depth: botSizeY - 30,
- bevelEnabled: false,
- },
- )}>
+ geometry={horizontalGeometry}>
", () => {
const Component = CrossSlide(model);
const { container } = render();
expect(container.innerHTML).toContain("name");
- expect(container.innerHTML).toContain("instancedmesh");
+ expect(container.querySelector("mesh, instancedmesh")).toBeTruthy();
});
});
diff --git a/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx b/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx
index 1459198c1b..34cd164b0c 100644
--- a/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx
+++ b/frontend/three_d_garden/bot/parts/__tests__/gantry_wheel_plate_test.tsx
@@ -10,6 +10,6 @@ describe("", () => {
const Component = GantryWheelPlate(model);
const { container } = render();
expect(container.innerHTML).toContain("name");
- expect(container.innerHTML).toContain("instancedmesh");
+ expect(container.querySelector("mesh, instancedmesh")).toBeTruthy();
});
});
diff --git a/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts b/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts
new file mode 100644
index 0000000000..586c69b48c
--- /dev/null
+++ b/frontend/three_d_garden/bot/parts/__tests__/merged_instanced_geometry_test.ts
@@ -0,0 +1,45 @@
+import * as THREE from "three";
+import { mergedInstancedGeometry } from "../merged_instanced_geometry";
+
+describe("mergedInstancedGeometry", () => {
+ it("bakes instanced matrices into one geometry", () => {
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute("position", new THREE.BufferAttribute(
+ new Float32Array([
+ 0, 0, 0,
+ 1, 0, 0,
+ 0, 1, 0,
+ ]),
+ 3,
+ ));
+ const matrices = new Float32Array(32);
+ new THREE.Matrix4().identity().toArray(matrices, 0);
+ new THREE.Matrix4().makeTranslation(10, 0, 0).toArray(matrices, 16);
+ const model = {
+ nodes: {
+ mesh0_mesh: {
+ geometry,
+ instanceMatrix: new THREE.InstancedBufferAttribute(matrices, 16),
+ } as THREE.Mesh & { instanceMatrix: THREE.InstancedBufferAttribute },
+ ignored: { geometry } as THREE.Mesh,
+ },
+ };
+
+ const merged = mergedInstancedGeometry(model, /^mesh/);
+
+ expect(merged?.getAttribute("position").count).toEqual(6);
+ const positions = merged?.getAttribute("position").array;
+ expect(Array.from(positions || []).slice(9, 12)).toEqual([10, 0, 0]);
+ expect(mergedInstancedGeometry(model, /^mesh/)).toBe(merged);
+ });
+
+ it("returns undefined without matching instanced nodes", () => {
+ const model = {
+ nodes: {
+ other: { geometry: new THREE.BufferGeometry() } as THREE.Mesh,
+ },
+ };
+
+ expect(mergedInstancedGeometry(model, /^mesh/)).toBeUndefined();
+ });
+});
diff --git a/frontend/three_d_garden/bot/parts/cross_slide.tsx b/frontend/three_d_garden/bot/parts/cross_slide.tsx
index c648a3a657..fea0f75970 100644
--- a/frontend/three_d_garden/bot/parts/cross_slide.tsx
+++ b/frontend/three_d_garden/bot/parts/cross_slide.tsx
@@ -5,6 +5,7 @@ import { InstancedBufferAttribute } from "three";
import type { GLTF } from "three-stdlib";
import { Group, Mesh as MeshComponent, InstancedMesh } from "../../components";
import { ThreeElements } from "@react-three/fiber";
+import { mergedInstancedGeometry } from "./merged_instanced_geometry";
type Mesh = THREE.Mesh & { instanceMatrix: InstancedBufferAttribute | undefined };
@@ -153,6 +154,19 @@ interface CrossSlideProps extends Omit {
export const CrossSlideModel = (props: CrossSlideProps) => {
const { model, ...groupProps } = props;
const { nodes, materials } = model;
+ const mergedGeometry = mergedInstancedGeometry(model, /^mesh/);
+ if (mergedGeometry) {
+ return
+
+
+ ;
+ }
return
(props: Omit) => {
const { nodes, materials } = model;
+ const mergedGeometry = mergedInstancedGeometry(model, /^mesh/);
+ if (mergedGeometry) {
+ return
+
+
+ ;
+ }
return
;
+};
+
+const geometryCache = new WeakMap();
+
+export const mergedInstancedGeometry = (
+ model: InstancedModel,
+ keyPattern: RegExp,
+) => {
+ const cached = geometryCache.get(model);
+ if (cached) { return cached; }
+ const matrix = new THREE.Matrix4();
+ const geometries: THREE.BufferGeometry[] = [];
+ Object.entries(model.nodes)
+ .filter(([key, node]) => keyPattern.test(key) && node.instanceMatrix)
+ .forEach(([, node]) => {
+ const instanceMatrix = node.instanceMatrix;
+ if (!instanceMatrix) { return; }
+ for (let i = 0; i < instanceMatrix.count; i++) {
+ matrix.fromArray(instanceMatrix.array, i * instanceMatrix.itemSize);
+ const geometry = node.geometry.clone();
+ geometry.applyMatrix4(matrix);
+ geometries.push(geometry);
+ }
+ });
+ if (geometries.length == 0) { return undefined; }
+ const merged = mergeGeometries(geometries, false);
+ geometries.forEach(geometry => geometry.dispose());
+ if (!merged) { return undefined; }
+ geometryCache.set(model, merged);
+ return merged;
+};
diff --git a/frontend/three_d_garden/bot/power_supply.tsx b/frontend/three_d_garden/bot/power_supply.tsx
index 9813f4809c..855af14bec 100644
--- a/frontend/three_d_garden/bot/power_supply.tsx
+++ b/frontend/three_d_garden/bot/power_supply.tsx
@@ -2,11 +2,12 @@
import React from "react";
import { RepeatWrapping } from "three";
import * as THREE from "three";
-import { Box, Tube, useTexture } from "@react-three/drei";
+import { Box, Tube } from "@react-three/drei";
import { ASSETS } from "../constants";
import { threeSpace, easyCubicBezierCurve3 } from "../helpers";
import { Config } from "../config";
import { Group, MeshPhongMaterial } from "../components";
+import { useTextureVariant } from "../texture_variants";
export interface PowerSupplyProps {
config: Config;
@@ -30,14 +31,11 @@ export const PowerSupply = (props: PowerSupplyProps) => {
} = props.config;
const zGround = -bedHeight - bedZOffset;
- const powerSupplyTextureBase = useTexture(ASSETS.textures.aluminum + "?=powerSupply");
- const powerSupplyTexture = React.useMemo(() => {
- const texture = powerSupplyTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.01, 0.003);
- return texture;
- }, [powerSupplyTextureBase]);
+ const powerSupplyTexture = useTextureVariant(ASSETS.textures.aluminum, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.01, 0.003],
+ });
const combinedCablePath = new THREE.CurvePath();
diff --git a/frontend/three_d_garden/click_event.ts b/frontend/three_d_garden/click_event.ts
new file mode 100644
index 0000000000..2abec2af7a
--- /dev/null
+++ b/frontend/three_d_garden/click_event.ts
@@ -0,0 +1,7 @@
+import type { ThreeEvent } from "@react-three/fiber";
+
+export const MAX_POINTER_CLICK_DELTA = 1;
+
+export const clickWasDragged = (
+ event: Pick, "delta">,
+) => (event.delta || 0) > MAX_POINTER_CLICK_DELTA;
diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts
index 84b538c40d..e403c67819 100644
--- a/frontend/three_d_garden/config.ts
+++ b/frontend/three_d_garden/config.ts
@@ -158,7 +158,7 @@ export const INITIAL: ConfigWithPosition = {
showSoilPoints: false,
plants: "Spring",
labels: false,
- labelsOnHover: false,
+ labelsOnHover: true,
ground: true,
grid: true,
axes: false,
@@ -578,10 +578,10 @@ type SeasonProperties = {
cloudOpacity: number;
};
const SEASON_PROPERTIES: Record = {
- Winter: { sunIntensity: 4 / 4, sunColor: "#A0C4FF", cloudOpacity: 0.85 },
- Spring: { sunIntensity: 7 / 4, sunColor: "#BDE0FE", cloudOpacity: 0.2 },
- Summer: { sunIntensity: 9 / 4, sunColor: "#FFFFFF", cloudOpacity: 0 },
- Fall: { sunIntensity: 5.5 / 4, sunColor: "#FFD6BC", cloudOpacity: 0.3 },
+ Winter: { sunIntensity: 4, sunColor: "#A0C4FF", cloudOpacity: 0.75 },
+ Spring: { sunIntensity: 7, sunColor: "#BDE0FE", cloudOpacity: 0.2 },
+ Summer: { sunIntensity: 9, sunColor: "#FFFFFF", cloudOpacity: 0 },
+ Fall: { sunIntensity: 5.5, sunColor: "#FFD6BC", cloudOpacity: 0.3 },
};
export const getSeasonProperties = (
config: Config,
diff --git a/frontend/three_d_garden/config_overlays.tsx b/frontend/three_d_garden/config_overlays.tsx
index 9c19cfd961..6d373ed908 100644
--- a/frontend/three_d_garden/config_overlays.tsx
+++ b/frontend/three_d_garden/config_overlays.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { ConfigWithPosition, modifyConfig } from "./config";
import { setUrlParam } from "./zoom_beacons_constants";
import { ExternalUrl } from "../external_urls";
+import { FocusVisibilityDiv } from "./focus_transition";
export interface ToolTip {
timeoutId: number;
@@ -15,6 +16,7 @@ export interface OverlayProps {
setToolTip(tooltip: ToolTip): void;
activeFocus: string;
setActiveFocus(focus: string): void;
+ loadComplete?: boolean;
startTimeRef?: React.RefObject;
}
@@ -74,10 +76,16 @@ const PublicOverlaySection = (props: SectionProps) => {
export const PublicOverlay = (props: OverlayProps) => {
const { config, setConfig, toolTip, setToolTip } = props;
const commonSectionProps = { config, setConfig, toolTip, setToolTip };
+ const settingsBarClassName = [
+ "settings-bar",
+ props.loadComplete ? "settings-bar-loaded" : "",
+ ].join(" ");
return
- {config.settingsBar && !props.activeFocus &&
-
+
+
{
"lab": "Lab",
"greenhouse": "Greenhouse",
}} />
- }
- {config.promoInfo && !props.activeFocus &&
+
+
+
}
+ kitVersion={config.kitVersion} />
+
;
};
@@ -129,7 +141,7 @@ interface PromoInfoProps {
const PromoInfo = (props: PromoInfoProps) => {
const { isGenesis, kitVersion } = props;
- return
+ return
Explore our models
{isGenesis
?
@@ -157,29 +169,35 @@ const PromoInfo = (props: PromoInfoProps) => {
universities, and commercial research facilities.
}
-
- Order Genesis
-
- XL
-
- {kitVersion}
-
- ;
+
+ ;
};
interface ConfigRowProps {
configKey: keyof ConfigWithPosition;
children: React.ReactNode;
addLabel?: string;
+ searchTerms?: string[];
}
+const ConfigSearchContext = React.createContext("");
+
const ConfigRow = (props: ConfigRowProps) => {
const { configKey } = props;
+ const search = React.useContext(ConfigSearchContext).trim().toLowerCase();
const urlHasParam = (key: keyof ConfigWithPosition) =>
!!(new URLSearchParams(window.location.search)).get(key);
const removeParam = () => {
@@ -195,6 +213,10 @@ const ConfigRow = (props: ConfigRowProps) => {
if (props.addLabel) {
label += ` (${props.addLabel})`;
}
+ const searchText = [label, ...(props.searchTerms || [])]
+ .join(" ")
+ .toLowerCase();
+ if (search && !searchText.includes(search)) { return ; }
return
{hasParam &&
x
}
{label}
@@ -275,7 +297,10 @@ const Radio = (props: RadioProps) => {
setConfig(modifyConfig(config, update));
maybeAddParam(config.urlParamAutoAdd, configKey, "" + newValue);
};
- return
+ return
{options.map(value =>
@@ -297,145 +322,183 @@ export const PrivateOverlay = (props: OverlayProps) => {
const bedMin = props.config.bedWallThickness * 2;
const { config, setConfig } = props;
const common = { ...props };
- return
-
-
- {"Configs"}
- setConfig(modifyConfig(config, { config: false }))}>
- X
-
-
-
+ const [search, setSearch] = React.useState("");
+ // eslint-disable-next-line no-null/no-null
+ const searchInputRef = React.useRef(null);
+ const closeConfig = React.useCallback(() =>
+ setConfig(modifyConfig(config, { config: false })), [config, setConfig]);
+ React.useEffect(() => {
+ searchInputRef.current?.focus();
+ }, []);
+ return e.key == "Escape" && closeConfig()}>
+
+
+
setSearch(e.target.value)}
+ />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
;
};
diff --git a/frontend/three_d_garden/focus_transition.tsx b/frontend/three_d_garden/focus_transition.tsx
new file mode 100644
index 0000000000..9504176d93
--- /dev/null
+++ b/frontend/three_d_garden/focus_transition.tsx
@@ -0,0 +1,596 @@
+import React from "react";
+import { useSpring } from "@react-spring/three";
+import { Material, Object3D } from "three";
+import { Group } from "./components";
+import { Camera, VectorXyz } from "./zoom_beacons_constants";
+
+export const FOCUS_TRANSITION_MS = 900;
+
+export const easeInOutCubic = (t: number) =>
+ t < 0.5
+ ? 4 * t * t * t
+ : 1 - Math.pow(-2 * t + 2, 3) / 2;
+
+interface FocusTransitionContextValue {
+ enabled: boolean;
+ duration: number;
+}
+
+const FocusTransitionContext =
+ React.createContext({
+ enabled: false,
+ duration: FOCUS_TRANSITION_MS,
+ });
+
+export interface FocusTransitionProviderProps {
+ enabled: boolean;
+ duration?: number;
+ children: React.ReactNode;
+}
+
+export const FocusTransitionProvider =
+ (props: FocusTransitionProviderProps) => {
+ const parent = React.useContext(FocusTransitionContext);
+ const value = React.useMemo(() => ({
+ enabled: props.enabled,
+ duration: props.duration || parent.duration || FOCUS_TRANSITION_MS,
+ }), [parent.duration, props.duration, props.enabled]);
+ return
+ {props.children}
+ ;
+ };
+
+export const useFocusTransition = () =>
+ React.useContext(FocusTransitionContext);
+
+interface MaterialState {
+ opacity: number;
+ transparent: boolean;
+ depthWrite: boolean;
+}
+
+type MaterialSlot = Material | Material[];
+
+interface MaterialOwner extends Object3D {
+ material?: MaterialSlot;
+}
+
+interface MaterialRecord {
+ owner: MaterialOwner;
+ original: MaterialSlot;
+ clones: MaterialSlot;
+ states: MaterialState[];
+}
+
+const isMaterial = (value: unknown): value is Material =>
+ !!value
+ && typeof value == "object"
+ && typeof (value as Material).clone == "function";
+
+const materialState = (material: Material): MaterialState => ({
+ opacity: material.opacity,
+ transparent: material.transparent,
+ depthWrite: material.depthWrite,
+});
+
+const cloneMaterial = (material: Material) => {
+ const clone = material.clone();
+ clone.onBeforeCompile = material.onBeforeCompile;
+ return clone;
+};
+
+const cloneSlot = (slot: MaterialSlot): {
+ clones: MaterialSlot;
+ states: MaterialState[];
+} | undefined => {
+ if (Array.isArray(slot)) {
+ const clones = slot.map(cloneMaterial);
+ return { clones, states: clones.map(materialState) };
+ }
+ if (!isMaterial(slot)) { return undefined; }
+ const clone = cloneMaterial(slot);
+ return { clones: clone, states: [materialState(clone)] };
+};
+
+const forEachMaterial =
+ (slot: MaterialSlot, callback: (material: Material, index: number) => void) => {
+ if (Array.isArray(slot)) {
+ slot.map(callback);
+ } else if (isMaterial(slot)) {
+ callback(slot, 0);
+ }
+ };
+
+export const applyFocusMaterialOpacity = (
+ material: Material,
+ state: MaterialState,
+ opacity: number,
+ preserveDepthWrite = false,
+) => {
+ material.opacity = state.opacity * opacity;
+ material.transparent = state.transparent || opacity < 1;
+ material.depthWrite = preserveDepthWrite || opacity >= 1
+ ? state.depthWrite
+ : false;
+ material.needsUpdate = true;
+};
+
+interface CreateFocusMaterialBindingOptions {
+ preserveDepthWrite?: boolean;
+}
+
+export const createFocusMaterialBinding = (
+ root: Object3D,
+ options: CreateFocusMaterialBindingOptions = {},
+) => {
+ const records: MaterialRecord[] = [];
+ root.traverse(child => {
+ const owner = child as MaterialOwner;
+ if (!owner.material) { return; }
+ const clone = cloneSlot(owner.material);
+ if (!clone) { return; }
+ records.push({
+ owner,
+ original: owner.material,
+ clones: clone.clones,
+ states: clone.states,
+ });
+ owner.material = clone.clones;
+ });
+
+ return {
+ apply: (opacity: number) => {
+ records.map(record =>
+ forEachMaterial(record.clones, (material, index) =>
+ applyFocusMaterialOpacity(
+ material,
+ record.states[index],
+ opacity,
+ !!options.preserveDepthWrite,
+ )));
+ },
+ restore: () => {
+ records.map(record => {
+ record.owner.material = record.original;
+ forEachMaterial(record.clones, material => material.dispose());
+ });
+ },
+ };
+};
+
+const canTraverse = (value: unknown): value is Object3D =>
+ !!value
+ && typeof value == "object"
+ && typeof (value as Object3D).traverse == "function";
+
+const setVector = (
+ vector: { set(x: number, y: number, z: number): void },
+ values: VectorXyz,
+) => vector.set(values[0], values[1], values[2]);
+
+export type FocusVisibilityGroupProps =
+ React.ComponentProps & {
+ visible: boolean;
+ keepMounted?: boolean;
+ materialBindingKey?: React.Key;
+ preserveDepthWrite?: boolean;
+ };
+
+export const shouldUnmountFocusVisibilityGroup = (
+ rendered: boolean,
+ visible: boolean,
+ keepMounted?: boolean,
+) => !rendered && !visible && !keepMounted;
+
+export const FocusVisibilityGroup =
+ React.forwardRef((props, forwardedRef) => {
+ const {
+ visible, keepMounted, materialBindingKey, preserveDepthWrite, children,
+ ...groupProps
+ } = props;
+ const transition = useFocusTransition();
+ const enabled = transition.enabled;
+ const [rendered, setRendered] = React.useState(visible || !!keepMounted);
+ const [groupVisible, setGroupVisible] = React.useState(visible);
+ const groupRef = React.useRef(undefined);
+ const opacityRef = React.useRef(visible ? 1 : 0);
+ const materialBinding = React.useRef | undefined>(undefined);
+
+ const restoreMaterialBinding = React.useCallback(() => {
+ materialBinding.current?.restore();
+ materialBinding.current = undefined;
+ }, []);
+
+ const refreshMaterialBinding = React.useCallback(() => {
+ if (!enabled || !canTraverse(groupRef.current)) { return; }
+ restoreMaterialBinding();
+ materialBinding.current = createFocusMaterialBinding(groupRef.current, {
+ preserveDepthWrite,
+ });
+ materialBinding.current.apply(opacityRef.current);
+ }, [enabled, preserveDepthWrite, restoreMaterialBinding]);
+
+ const setRef = React.useCallback((value: Object3D | null) => {
+ if (!value) {
+ groupRef.current = undefined;
+ restoreMaterialBinding();
+ return;
+ }
+ groupRef.current = value;
+ if (enabled && canTraverse(value)) {
+ materialBinding.current ||= createFocusMaterialBinding(value, {
+ preserveDepthWrite,
+ });
+ materialBinding.current.apply(opacityRef.current);
+ }
+ if (typeof forwardedRef == "function") {
+ forwardedRef(value);
+ } else if (forwardedRef) {
+ forwardedRef.current = value;
+ }
+ }, [enabled, forwardedRef, preserveDepthWrite, restoreMaterialBinding]);
+
+ const applyOpacity = React.useCallback((opacity: number) => {
+ opacityRef.current = opacity;
+ if (!enabled || !canTraverse(groupRef.current)) { return; }
+ materialBinding.current ||= createFocusMaterialBinding(groupRef.current, {
+ preserveDepthWrite,
+ });
+ materialBinding.current.apply(opacity);
+ }, [enabled, preserveDepthWrite]);
+
+ React.useEffect(() => {
+ if (enabled && visible) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setRendered(true);
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setGroupVisible(true);
+ }
+ }, [enabled, visible]);
+
+ React.useEffect(() => {
+ if (materialBindingKey === undefined) { return; }
+ refreshMaterialBinding();
+ }, [materialBindingKey, refreshMaterialBinding]);
+
+ useSpring({
+ opacity: visible ? 1 : 0,
+ immediate: !enabled,
+ config: {
+ duration: transition.duration,
+ easing: easeInOutCubic,
+ },
+ onChange: result => {
+ const value = result.value as { opacity?: number };
+ applyOpacity(value.opacity ?? (visible ? 1 : 0));
+ },
+ onRest: () => {
+ applyOpacity(visible ? 1 : 0);
+ if (enabled && !visible) {
+ if (keepMounted) {
+ setGroupVisible(false);
+ } else {
+ setRendered(false);
+ }
+ }
+ },
+ });
+
+ React.useEffect(() => restoreMaterialBinding, [restoreMaterialBinding]);
+
+ if (!enabled) {
+ return
+ {children}
+ ;
+ }
+
+ if (shouldUnmountFocusVisibilityGroup(rendered, visible, keepMounted)) {
+ return undefined;
+ }
+
+ return
+ {children}
+ ;
+ });
+
+export const useFocusTransitionMount = (visible: boolean) => {
+ const transition = useFocusTransition();
+ const [mounted, setMounted] = React.useState(visible);
+
+ React.useEffect(() => {
+ if (transition.enabled && visible) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setMounted(true);
+ return;
+ }
+ if (!transition.enabled) { return; }
+ const timeout = window.setTimeout(() =>
+ setMounted(false), transition.duration);
+ return () => window.clearTimeout(timeout);
+ }, [transition.duration, transition.enabled, visible]);
+
+ return {
+ ...transition,
+ mounted: transition.enabled ? mounted || visible : visible,
+ };
+};
+
+export const useFocusVisibilityClass = (visible: boolean) => {
+ const transition = useFocusTransitionMount(visible);
+ const [shown, setShown] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!transition.enabled) { return; }
+ if (!transition.mounted) { return; }
+ if (!visible) {
+ const frame = window.requestAnimationFrame(() => setShown(false));
+ return () => window.cancelAnimationFrame(frame);
+ }
+ let secondFrame = 0;
+ const firstFrame = window.requestAnimationFrame(() => {
+ secondFrame = window.requestAnimationFrame(() => setShown(true));
+ });
+ return () => {
+ window.cancelAnimationFrame(firstFrame);
+ window.cancelAnimationFrame(secondFrame);
+ };
+ }, [
+ transition.enabled,
+ transition.mounted,
+ visible,
+ ]);
+
+ const visibleClass = transition.enabled
+ ? visible && shown
+ : visible;
+ return {
+ ...transition,
+ className: visibleClass
+ ? "focus-transition-visible"
+ : "focus-transition-hidden",
+ };
+};
+
+export interface FocusVisibilityDivProps
+ extends React.HTMLAttributes {
+ visible: boolean;
+ className: string;
+ children: React.ReactNode;
+}
+
+export const FocusVisibilityDiv = (props: FocusVisibilityDivProps) => {
+ const {
+ visible, className: incomingClassName, children, ...divProps
+ } = props;
+ const transition = useFocusVisibilityClass(visible);
+
+ if (!transition.mounted) { return undefined; }
+ const className = [
+ incomingClassName,
+ "focus-transition-dom",
+ transition.className,
+ ].join(" ");
+ return
+ {children}
+
;
+};
+
+export interface SmoothCameraState extends Camera {
+ zoom: number;
+}
+
+const vectorFromSpring = (value: unknown, fallback: VectorXyz): VectorXyz =>
+ Array.isArray(value) && value.length == 3
+ ? [Number(value[0]), Number(value[1]), Number(value[2])]
+ : fallback;
+
+export const cameraTransitionValue = (
+ value: Partial>,
+ fallback: SmoothCameraState,
+): SmoothCameraState => ({
+ position: vectorFromSpring(value.position, fallback.position),
+ target: vectorFromSpring(value.target, fallback.target),
+ zoom: typeof value.zoom == "number" ? value.zoom : fallback.zoom,
+});
+
+export const interpolateCameraState = (
+ from: SmoothCameraState,
+ to: SmoothCameraState,
+ progress: number,
+): SmoothCameraState => {
+ const lerp = (start: number, end: number) =>
+ start + (end - start) * progress;
+ return {
+ position: [
+ lerp(from.position[0], to.position[0]),
+ lerp(from.position[1], to.position[1]),
+ lerp(from.position[2], to.position[2]),
+ ],
+ target: [
+ lerp(from.target[0], to.target[0]),
+ lerp(from.target[1], to.target[1]),
+ lerp(from.target[2], to.target[2]),
+ ],
+ zoom: lerp(from.zoom, to.zoom),
+ };
+};
+
+const cameraKey = (state: SmoothCameraState) =>
+ [
+ ...state.position,
+ ...state.target,
+ state.zoom,
+ ].join(",");
+
+export interface SmoothCameraObject {
+ position: {
+ x?: number;
+ y?: number;
+ z?: number;
+ set(x: number, y: number, z: number): void;
+ toArray?(): number[];
+ };
+ zoom: number;
+ lookAt?(x: number, y: number, z: number): void;
+ updateProjectionMatrix?(): void;
+}
+
+export interface SmoothCameraControls {
+ target: {
+ x?: number;
+ y?: number;
+ z?: number;
+ set(x: number, y: number, z: number): void;
+ toArray?(): number[];
+ };
+ update?(): void;
+}
+
+const readVector = (
+ vector: SmoothCameraObject["position"] | SmoothCameraControls["target"] | undefined,
+ fallback: VectorXyz,
+): VectorXyz => {
+ const values = vector?.toArray?.();
+ if (values && values.length >= 3) {
+ return [values[0], values[1], values[2]];
+ }
+ if (
+ typeof vector?.x == "number"
+ && typeof vector.y == "number"
+ && typeof vector.z == "number"
+ ) {
+ return [vector.x, vector.y, vector.z];
+ }
+ return fallback;
+};
+
+export const readSmoothCameraState = (
+ fallback: SmoothCameraState,
+ cameraObject?: SmoothCameraObject | null,
+ controls?: SmoothCameraControls | null,
+): SmoothCameraState => ({
+ position: readVector(cameraObject?.position, fallback.position),
+ target: readVector(controls?.target, fallback.target),
+ zoom: typeof cameraObject?.zoom == "number"
+ ? cameraObject.zoom
+ : fallback.zoom,
+});
+
+export const applySmoothCameraState = (
+ state: SmoothCameraState,
+ cameraObject?: SmoothCameraObject | null,
+ controls?: SmoothCameraControls | null,
+) => {
+ if (cameraObject && typeof cameraObject.position?.set == "function") {
+ setVector(cameraObject.position, state.position);
+ cameraObject.zoom = state.zoom;
+ cameraObject.updateProjectionMatrix?.();
+ cameraObject.lookAt?.(...state.target);
+ }
+ if (controls && typeof controls.target?.set == "function") {
+ setVector(controls.target, state.target);
+ controls.update?.();
+ }
+};
+
+export interface UseSmoothCameraProps {
+ camera: Camera;
+ zoom: number;
+ enabled: boolean;
+ cameraObject?: SmoothCameraObject | null;
+ controls?: SmoothCameraControls | null;
+}
+
+export const useSmoothCamera = (props: UseSmoothCameraProps) => {
+ const transition = useFocusTransition();
+ const [positionX, positionY, positionZ] = props.camera.position;
+ const [targetX, targetY, targetZ] = props.camera.target;
+ const target = React.useMemo(() => ({
+ position: [positionX, positionY, positionZ],
+ target: [targetX, targetY, targetZ],
+ zoom: props.zoom,
+ }), [
+ positionX,
+ positionY,
+ positionZ,
+ props.zoom,
+ targetX,
+ targetY,
+ targetZ,
+ ]);
+ const [displayCamera, setDisplayCamera] = React.useState(target);
+ const displayRef = React.useRef(displayCamera);
+ const key = cameraKey(target);
+
+ React.useEffect(() => {
+ displayRef.current = displayCamera;
+ }, [displayCamera]);
+
+ const appliedCamera = props.enabled ? displayCamera : target;
+ React.useLayoutEffect(() => {
+ applySmoothCameraState(
+ appliedCamera,
+ props.cameraObject,
+ props.controls,
+ );
+ }, [
+ appliedCamera,
+ props.cameraObject,
+ props.controls,
+ ]);
+
+ React.useEffect(() => {
+ if (!props.enabled) {
+ displayRef.current = target;
+ applySmoothCameraState(
+ target,
+ props.cameraObject,
+ props.controls,
+ );
+ return;
+ }
+ const from = readSmoothCameraState(
+ displayRef.current,
+ props.cameraObject,
+ props.controls,
+ );
+ const startedAt = performance.now();
+ let frame = 0;
+ const tick = () => {
+ const elapsed = performance.now() - startedAt;
+ const progress = Math.min(elapsed / transition.duration, 1);
+ const next = interpolateCameraState(
+ from,
+ target,
+ easeInOutCubic(progress),
+ );
+ displayRef.current = next;
+ setDisplayCamera(next);
+ applySmoothCameraState(next, props.cameraObject, props.controls);
+ if (progress < 1) {
+ frame = window.requestAnimationFrame(tick);
+ } else {
+ displayRef.current = target;
+ setDisplayCamera(target);
+ applySmoothCameraState(
+ target,
+ props.cameraObject,
+ props.controls,
+ );
+ }
+ };
+ frame = window.requestAnimationFrame(tick);
+ return () => window.cancelAnimationFrame(frame);
+ }, [
+ key,
+ props.cameraObject,
+ props.controls,
+ props.enabled,
+ target,
+ transition.duration,
+ ]);
+
+ return props.enabled ? displayCamera : target;
+};
diff --git a/frontend/three_d_garden/fps_probe.tsx b/frontend/three_d_garden/fps_probe.tsx
index 5c1c0de2f7..b25d7bb933 100644
--- a/frontend/three_d_garden/fps_probe.tsx
+++ b/frontend/three_d_garden/fps_probe.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { useFrame, useThree } from "@react-three/fiber";
+import { perfSample } from "../performance/perf";
type SceneObject = {
isMesh?: boolean;
@@ -86,6 +87,8 @@ export const FPSProbe = () => {
const { geometries, textures } = gl.info.memory;
const sceneCounts = countSceneObjects(scene);
window.__fps = fps;
+ perfSample("fps", fps);
+ perfSample("frame_ms", 1000 / fps);
const linesToLogObj: Record = {
epoch: Date.now(),
FPS: fps.toFixed(2),
diff --git a/frontend/three_d_garden/garden/__tests__/clouds_test.tsx b/frontend/three_d_garden/garden/__tests__/clouds_test.tsx
index d2884a48c1..dde827c525 100644
--- a/frontend/three_d_garden/garden/__tests__/clouds_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/clouds_test.tsx
@@ -13,4 +13,11 @@ describe("", () => {
const { container } = render();
expect(container).toContainHTML("clouds");
});
+
+ it("doesn't mount zero-opacity clouds", () => {
+ const p = fakeProps();
+ p.config.plants = "Summer";
+ const { container } = render();
+ expect(container).not.toContainHTML("clouds");
+ });
});
diff --git a/frontend/three_d_garden/garden/__tests__/grid_test.tsx b/frontend/three_d_garden/garden/__tests__/grid_test.tsx
index 4b6556407f..9064c63628 100644
--- a/frontend/three_d_garden/garden/__tests__/grid_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/grid_test.tsx
@@ -1,8 +1,13 @@
import React from "react";
import { render } from "@testing-library/react";
import { Grid, gridLineOffsets, GridProps } from "../grid";
-import { INITIAL } from "../../config";
+import { INITIAL, PRESETS } from "../../config";
import { clone } from "lodash";
+import {
+ actRenderer,
+ createRenderer,
+ unmountRenderer,
+} from "../../../__test_support__/test_renderer";
describe("gridLineOffsets()", () => {
it("calculates offsets", () => {
@@ -25,4 +30,20 @@ describe("", () => {
const { container } = render();
expect(container).toContainHTML("grid");
});
+
+ it("refreshes focus material binding when grid dimensions change", () => {
+ const p = fakeProps();
+ p.config.grid = true;
+ const wrapper = createRenderer();
+ const findGridGroup = () => wrapper.root.findAll(node =>
+ node.props.name == "garden-grid")[0];
+ const genesisBindingKey = findGridGroup().props.materialBindingKey;
+ p.config = { ...p.config, ...PRESETS["Genesis XL"] };
+ actRenderer(() => {
+ wrapper.update();
+ });
+ expect(findGridGroup().props.materialBindingKey)
+ .not.toEqual(genesisBindingKey);
+ unmountRenderer(wrapper);
+ });
});
diff --git a/frontend/three_d_garden/garden/__tests__/images_test.tsx b/frontend/three_d_garden/garden/__tests__/images_test.tsx
index b09bb4a218..ad697a6c4c 100644
--- a/frontend/three_d_garden/garden/__tests__/images_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/images_test.tsx
@@ -2,13 +2,13 @@ let mockDemo = false;
import React from "react";
import { render, screen } from "@testing-library/react";
import {
- extraRotation, getImagePosition, getMirrorTextureProps,
+ extraRotation, getImagePosition, getImageTextureKey, getMirrorTextureProps,
ImageTexture, ImageTextureProps,
} from "../images";
import { INITIAL } from "../../config";
import { clone } from "lodash";
import {
- fakeImage, fakeWebAppConfig,
+ fakeImage, fakeSensor, fakeSensorReading, fakeWebAppConfig,
} from "../../../__test_support__/fake_state/resources";
import { fakeAddPlantProps } from "../../../__test_support__/fake_props";
import * as mustBeOnline from "../../../devices/must_be_online";
@@ -61,6 +61,8 @@ describe("", () => {
p.addPlantProps = apProps;
render();
expect(screen.getAllByText("image").length).toEqual(3);
+ expect(document.querySelector(".render-texture"))
+ .toHaveAttribute("data-frames", "1");
});
it("renders when images missing", () => {
@@ -157,6 +159,31 @@ describe("", () => {
render();
expect(screen.queryAllByText("image").length).toEqual(1);
});
+
+ it("changes texture key when moisture visibility changes", () => {
+ const p = fakeProps();
+ const key = getImageTextureKey(p);
+ p.showMoistureMap = false;
+ expect(getImageTextureKey(p)).not.toEqual(key);
+ });
+
+ it("changes texture key when moisture data changes", () => {
+ const p = fakeProps();
+ const reading = fakeSensorReading();
+ p.sensorReadings = [reading];
+ const key = getImageTextureKey(p);
+ reading.body.value = 800;
+ expect(getImageTextureKey(p)).not.toEqual(key);
+ });
+
+ it("changes texture key when moisture sensor metadata changes", () => {
+ const p = fakeProps();
+ const sensor = fakeSensor();
+ p.sensors = [sensor];
+ const key = getImageTextureKey(p);
+ sensor.body.pin = (sensor.body.pin || 0) + 1;
+ expect(getImageTextureKey(p)).not.toEqual(key);
+ });
});
describe("extraRotation()", () => {
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 2910dd0211..67051f0506 100644
--- a/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/moisture_texture_test.tsx
@@ -6,6 +6,8 @@ import { INITIAL } from "../../config";
import {
fakeSensor, fakeSensorReading,
} from "../../../__test_support__/fake_state/resources";
+import * as interpolationMap from
+ "../../../farm_designer/map/layers/points/interpolation_map";
describe("", () => {
const fakeProps = (): MoistureSurfaceProps => ({
@@ -40,4 +42,19 @@ describe("", () => {
const { container } = render();
expect(container).toContainHTML("moisture-layer");
});
+
+ it("renders the moisture map with a native instanced mesh", () => {
+ const { container } = render();
+ expect(container.querySelector("instancedmesh")).toBeTruthy();
+ expect(container.querySelector(".instances")).toBeFalsy();
+ expect(container.querySelector(".instance")).toBeFalsy();
+ });
+
+ it("skips interpolation when the moisture map is hidden", () => {
+ const generateData = jest.spyOn(interpolationMap, "generateData");
+ const p = fakeProps();
+ p.showMoistureMap = false;
+ render();
+ expect(generateData).not.toHaveBeenCalled();
+ });
});
diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx
index 75a4b4a518..6873f958a7 100644
--- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx
@@ -22,7 +22,12 @@ import { fireEvent, render } from "@testing-library/react";
import { clone } from "lodash";
import { useFrame } from "@react-three/fiber";
import { useTexture } from "@react-three/drei";
-import { Quaternion } from "three";
+import {
+ InstancedMesh as ThreeInstancedMesh,
+ Quaternion,
+ type Intersection,
+ type Raycaster,
+} from "three";
import { fakePlant } from "../../../__test_support__/fake_state/resources";
import { INITIAL } from "../../config";
import {
@@ -38,6 +43,11 @@ import { setMockInstanceId } from "../../../__test_support__/three_d_mocks";
import { PLANT_ICON_ATLAS } from "../plant_icon_atlas";
import { Mode } from "../../../farm_designer/map/interfaces";
import * as mapUtil from "../../../farm_designer/map/util";
+import * as meshKey from "../instanced_mesh_key";
+import {
+ createRenderer,
+ unmountRenderer,
+} from "../../../__test_support__/test_renderer";
describe("", () => {
let reactUseRefSpy: jest.SpyInstance;
@@ -99,6 +109,46 @@ describe("", () => {
expect(meshes.length).toBe(2);
});
+ it("uses reserved icon capacity while rendering only active plants", () => {
+ const p = fakeProps();
+ p.plants = [p.plants[0]];
+ p.iconCapacities = { [p.plants[0].icon]: 10 };
+ const { container } = render();
+ const mesh = container.querySelector("instancedmesh");
+ expect(mesh?.getAttribute("args")).toContain("10");
+ expect(mesh?.getAttribute("count")).toEqual("1");
+ });
+
+ it("keeps reserved icon meshes mounted without active plants", () => {
+ const p = fakeProps();
+ p.plants = [p.plants[0]];
+ p.iconCapacities = {
+ [p.plants[0].icon]: 10,
+ "https://example.com/inactive-icon.avif": 5,
+ };
+ const { container } = render();
+ const meshes = container.querySelectorAll("instancedmesh");
+ expect(meshes.length).toBe(2);
+ expect(meshes.item(1).getAttribute("args")).toContain("5");
+ expect(meshes.item(1).getAttribute("count")).toEqual("0");
+ expect(useTexture).toHaveBeenCalledWith("https://example.com/inactive-icon.avif");
+ });
+
+ it("disables frustum culling for billboarded plant icons", () => {
+ const wrapper = createRenderer();
+ const mesh = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh")[0];
+ expect(mesh.props.frustumCulled).toEqual(false);
+ unmountRenderer(wrapper);
+ });
+
+ it("doesn't build per-plant mesh keys while rendering", () => {
+ const keySpy = jest.spyOn(meshKey, "instancedMeshKey");
+ render();
+ expect(keySpy).not.toHaveBeenCalled();
+ keySpy.mockRestore();
+ });
+
it("loads the atlas texture when an icon is mapped", () => {
PLANT_ICON_ATLAS["/crops/icons/beet.avif"] = {
atlasUrl: "/crops/icons/atlas.avif",
@@ -135,6 +185,19 @@ describe("", () => {
expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1"));
});
+ it("doesn't navigate after orbiting over a plant icon", () => {
+ const p = fakeProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ const wrapper = createRenderer();
+ const mesh = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh")[0];
+ mesh.props.onClick({ instanceId: 0, delta: 3 });
+ unmountRenderer(wrapper);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
it("doesn't navigate without dispatch", () => {
setMockInstanceId(0);
const p = fakeProps();
@@ -157,6 +220,50 @@ describe("", () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
+ const iconRaycast = (p = fakeProps()) => {
+ const wrapper = createRenderer();
+ const mesh = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh")[0];
+ const raycast = mesh.props.raycast as (
+ this: ThreeInstancedMesh,
+ raycaster: Raycaster,
+ intersects: Intersection[],
+ ) => void;
+ unmountRenderer(wrapper);
+ return raycast;
+ };
+
+ it.each([
+ Mode.clickToAdd,
+ Mode.createPoint,
+ Mode.createWeed,
+ ])("allows %s raycasts through plant icons", mode => {
+ getModeSpy.mockReturnValue(mode);
+ const defaultRaycast = jest.spyOn(
+ ThreeInstancedMesh.prototype,
+ "raycast",
+ );
+ const intersects: Intersection[] = [];
+ const raycaster = {} as Raycaster;
+ iconRaycast().call({} as ThreeInstancedMesh, raycaster, intersects);
+ expect(defaultRaycast).not.toHaveBeenCalled();
+ expect(intersects).toEqual([]);
+ defaultRaycast.mockRestore();
+ });
+
+ it("keeps plant icon raycasts outside placement modes", () => {
+ getModeSpy.mockReturnValue(Mode.none);
+ const defaultRaycast = jest.spyOn(
+ ThreeInstancedMesh.prototype,
+ "raycast",
+ ).mockImplementation(() => undefined);
+ const intersects: Intersection[] = [];
+ const raycaster = {} as Raycaster;
+ iconRaycast().call({} as ThreeInstancedMesh, raycaster, intersects);
+ expect(defaultRaycast).toHaveBeenCalledWith(raycaster, intersects);
+ defaultRaycast.mockRestore();
+ });
+
it("doesn't navigate with missing instanceId", () => {
setMockInstanceId(undefined);
const p = fakeProps();
@@ -223,6 +330,24 @@ describe("", () => {
expect(matrix.elements[13]).toBeCloseTo(460);
});
+ it("skips repeated icon matrix updates until camera changes", () => {
+ const p = fakeProps();
+ p.plants = [p.plants[0]];
+ render();
+ const frameFn = (useFrame as jest.Mock).mock.calls[0][0];
+ const instancedRef = allRefs.find(ref => !!ref.current?.setMatrixAt);
+ const setMatrixAt = instancedRef?.current?.setMatrixAt as jest.Mock;
+ const state = { camera: { quaternion: new Quaternion() } };
+ frameFn(state);
+ setMatrixAt.mockClear();
+ frameFn(state);
+ expect(setMatrixAt).not.toHaveBeenCalled();
+ frameFn({
+ camera: { quaternion: new Quaternion(0, 0, 0.1, 1).normalize() },
+ });
+ expect(setMatrixAt).toHaveBeenCalled();
+ });
+
it("updates material brightness when changed", () => {
const setScalar = jest.fn();
const instancedRef = {
diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx
index 3b1c48e7db..cb24893b58 100644
--- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx
@@ -16,9 +16,20 @@ import { Actions } from "../../../constants";
import { convertPlants } from "../../../farm_designer/three_d_garden_map";
import { setMockInstanceId } from "../../../__test_support__/three_d_mocks";
import { useFrame } from "@react-three/fiber";
-import { Quaternion, WebGLProgramParametersWithUniforms } from "three";
+import {
+ InstancedMesh as ThreeInstancedMesh,
+ Quaternion,
+ WebGLProgramParametersWithUniforms,
+ type Intersection,
+ type Raycaster,
+} from "three";
import { Mode } from "../../../farm_designer/map/interfaces";
import * as mapUtil from "../../../farm_designer/map/util";
+import * as meshKey from "../instanced_mesh_key";
+import {
+ createRenderer,
+ unmountRenderer,
+} from "../../../__test_support__/test_renderer";
interface MockRef {
current: {
@@ -190,6 +201,25 @@ describe("", () => {
expect(container.querySelectorAll("instancedmesh").length).toBe(1);
});
+ it("uses reserved spread capacity while rendering active plants", () => {
+ location.pathname = Path.mock(Path.cropSearch("mint"));
+ queueMeshRef();
+ const p = fakeProps();
+ p.instanceCapacity = 10;
+ const { container } = render();
+ const mesh = container.querySelector("instancedmesh");
+ expect(mesh?.getAttribute("args")).toContain("10");
+ expect(mesh?.getAttribute("count")).toEqual("2");
+ });
+
+ it("doesn't build per-plant spread mesh keys while rendering", () => {
+ const keySpy = jest.spyOn(meshKey, "instancedMeshKey");
+ queueMeshRef();
+ render();
+ expect(keySpy).not.toHaveBeenCalled();
+ keySpy.mockRestore();
+ });
+
it("renders spread: edit plant mode", () => {
location.pathname = Path.mock(Path.plants("1"));
queueMeshRef();
@@ -223,6 +253,20 @@ describe("", () => {
expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1"));
});
+ it("doesn't navigate after orbiting over a spread sphere", () => {
+ queueMeshRef();
+ const p = fakeProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ const wrapper = createRenderer();
+ const mesh = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh")[0];
+ mesh.props.onClick({ instanceId: 0, delta: 3 });
+ unmountRenderer(wrapper);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
it("doesn't navigate on spread click in camera selection mode", () => {
setMockInstanceId(0);
getModeSpy.mockReturnValue(Mode.cameraSelection);
@@ -237,6 +281,51 @@ describe("", () => {
expect(mockNavigate).not.toHaveBeenCalled();
});
+ const spreadRaycast = (p = fakeProps()) => {
+ queueMeshRef();
+ const wrapper = createRenderer();
+ const mesh = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh")[0];
+ const raycast = mesh.props.raycast as (
+ this: ThreeInstancedMesh,
+ raycaster: Raycaster,
+ intersects: Intersection[],
+ ) => void;
+ unmountRenderer(wrapper);
+ return raycast;
+ };
+
+ it.each([
+ Mode.clickToAdd,
+ Mode.createPoint,
+ Mode.createWeed,
+ ])("allows %s raycasts through spread spheres", mode => {
+ getModeSpy.mockReturnValue(mode);
+ const defaultRaycast = jest.spyOn(
+ ThreeInstancedMesh.prototype,
+ "raycast",
+ );
+ const intersects: Intersection[] = [];
+ const raycaster = {} as Raycaster;
+ spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects);
+ expect(defaultRaycast).not.toHaveBeenCalled();
+ expect(intersects).toEqual([]);
+ defaultRaycast.mockRestore();
+ });
+
+ it("keeps spread sphere raycasts outside placement modes", () => {
+ getModeSpy.mockReturnValue(Mode.none);
+ const defaultRaycast = jest.spyOn(
+ ThreeInstancedMesh.prototype,
+ "raycast",
+ ).mockImplementation(() => undefined);
+ const intersects: Intersection[] = [];
+ const raycaster = {} as Raycaster;
+ spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects);
+ expect(defaultRaycast).toHaveBeenCalledWith(raycaster, intersects);
+ defaultRaycast.mockRestore();
+ });
+
it("updates instance colors on frame", () => {
queueMeshRef();
const p = fakeProps();
@@ -270,6 +359,59 @@ describe("", () => {
expect(meshRef?.current?.instanceMatrix?.needsUpdate).toBeFalsy();
});
+ it("skips repeated inactive spread frame updates", () => {
+ queueMeshRef();
+ const p = fakeProps();
+ p.spreadVisible = false;
+ render();
+ const meshRef = allRefs[0];
+ meshRef.current = buildMeshRef();
+ const mesh = meshRef.current;
+ const setMatrixAt = mesh?.setMatrixAt as jest.Mock;
+ const frameFn = (useFrame as jest.Mock).mock.calls[0][0];
+ const state = { camera: { quaternion: new Quaternion() } };
+ frameFn(state);
+ setMatrixAt.mockClear();
+ frameFn(state);
+ expect(setMatrixAt).not.toHaveBeenCalled();
+ });
+
+ it("skips repeated visible spread frame updates", () => {
+ queueMeshRef();
+ const p = fakeProps();
+ p.spreadVisible = true;
+ render();
+ const meshRef = allRefs[0];
+ meshRef.current = buildMeshRef();
+ const mesh = meshRef.current;
+ const setMatrixAt = mesh?.setMatrixAt as jest.Mock;
+ const frameFn = (useFrame as jest.Mock).mock.calls[0][0];
+ const state = { camera: { quaternion: new Quaternion() } };
+ frameFn(state);
+ setMatrixAt.mockClear();
+ frameFn(state);
+ expect(setMatrixAt).not.toHaveBeenCalled();
+ });
+
+ it("updates click-to-add spread when active position changes", () => {
+ queueMeshRef();
+ const p = fakeProps();
+ p.spreadVisible = true;
+ getModeSpy.mockReturnValue(Mode.clickToAdd);
+ render();
+ const meshRef = allRefs[0];
+ meshRef.current = buildMeshRef();
+ const mesh = meshRef.current;
+ const setMatrixAt = mesh?.setMatrixAt as jest.Mock;
+ const frameFn = (useFrame as jest.Mock).mock.calls[0][0];
+ const state = { camera: { quaternion: new Quaternion() } };
+ frameFn(state);
+ setMatrixAt.mockClear();
+ p.activePositionRef.current = { x: 100, y: 100 };
+ frameFn(state);
+ expect(setMatrixAt).toHaveBeenCalled();
+ });
+
it("handles missing mesh in layout effect", () => {
reactUseImperativeHandleSpy.mockImplementation(() => undefined);
reactUseRefSpy.mockImplementation(() => ({ current: undefined }) as never);
@@ -288,7 +430,9 @@ describe("", () => {
{ current: undefined as unknown as { x: number; y: number } };
location.pathname = Path.mock(Path.plants("1"));
render();
- const mesh = getMeshRef()?.current as
+ const meshRef = allRefs[0];
+ meshRef.current = buildMeshRef();
+ const mesh = meshRef.current as
(MockRef["current"] & { material: { needsUpdate: boolean } }) | undefined;
if (mesh) {
mesh.instanceColor = { needsUpdate: false, count: 0 };
diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx
index 7326c664bc..e354275c95 100644
--- a/frontend/three_d_garden/garden/__tests__/point_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx
@@ -1,6 +1,9 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";
-import { DrawnPoint, DrawnPointProps, Point, PointProps } from "../point";
+import {
+ DrawnPoint, DrawnPointProps, Point, PointInstances, PointInstancesProps,
+ PointProps,
+} from "../point";
import { INITIAL } from "../../config";
import { clone } from "lodash";
import { fakePoint } from "../../../__test_support__/fake_state/resources";
@@ -11,12 +14,23 @@ import {
fakeDesignerState, fakeDrawnPoint,
} from "../../../__test_support__/fake_designer_state";
import { SpecialStatus } from "farmbot";
+import {
+ createRenderer,
+ unmountRenderer,
+} from "../../../__test_support__/test_renderer";
describe("", () => {
+ const mountedWrappers: ReturnType[] = [];
+
beforeEach(() => {
location.pathname = Path.mock(Path.points());
});
+ afterEach(() => {
+ mountedWrappers.splice(0).forEach(wrapper =>
+ unmountRenderer(wrapper));
+ });
+
const fakeProps = (): PointProps => ({
config: clone(INITIAL),
point: fakePoint(),
@@ -64,6 +78,20 @@ describe("", () => {
expect(mockNavigate).toHaveBeenCalledWith(Path.points("1"));
});
+ it("doesn't navigate after orbiting over a point", () => {
+ const p = fakeProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ p.point.body.id = 1;
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const point = wrapper.root
+ .findAll(node => node.props.name == "marker")[0];
+ point.props.onClick({ delta: 3 });
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
it("doesn't navigate to point info", () => {
const p = fakeProps();
p.dispatch = undefined;
@@ -73,6 +101,52 @@ describe("", () => {
point && fireEvent.click(point);
expect(mockNavigate).not.toHaveBeenCalled();
});
+
+ const fakeInstanceProps = (): PointInstancesProps => ({
+ config: clone(INITIAL),
+ points: [fakePoint(), fakePoint()],
+ visible: true,
+ getZ: () => 0,
+ });
+
+ it("renders instanced point markers", () => {
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const meshes = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh");
+ expect(meshes.length).toEqual(3);
+ expect(meshes[0].props.args[2]).toEqual(2);
+ });
+
+ it("navigates from a point instance", () => {
+ const p = fakeInstanceProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ p.points[0].body.id = 1;
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const marker = wrapper.root
+ .findAll(node => node.props.name == "marker")[0];
+ marker.props.onClick({ instanceId: 0 });
+ expect(dispatch).toHaveBeenCalledWith({
+ type: Actions.SET_PANEL_OPEN, payload: true,
+ });
+ expect(mockNavigate).toHaveBeenCalledWith(Path.points("1"));
+ });
+
+ it("doesn't navigate after orbiting over a point instance", () => {
+ const p = fakeInstanceProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ p.points[0].body.id = 1;
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const marker = wrapper.root
+ .findAll(node => node.props.name == "marker")[0];
+ marker.props.onClick({ instanceId: 0, delta: 3 });
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
});
describe("", () => {
@@ -95,6 +169,17 @@ describe("", () => {
expect(container).toContainHTML("position=\"0,0,0\"");
});
+ it("draws point radius preview", () => {
+ location.pathname = Path.mock(Path.points("add"));
+ const p = fakeProps();
+ const point = fakeDrawnPoint();
+ point.r = 0;
+ p.designer.drawnPoint = point;
+ p.torusRef = { current: undefined as never };
+ const { container } = render();
+ expect(container.querySelector(".torus")).not.toBeNull();
+ });
+
it("doesn't draw point", () => {
location.pathname = Path.mock(Path.points("add"));
const p = fakeProps();
diff --git a/frontend/three_d_garden/garden/__tests__/solar_test.tsx b/frontend/three_d_garden/garden/__tests__/solar_test.tsx
index aec76f6bb8..5780d9e749 100644
--- a/frontend/three_d_garden/garden/__tests__/solar_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/solar_test.tsx
@@ -3,6 +3,13 @@ import { render } from "@testing-library/react";
import { Solar, SolarProps } from "../solar";
import { INITIAL } from "../../config";
import { clone } from "lodash";
+import { FocusTransitionProvider } from "../../focus_transition";
+import {
+ createRenderer,
+ unmountRenderer,
+} from "../../../__test_support__/test_renderer";
+import { RenderOrder } from "../../constants";
+import { DoubleSide } from "three";
describe("", () => {
const fakeProps = (): SolarProps => ({
@@ -16,4 +23,45 @@ describe("", () => {
const { container } = render();
expect(container).toContainHTML("solar");
});
+
+ it("keeps solar mounted during focus transitions", () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(container).toContainHTML("solar-wiring");
+ });
+
+ it("doesn't cull instanced solar cells", () => {
+ const p = fakeProps();
+ p.config.solar = true;
+ const wrapper = createRenderer();
+ const solarCells = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh");
+ expect(solarCells[0].props.frustumCulled).toEqual(false);
+ expect(solarCells[0].props.renderOrder).toEqual(RenderOrder.one + 1);
+ unmountRenderer(wrapper);
+ });
+
+ it("renders solar cells above panels and wiring", () => {
+ const p = fakeProps();
+ p.config.solar = true;
+ const wrapper = createRenderer();
+ const wiring = wrapper.root.findAll(node =>
+ node.props.name == "solar-wiring")[0];
+ const panel = wrapper.root.findAll(node =>
+ (node.type as string) == "mesh"
+ && node.props.renderOrder == RenderOrder.one)[0];
+ const cells = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh")[0];
+ const cellMaterial = wrapper.root.findAll(node =>
+ node.props.side == DoubleSide)[0];
+ expect(wiring.props.renderOrder).toEqual(RenderOrder.default);
+ expect(panel.props.renderOrder).toEqual(RenderOrder.one);
+ expect(Number(cells.props.renderOrder))
+ .toBeGreaterThan(Number(panel.props.renderOrder));
+ expect(cellMaterial.props.side).toEqual(DoubleSide);
+ unmountRenderer(wrapper);
+ });
});
diff --git a/frontend/three_d_garden/garden/__tests__/sun_test.tsx b/frontend/three_d_garden/garden/__tests__/sun_test.tsx
index 60c221874d..5d687e4528 100644
--- a/frontend/three_d_garden/garden/__tests__/sun_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/sun_test.tsx
@@ -1,26 +1,9 @@
-interface Mock0Ref {
- current: number;
-}
-const mock0Ref: Mock0Ref = {
- current: 0,
-};
interface Mock1Ref {
current: { position: { set: Function; }; } | undefined;
}
const mock1Ref: Mock1Ref = {
current: { position: { set: jest.fn() } }
};
-interface Mock4Ref {
- current: { position: { set: Function; }; }[] | undefined;
-}
-const mock4Ref: Mock4Ref = {
- current: [
- { position: { set: jest.fn() } },
- { position: { set: jest.fn() } },
- { position: { set: jest.fn() } },
- { position: { set: jest.fn() } },
- ]
-};
interface MockMaterialRef {
current: { opacity: number; } | undefined;
}
@@ -33,7 +16,7 @@ import { render } from "@testing-library/react";
import { calcSunI, getCycleLength, skyColor, Sun, SunProps } from "../sun";
import { INITIAL } from "../../config";
import { clone } from "lodash";
-import { MeshBasicMaterial } from "three";
+import { MeshBasicMaterial, Vector3 } from "three";
beforeEach(() => {
jest.clearAllMocks();
@@ -97,6 +80,14 @@ describe("", () => {
expect(left).toBeLessThanOrEqual(-minBound);
});
+ it("disables shadows in low-detail mode", () => {
+ const p = fakeProps();
+ p.config.lowDetail = true;
+ const { container } = render();
+ const light = container.querySelector("directionallight");
+ expect(light?.getAttribute("castshadow")).not.toEqual("true");
+ });
+
it("renders animated without ref", () => {
const p = fakeProps();
p.config.animateSeasons = true;
@@ -110,14 +101,13 @@ describe("", () => {
it("renders animated", () => {
jest.spyOn(React, "useRef")
- .mockImplementationOnce(() => mock4Ref)
- .mockImplementationOnce(() => mock4Ref)
.mockImplementationOnce(() => mock1Ref)
.mockImplementationOnce(() => mock1Ref)
.mockImplementationOnce(() => mock1Ref)
- .mockImplementationOnce(() => mock0Ref)
+ .mockImplementationOnce(() => mock1Ref)
+ .mockImplementationOnce(() => mock1Ref)
.mockImplementationOnce(() => mockMaterialRef);
- jest.spyOn(React, "useState").mockReturnValue([[], jest.fn()]);
+ jest.spyOn(React, "useState").mockReturnValue([new Vector3(), jest.fn()]);
const p = fakeProps();
p.config.animateSeasons = true;
p.startTimeRef = { current: 0 };
diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx
index d321f52d97..7046275197 100644
--- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { fireEvent, render } from "@testing-library/react";
-import { Weed, WeedProps } from "../weed";
+import { Weed, WeedInstances, WeedInstancesProps, WeedProps } from "../weed";
import { INITIAL } from "../../config";
import { clone } from "lodash";
import { fakeWeed } from "../../../__test_support__/fake_state/resources";
@@ -9,16 +9,29 @@ import { Actions } from "../../../constants";
import { mockDispatch } from "../../../__test_support__/fake_dispatch";
import * as mapUtil from "../../../farm_designer/map/util";
import { Mode } from "../../../farm_designer/map/interfaces";
+import { useFrame } from "@react-three/fiber";
+import { Quaternion } from "three";
+import {
+ createRenderer,
+ unmountRenderer,
+} from "../../../__test_support__/test_renderer";
describe("", () => {
let getModeSpy: jest.SpyInstance;
+ let reactUseRefSpy: jest.SpyInstance | undefined;
+ const mountedWrappers: ReturnType[] = [];
beforeEach(() => {
getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none);
+ (useFrame as jest.Mock).mockClear();
});
afterEach(() => {
+ mountedWrappers.splice(0).forEach(wrapper =>
+ unmountRenderer(wrapper));
getModeSpy.mockRestore();
+ reactUseRefSpy?.mockRestore();
+ reactUseRefSpy = undefined;
});
const fakeProps = (): WeedProps => ({
@@ -59,6 +72,20 @@ describe("", () => {
expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1"));
});
+ it("doesn't navigate after orbiting over a weed", () => {
+ const p = fakeProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ p.weed.body.id = 1;
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const weed = wrapper.root
+ .findAll(node => node.props.name == "weed-1")[0];
+ weed.props.onClick({ delta: 3 });
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
it("doesn't navigate to weed info", () => {
const p = fakeProps();
p.dispatch = undefined;
@@ -68,4 +95,97 @@ describe("", () => {
weed && fireEvent.click(weed);
expect(mockNavigate).not.toHaveBeenCalled();
});
+
+ const fakeInstanceProps = (): WeedInstancesProps => ({
+ config: clone(INITIAL),
+ weeds: [fakeWeed(), fakeWeed()],
+ visible: true,
+ getZ: () => 0,
+ });
+
+ it("renders instanced weeds", () => {
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const meshes = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh");
+ expect(meshes.length).toEqual(2);
+ expect(meshes[0].props.name).toEqual("weed-icons");
+ expect(meshes[1].props.name).toEqual("weed-radius");
+ });
+
+ it("navigates from a weed instance", () => {
+ const p = fakeInstanceProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ p.weeds[0].body.id = 1;
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const weedIcons = wrapper.root
+ .findAll(node => node.props.name == "weed-icons")[0];
+ weedIcons.props.onClick({ instanceId: 0 });
+ expect(dispatch).toHaveBeenCalledWith({
+ type: Actions.SET_PANEL_OPEN, payload: true,
+ });
+ expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1"));
+ });
+
+ it("navigates from a weed radius instance", () => {
+ const p = fakeInstanceProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ p.weeds[0].body.id = 1;
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const weedRadius = wrapper.root
+ .findAll(node => node.props.name == "weed-radius")[0];
+ weedRadius.props.onClick({ instanceId: 0 });
+ expect(dispatch).toHaveBeenCalledWith({
+ type: Actions.SET_PANEL_OPEN, payload: true,
+ });
+ expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1"));
+ });
+
+ it("doesn't navigate after orbiting over weed instances", () => {
+ const p = fakeInstanceProps();
+ const dispatch = jest.fn();
+ p.dispatch = mockDispatch(dispatch);
+ p.weeds[0].body.id = 1;
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const weedIcons = wrapper.root
+ .findAll(node => node.props.name == "weed-icons")[0];
+ const weedRadius = wrapper.root
+ .findAll(node => node.props.name == "weed-radius")[0];
+ weedIcons.props.onClick({ instanceId: 0, delta: 3 });
+ weedRadius.props.onClick({ instanceId: 0, delta: 3 });
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ it("updates weed icon matrices on frame", () => {
+ const iconRef = {
+ current: {
+ setMatrixAt: jest.fn(),
+ instanceMatrix: { needsUpdate: false },
+ },
+ };
+ const radiusRef = {
+ current: {
+ setMatrixAt: jest.fn(),
+ instanceMatrix: { needsUpdate: false },
+ },
+ };
+ const updateStateRef = { current: {} };
+ reactUseRefSpy = jest.spyOn(React, "useRef")
+ .mockImplementationOnce(() => iconRef)
+ .mockImplementationOnce(() => updateStateRef)
+ .mockImplementationOnce(() => radiusRef)
+ .mockImplementation(value => ({ current: value }));
+ const wrapper = createRenderer();
+ mountedWrappers.push(wrapper);
+ const frameFn = (useFrame as jest.Mock).mock.calls[0][0];
+ frameFn({ camera: { quaternion: new Quaternion() } });
+ expect(iconRef.current.setMatrixAt).toHaveBeenCalled();
+ expect(iconRef.current.instanceMatrix.needsUpdate).toEqual(true);
+ });
});
diff --git a/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx b/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx
index abec987e83..af8dfe290d 100644
--- a/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/zoom_beacons_test.tsx
@@ -11,6 +11,8 @@ import {
createRenderer,
unmountRenderer,
} from "../../../__test_support__/test_renderer";
+import { FocusTransitionProvider } from "../../focus_transition";
+import { RenderOrder } from "../../constants";
const originalDocumentQuerySelector = document.querySelector.bind(document);
let isDesktopSpy: jest.SpyInstance;
@@ -78,7 +80,7 @@ describe("", () => {
unmountRenderer(wrapper);
});
- it("changes cursor", () => {
+ it("hides beacon while focused", () => {
const element = document.createElement("div");
Object.defineProperty(document, "querySelector", {
value: () => element,
@@ -89,19 +91,50 @@ describe("", () => {
p.config.animate = false;
const wrapper = createRenderer();
const sphere = wrapper.root.findAll(node => node.props.name == "beacon-sphere")[0];
- actRenderer(() => {
- sphere?.props.onPointerEnter();
- });
- expect(element.style.cursor).toEqual("zoom-out");
- actRenderer(() => {
- sphere?.props.onPointerLeave();
- });
- expect(element.style.cursor).toEqual("");
- actRenderer(() => {
- sphere?.props.onClick();
- });
+ expect(sphere).toBeUndefined();
expect(element.style.cursor).toEqual("");
- expect(p.setActiveFocus).toHaveBeenCalledWith("");
+ expect(p.setActiveFocus).not.toHaveBeenCalled();
+ unmountRenderer(wrapper);
+ });
+
+ it("renders visible beacons without writing depth", () => {
+ const p = fakeProps();
+ p.config.animate = false;
+ const wrapper = createRenderer();
+ const sphere = wrapper.root.findAll(node =>
+ node.props.name == "beacon-sphere")[0];
+ const materials = wrapper.root.findAll(node =>
+ node.props.depthWrite === false);
+ expect(sphere.props.renderOrder).toEqual(RenderOrder.beacons);
+ expect(materials[0].props.depthTest).toBeUndefined();
+ expect(materials[0].props.depthWrite).toEqual(false);
+ unmountRenderer(wrapper);
+ });
+
+ it("applies load-in scale on each anchored beacon visual", () => {
+ const p = fakeProps();
+ p.config.animate = false;
+ const wrapper = createRenderer();
+ const beacon = wrapper.root.findAll(node =>
+ node.props.name == "zoom-beacon")[0];
+ const visual = wrapper.root.findAll(node =>
+ node.props.name == "beacon-visual")[0];
+ expect(beacon.props.position).toBeTruthy();
+ expect(visual.props.scale).toEqual(0.35);
+ unmountRenderer(wrapper);
+ });
+
+ it("doesn't mount stable focused beacon visuals", () => {
+ const p = fakeProps();
+ p.activeFocus = "What you can grow";
+ p.config.animate = false;
+ const wrapper = createRenderer(
+
+
+ ,
+ );
+ expect(wrapper.root.findAll(node =>
+ node.props.name == "beacon-sphere").length).toEqual(0);
unmountRenderer(wrapper);
});
@@ -129,7 +162,8 @@ describe("", () => {
p.config.animate = false;
const wrapper = createRenderer();
const e = { stopPropagation: jest.fn() };
- const info = wrapper.root.findAll(node => node.props.className == "beacon-info")[0];
+ const info = wrapper.root.findAll(node =>
+ (node.props.className || "").includes("beacon-info"))[0];
actRenderer(() => {
info?.props.onPointerDown(e);
info?.props.onPointerMove(e);
diff --git a/frontend/three_d_garden/garden/clouds.tsx b/frontend/three_d_garden/garden/clouds.tsx
index 0a4fd62d99..3f24373fab 100644
--- a/frontend/three_d_garden/garden/clouds.tsx
+++ b/frontend/three_d_garden/garden/clouds.tsx
@@ -2,18 +2,29 @@ import React from "react";
import { Config, getSeasonProperties } from "../config";
import { Cloud, Clouds as DreiClouds } from "@react-three/drei";
import { ASSETS, RenderOrder } from "../constants";
+import { animated, useSpring } from "@react-spring/three";
export interface CloudsProps {
config: Config;
}
+const AnimatedCloud = animated(Cloud);
+
export const Clouds = (props: CloudsProps) => {
const { config } = props;
const sunParams = getSeasonProperties(config, "Summer");
+ const targetOpacity = sunParams.cloudOpacity;
+ const { opacity } = useSpring({
+ from: { opacity: 0 },
+ to: { opacity: targetOpacity },
+ immediate: !config.animate,
+ config: { duration: 600 },
+ });
+ if (!config.clouds || targetOpacity <= 0) { return undefined; }
return
- {
color="#ccc"
growth={400}
speed={.1}
- opacity={sunParams.cloudOpacity}
+ opacity={opacity}
fade={5000} />
;
};
diff --git a/frontend/three_d_garden/garden/grid.tsx b/frontend/three_d_garden/garden/grid.tsx
index a853aace2e..06a6c912b8 100644
--- a/frontend/three_d_garden/garden/grid.tsx
+++ b/frontend/three_d_garden/garden/grid.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { Config } from "../config";
-import { Group, Primitive } from "../components";
+import { Primitive } from "../components";
import {
get3DPositionFunc, zero as zeroFunc,
} from "../helpers";
@@ -13,6 +13,7 @@ import {
LineSegmentsGeometry,
} from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+import { FocusVisibilityGroup } from "../focus_transition";
export const gridLineOffsets = (botDimension: number): number[] => {
const lastRegularOffset = floor(botDimension, -2);
@@ -132,8 +133,17 @@ export const Grid = (props: GridProps) => {
});
return result;
}, [config, props.getZ]);
- return
{
color={"white"}
opacity={0.5}
linewidth={2} />
- ;
+ ;
};
diff --git a/frontend/three_d_garden/garden/ground.tsx b/frontend/three_d_garden/garden/ground.tsx
index 4050b93408..8dceff03d8 100644
--- a/frontend/three_d_garden/garden/ground.tsx
+++ b/frontend/three_d_garden/garden/ground.tsx
@@ -1,9 +1,10 @@
import React from "react";
import { Config, detailLevels } from "../config";
-import { Detailed, useTexture } from "@react-three/drei";
+import { Detailed } from "@react-three/drei";
import { Mesh, MeshPhongMaterial } from "../components";
import { ASSETS, BigDistance } from "../constants";
import { CircleGeometry, Float32BufferAttribute, RepeatWrapping } from "three";
+import { useTextureVariant } from "../texture_variants";
export interface GroundProps {
config: Config;
@@ -44,30 +45,21 @@ export const Ground = (props: GroundProps) => {
const { config } = props;
const groundZ = config.bedZOffset + config.bedHeight;
- const grassTextureBase = useTexture(ASSETS.textures.grass + "?=grass");
- const grassTexture = React.useMemo(() => {
- const texture = grassTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(24, 24);
- return texture;
- }, [grassTextureBase]);
- const labFloorTextureBase = useTexture(ASSETS.textures.concrete + "?=labFloor");
- const labFloorTexture = React.useMemo(() => {
- const texture = labFloorTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(16, 24);
- return texture;
- }, [labFloorTextureBase]);
- const brickTextureBase = useTexture(ASSETS.textures.bricks + "?=bricks");
- const brickTexture = React.useMemo(() => {
- const texture = brickTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(30, 30);
- return texture;
- }, [brickTextureBase]);
+ const grassTexture = useTextureVariant(ASSETS.textures.grass, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [24, 24],
+ });
+ const labFloorTexture = useTextureVariant(ASSETS.textures.concrete, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [16, 24],
+ });
+ const brickTexture = useTextureVariant(ASSETS.textures.bricks, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [30, 30],
+ });
const getGroundProperties = (sceneName: string) => {
switch (sceneName) {
diff --git a/frontend/three_d_garden/garden/images.tsx b/frontend/three_d_garden/garden/images.tsx
index f33acd3a3e..0e2f37f427 100644
--- a/frontend/three_d_garden/garden/images.tsx
+++ b/frontend/three_d_garden/garden/images.tsx
@@ -20,6 +20,7 @@ import {
} from "../../farm_designer/map/layers/images/map_image";
import { forceOnline } from "../../devices/must_be_online";
import { MoistureSurface } from "./moisture_texture";
+import { perfCount, perfMeasure } from "../../performance/perf";
interface BaseProps {
config: Config;
@@ -39,6 +40,7 @@ interface PlaneWrapperProps {
const PlaneWrapper = (props: PlaneWrapperProps) =>
perfCount("soilTextureRenders")}
position={[
props.bedWallThickness + props.width / 2,
props.bedWallThickness + props.height / 2,
@@ -87,6 +89,38 @@ export interface ImageTextureProps extends BaseProps {
showMoistureMap: boolean;
}
+const getSensorKey = (sensors: TaggedSensor[]) => {
+ let key = "";
+ sensors.map(sensor => {
+ key += `${sensor.uuid},${sensor.body.label},`;
+ key += `${sensor.body.mode},${sensor.body.pin}|`;
+ });
+ return key;
+};
+
+const getSensorReadingKey = (readings: TaggedSensorReading[]) => {
+ let key = "";
+ readings.map(reading => {
+ key += `${reading.uuid},${reading.body.x},${reading.body.y},`;
+ key += `${reading.body.z},${reading.body.value},`;
+ key += `${reading.body.mode},${reading.body.pin},${reading.body.read_at}|`;
+ });
+ return key;
+};
+
+export const getImageTextureKey = (props: ImageTextureProps) => {
+ const extents = soilSurfaceExtents(props.config);
+ const moistureVisible = props.showMoistureMap || props.showMoistureReadings;
+ return [
+ extents.x.min, extents.x.max,
+ extents.y.min, extents.y.max,
+ props.showMoistureMap,
+ props.showMoistureReadings,
+ moistureVisible && getSensorKey(props.sensors),
+ moistureVisible && getSensorReadingKey(props.sensorReadings),
+ ].join(":");
+};
+
export const ImageTexture = (props: ImageTextureProps) => {
const extents = soilSurfaceExtents(props.config);
const width = extents.x.max - extents.x.min;
@@ -98,10 +132,8 @@ export const ImageTexture = (props: ImageTextureProps) => {
const textureHeight = height >= width
? textureSize
: Math.max(1, Math.round(textureSize * height / width));
- const textureKey = [
- extents.x.min, extents.x.max,
- extents.y.min, extents.y.max,
- ].join(":");
+ const textureKey = perfMeasure("imageTextureSetupMs", () =>
+ getImageTextureKey(props));
const { bedXOffset, bedYOffset, bedWallThickness } = props.config;
const soilTexture = useTexture(ASSETS.textures.soil + "?=soilT");
const color = getColorFromBrightness(props.config.soilBrightness);
@@ -109,21 +141,27 @@ export const ImageTexture = (props: ImageTextureProps) => {
const designer = addPlantProps?.designer;
const getConfigValue = addPlantProps?.getConfigValue;
const visible = !!addPlantProps?.getConfigValue(BooleanSetting.show_images);
- const filteredImages = filterImages({
- visible,
- designer,
- images,
- getConfigValue,
- calibrationZ: "" + props.config.imgCalZ,
- });
- const imageArray = filteredImages.filter(img => !img.highlighted);
- const lastImageArray = filteredImages.filter(img => img.highlighted);
+ const { imageArray, lastImageArray } =
+ perfMeasure("imageTextureSetupMs", () => {
+ const filteredImages = filterImages({
+ visible,
+ designer,
+ images,
+ getConfigValue,
+ calibrationZ: "" + props.config.imgCalZ,
+ });
+ return {
+ imageArray: filteredImages.filter(img => !img.highlighted),
+ lastImageArray: filteredImages.filter(img => img.highlighted),
+ };
+ });
const highlightActive = lastImageArray[0]?.highlighted;
const commonProps = { width, height, bedWallThickness };
const mirrorTextureProps = getMirrorTextureProps(props.config);
return {
const texture = useTexture(url);
const i = (texture.source?.data ?? texture.image) as HTMLImageElement | undefined;
if (!i) { return; }
- const aspect = i.width / i.height;
- const height = i.height * config.imgScale;
- const width = height * aspect;
- if (!props.image.highlighted &&
- !imageSizeCheck({ width: i.width, height: i.height },
- { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; }
-
- const alreadyRotated = isRotated(props.image.body.meta.name);
- const initialRotation = alreadyRotated ? 0 : config.imgRotation;
- const rotation = (initialRotation + extraRotation(config)) * Math.PI / 180;
-
- return ;
+ return perfMeasure("imageWrapperSetupMs", () => {
+ const aspect = i.width / i.height;
+ const height = i.height * config.imgScale;
+ const width = height * aspect;
+ if (!props.image.highlighted &&
+ !imageSizeCheck({ width: i.width, height: i.height },
+ { x: "" + config.imgCenterX, y: "" + config.imgCenterY })) { return; }
+
+ const alreadyRotated = isRotated(props.image.body.meta.name);
+ const initialRotation = alreadyRotated ? 0 : config.imgRotation;
+ const rotation = (initialRotation + extraRotation(config)) * Math.PI / 180;
+
+ return ;
+ });
};
export const extraRotation = (config: Config) => {
diff --git a/frontend/three_d_garden/garden/moisture_texture.tsx b/frontend/three_d_garden/garden/moisture_texture.tsx
index e75e333aac..56a5ed277b 100644
--- a/frontend/three_d_garden/garden/moisture_texture.tsx
+++ b/frontend/three_d_garden/garden/moisture_texture.tsx
@@ -1,7 +1,10 @@
import React from "react";
import { Config } from "../config";
-import { Instance, Instances, Sphere } from "@react-three/drei";
-import { BoxGeometry, Group, MeshBasicMaterial } from "../components";
+import { Sphere } from "@react-three/drei";
+import {
+ BoxGeometry, Group, InstancedMesh as InstancedMeshComponent,
+ MeshBasicMaterial,
+} from "../components";
import { TaggedSensor, TaggedSensorReading } from "farmbot";
import { threeSpace, zZero } from "../helpers";
import {
@@ -10,7 +13,8 @@ import {
import {
filterMoistureReadings, getMoistureColor,
} from "../../farm_designer/map/layers/sensor_readings/sensor_readings_layer";
-import { InstancedBufferAttribute, InstancedMesh } from "three";
+import { Color, Matrix4 } from "three";
+import { perfMeasure } from "../../performance/perf";
export interface MoistureSurfaceProps {
position: [number, number, number];
@@ -24,32 +28,68 @@ export interface MoistureSurfaceProps {
showMoistureMap: boolean;
}
+interface MoistureInstanceBuffers {
+ matrices: Float32Array;
+ colors: Float32Array;
+ opacities: Float32Array;
+}
+
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");
- // 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;
+ const {
+ interpolationStepSize,
+ interpolationUseNearest,
+ interpolationPower,
+ bedLengthOuter,
+ bedWidthOuter,
+ } = props.config;
+ const options = React.useMemo(() => ({
+ stepSize: interpolationStepSize,
+ useNearest: interpolationUseNearest,
+ power: interpolationPower,
+ }), [
+ interpolationPower,
+ interpolationStepSize,
+ interpolationUseNearest,
+ ]);
+ const data = React.useMemo(() => perfMeasure("moistureSurfaceMs", () => {
+ if (!props.showMoistureMap) { return []; }
+ const { readings: moistureReadings } =
+ filterMoistureReadings(props.sensorReadings, props.sensors);
+ generateData({
+ kind: "SensorReading",
+ points: moistureReadings,
+ gridSize: {
+ x: bedLengthOuter,
+ y: bedWidthOuter,
+ },
+ getColor: getMoistureColor,
+ options,
});
- ref.current?.geometry?.setAttribute("instanceOpacity",
- new InstancedBufferAttribute(opacities, 1));
- }, [data]);
+ return getInterpolationData("SensorReading");
+ }), [
+ bedLengthOuter,
+ bedWidthOuter,
+ options,
+ props.sensorReadings,
+ props.sensors,
+ props.showMoistureMap,
+ ]);
+ const buffers = React.useMemo(() =>
+ perfMeasure("moistureInstanceNodesMs", () => {
+ const matrices = new Float32Array(data.length * 16);
+ const colors = new Float32Array(data.length * 3);
+ const opacities = new Float32Array(data.length);
+ const matrix = new Matrix4();
+ const instanceColor = new Color();
+ data.map((d, i) => {
+ const color = getMoistureColor(d.z);
+ matrix.identity().setPosition(d.x, d.y, d.z / 2);
+ matrix.toArray(matrices, i * 16);
+ instanceColor.set(color.rgb).toArray(colors, i * 3);
+ opacities[i] = color.a;
+ });
+ return { matrices, colors, opacities };
+ }), [data]);
return
{props.showMoistureReadings &&
{
readingZOverride={props.readingZOverride}
readings={props.sensorReadings} />}
{props.showMoistureMap &&
-
+
+
+
+ args={[options.stepSize, options.stepSize, options.stepSize]}>
+
+
{
shader.vertexShader = `
@@ -81,14 +133,7 @@ export const MoistureSurface = (props: MoistureSurfaceProps) => {
"vec4 diffuseColor = vec4( diffuse, opacity );",
"vec4 diffuseColor = vec4( diffuse, opacity * vInstanceOpacity );");
}} />
- {data.map(p => {
- const { x, y, z } = p;
- return ;
- })}
- }
+ }
;
};
diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx
index 74762020cb..618133b7db 100644
--- a/frontend/three_d_garden/garden/plant_instances.tsx
+++ b/frontend/three_d_garden/garden/plant_instances.tsx
@@ -1,10 +1,12 @@
import React from "react";
import {
- InstancedMesh as InstancedMeshType,
+ InstancedMesh as ThreeInstancedMesh,
Matrix4,
Quaternion,
Vector3,
MeshBasicMaterial as ThreeMeshBasicMaterial,
+ type Intersection,
+ type Raycaster,
} from "three";
import { ThreeEvent, useFrame } from "@react-three/fiber";
import { useNavigate } from "react-router";
@@ -24,9 +26,10 @@ import {
getPlantIconTextureUrl,
} from "./plant_icon_atlas";
import { Mode } from "../../farm_designer/map/interfaces";
-import moment from "moment";
-import { calcSunCoordinate, calcSunI, getCycleLength } from "./sun";
-import { instancedMeshKey } from "./instanced_mesh_key";
+import {
+ calcSunCoordinate, calcSunI, getAnimatedSeasonDate,
+} from "./sun";
+import { clickWasDragged } from "../click_event";
export interface PlantInstancesProps {
plants: ThreeDGardenPlant[];
@@ -35,17 +38,40 @@ export interface PlantInstancesProps {
visible?: boolean;
startTimeRef?: React.RefObject;
dispatch?: Function;
+ iconCapacities?: Record;
}
interface PlantIconInstancesProps extends PlantInstancesProps {
icon: string;
plants: ThreeDGardenPlant[];
plantIndexes: number[];
+ capacity: number;
+}
+
+interface PlantIconUpdateState {
+ lastCameraQuaternion: Quaternion;
+ hasCameraQuaternion: boolean;
+ needsMatrixUpdate: boolean;
}
+const newPlantIconUpdateState = (): PlantIconUpdateState => ({
+ lastCameraQuaternion: new Quaternion(),
+ hasCameraQuaternion: false,
+ needsMatrixUpdate: true,
+});
+
export const plantIconBrightness = (sunFactor?: number) =>
Math.max(0.25, sunFactor ?? 1);
+const plantIconRaycast = function (
+ this: ThreeInstancedMesh,
+ raycaster: Raycaster,
+ intersects: Intersection[],
+) {
+ if (HOVER_OBJECT_MODES.includes(getMode())) { return; }
+ ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects);
+};
+
const PlantIconInstances = (props: PlantIconInstancesProps) => {
const {
config, plants, icon, visible, startTimeRef, dispatch, getZ, plantIndexes,
@@ -57,10 +83,20 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => {
() => getPlantIconTexture(baseTexture, icon),
[baseTexture, icon]);
// eslint-disable-next-line no-null/no-null
- const instancedRef = React.useRef(null);
+ const instancedRef = React.useRef(null);
// eslint-disable-next-line no-null/no-null
const materialRef = React.useRef(null);
const lastBrightness = React.useRef(undefined);
+ const updateStateRef =
+ React.useRef(newPlantIconUpdateState());
+ const getUpdateState = () => {
+ const current =
+ updateStateRef.current as Partial | undefined;
+ if (!current?.lastCameraQuaternion) {
+ updateStateRef.current = newPlantIconUpdateState();
+ }
+ return updateStateRef.current;
+ };
const tempMatrix = React.useMemo(() => new Matrix4(), []);
const tempPosition = React.useMemo(() => new Vector3(), []);
const tempScale = React.useMemo(() => new Vector3(), []);
@@ -71,16 +107,26 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => {
+ getZ(plant.x, plant.y)
+ size / 2, [config, getZ]);
+ React.useEffect(() => {
+ const updateState = getUpdateState();
+ updateState.needsMatrixUpdate = true;
+ lastBrightness.current = undefined;
+ }, [config, getZ, plants, startTimeRef]);
+
+ // eslint-disable-next-line complexity
useFrame(state => {
const mesh = instancedRef.current;
- if (!mesh) { return; }
+ if (!mesh || visible === false) { return; }
+ if (plants.length == 0) { return; }
+ const updateState = getUpdateState();
+ const seasonAnimating = !!(config.animateSeasons && startTimeRef);
+ const cameraChanged = !updateState.hasCameraQuaternion
+ || !updateState.lastCameraQuaternion.equals(state.camera.quaternion);
let sunFactor = calcSunI(config.sunInclination);
- if (config.animateSeasons && startTimeRef) {
- const totalCycle = getCycleLength(config.plants);
+ if (seasonAnimating) {
const currentTime = performance.now() / 1000;
const t = currentTime - (startTimeRef.current || 0);
- const timeOffset = Math.min(t / totalCycle, 1) * 24 * 60 * 60;
- const date = moment().utc().startOf("day").add(timeOffset, "seconds").toDate();
+ const date = getAnimatedSeasonDate(config.plants, t);
sunFactor = calcSunI(calcSunCoordinate(date, 0, 52, 0).inclination);
}
const brightness = plantIconBrightness(sunFactor);
@@ -90,6 +136,9 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => {
materialRef.current.color.setScalar(brightness);
lastBrightness.current = brightness;
}
+ if (!updateState.needsMatrixUpdate && !seasonAnimating && !cameraChanged) {
+ return;
+ }
tempQuaternion.copy(state.camera.quaternion);
const currentTime = performance.now() / 1000;
const t = startTimeRef ? currentTime - (startTimeRef.current || 0) : 0;
@@ -108,9 +157,13 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => {
mesh.setMatrixAt(index, tempMatrix);
});
mesh.instanceMatrix.needsUpdate = true;
+ updateState.lastCameraQuaternion.copy(state.camera.quaternion);
+ updateState.hasCameraQuaternion = true;
+ updateState.needsMatrixUpdate = false;
});
const onClick = (event: ThreeEvent) => {
+ if (clickWasDragged(event)) { return; }
const instanceId = event.instanceId;
if (isUndefined(instanceId)) { return; }
const plant = plants[instanceId];
@@ -122,11 +175,13 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => {
};
return
@@ -138,9 +193,22 @@ const PlantIconInstances = (props: PlantIconInstancesProps) => {
;
};
-export const PlantInstances = (props: PlantInstancesProps) => {
+export const PlantInstances = React.memo((props: PlantInstancesProps) => {
const instances = React.useMemo(() => {
const iconInstances: Record = {};
+ Object.entries(props.iconCapacities || {}).map(([icon, capacity]) => {
+ iconInstances[icon] = {
+ config: props.config,
+ dispatch: props.dispatch,
+ getZ: props.getZ,
+ icon,
+ plants: [],
+ plantIndexes: [],
+ capacity,
+ startTimeRef: props.startTimeRef,
+ visible: props.visible,
+ };
+ });
props.plants.forEach((plant, index) => {
const instance = iconInstances[plant.icon];
if (instance) {
@@ -148,20 +216,39 @@ export const PlantInstances = (props: PlantInstancesProps) => {
instance.plantIndexes.push(index);
} else {
iconInstances[plant.icon] = {
- ...props,
+ config: props.config,
+ dispatch: props.dispatch,
+ getZ: props.getZ,
icon: plant.icon,
plants: [plant],
plantIndexes: [index],
+ capacity: 0,
+ startTimeRef: props.startTimeRef,
+ visible: props.visible,
};
}
});
- return Object.values(iconInstances);
- }, [props]);
+ return Object.values(iconInstances).map(instance => ({
+ ...instance,
+ capacity: Math.max(
+ instance.plants.length,
+ props.iconCapacities?.[instance.icon] || 0,
+ ),
+ }));
+ }, [
+ props.config,
+ props.dispatch,
+ props.getZ,
+ props.iconCapacities,
+ props.plants,
+ props.startTimeRef,
+ props.visible,
+ ]);
return <>
{instances.map(instance =>
)}
>;
-};
+});
diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx
index 34c9527ee3..37f5438b5b 100644
--- a/frontend/three_d_garden/garden/plants.tsx
+++ b/frontend/three_d_garden/garden/plants.tsx
@@ -7,10 +7,12 @@ import {
Group as GroupType,
Color,
WebGLProgramParametersWithUniforms,
- InstancedMesh as InstancedMeshType,
+ InstancedMesh as ThreeInstancedMesh,
Matrix4,
Quaternion,
InstancedBufferAttribute,
+ type Raycaster,
+ type Intersection,
} from "three";
import {
getGardenPositionFunc,
@@ -32,7 +34,8 @@ import {
import { ActivePositionRef } from "../bed/objects/pointer_objects";
import { Mode } from "../../farm_designer/map/interfaces";
import { findCrop } from "../../crops/find";
-import { instancedMeshKey } from "./instanced_mesh_key";
+import { perfMeasure } from "../../performance/perf";
+import { clickWasDragged } from "../click_event";
export interface ThreeDGardenPlant {
id?: number | undefined;
@@ -102,20 +105,52 @@ export interface PlantSpreadInstancesProps {
dispatch?: Function;
activePositionRef: ActivePositionRef;
spreadVisible: boolean;
+ instanceCapacity?: number;
}
-export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => {
+interface PlantSpreadUpdateState {
+ needsInstanceUpdate: boolean;
+ lastUpdateKey: string;
+}
+
+const plantSpreadRaycast = function (
+ this: ThreeInstancedMesh,
+ raycaster: Raycaster,
+ intersects: Intersection[],
+) {
+ if (HOVER_OBJECT_MODES.includes(getMode())) { return; }
+ ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects);
+};
+
+const newPlantSpreadUpdateState = (): PlantSpreadUpdateState => ({
+ needsInstanceUpdate: true,
+ lastUpdateKey: "",
+});
+
+export const PlantSpreadInstances = React.memo((props: PlantSpreadInstancesProps) => {
const {
config, plants, getZ, visible, dispatch, activePositionRef, spreadVisible,
} = props;
+ const instanceCapacity = Math.max(props.instanceCapacity || 0, plants.length);
const navigate = useNavigate();
// eslint-disable-next-line no-null/no-null
- const instancedRef = React.useRef(null);
+ const instancedRef = React.useRef(null);
const tempMatrix = React.useMemo(() => new Matrix4(), []);
const tempPosition = React.useMemo(() => new Vector3(), []);
const tempScale = React.useMemo(() => new Vector3(), []);
const tempQuaternion = React.useMemo(() => new Quaternion(), []);
const tempColor = React.useMemo(() => new Color(), []);
+ const updateStateRef =
+ React.useRef(newPlantSpreadUpdateState());
+ const getUpdateState = () => {
+ const current =
+ updateStateRef.current as Partial | undefined;
+ if (typeof current?.needsInstanceUpdate != "boolean" ||
+ typeof current?.lastUpdateKey != "string") {
+ updateStateRef.current = newPlantSpreadUpdateState();
+ }
+ return updateStateRef.current;
+ };
const get3DPosition = React.useMemo(() => get3DPositionFunc(config), [config]);
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo
const boundsCenter = React.useMemo(getBoundsCenter(config), []);
@@ -135,12 +170,14 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => {
const activeDragSpread = editPlantMode
? currentPlant?.spread
: findCrop(Path.getCropSlug()).spread;
+ const hasTransientPlant = React.useMemo(() =>
+ plants.some(plant => !plant.id), [plants]);
- const ensureInstanceColor = React.useCallback((mesh: InstancedMeshType) => {
+ const ensureInstanceColor = React.useCallback((mesh: ThreeInstancedMesh) => {
const needsResize = !mesh.instanceColor
- || mesh.instanceColor.count != plants.length;
+ || mesh.instanceColor.count != instanceCapacity;
if (needsResize) {
- const colors = new Float32Array(plants.length * 3);
+ const colors = new Float32Array(instanceCapacity * 3);
colors.fill(1);
mesh.instanceColor = new InstancedBufferAttribute(colors, 3);
if (mesh.geometry) {
@@ -154,7 +191,7 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => {
material.needsUpdate = true;
}
}
- }, [plants.length]);
+ }, [instanceCapacity]);
React.useLayoutEffect(() => {
const mesh = instancedRef.current;
@@ -162,69 +199,95 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => {
ensureInstanceColor(mesh);
}, [ensureInstanceColor]);
+ React.useEffect(() => {
+ const updateState = getUpdateState();
+ updateState.needsInstanceUpdate = true;
+ }, [activeDragSpread, config, getZ, plants]);
+
+ // eslint-disable-next-line complexity
useFrame(state => {
const mesh = instancedRef.current;
if (!mesh || visible === false) { return; }
+ const updateState = getUpdateState();
+ const clickToAddMode = getMode() == Mode.clickToAdd;
+ const spreadActive =
+ spreadVisible || editPlantMode || clickToAddMode || hasTransientPlant;
+ if (!spreadActive && !updateState.needsInstanceUpdate) { return; }
ensureInstanceColor(mesh);
tempQuaternion.copy(state.camera.quaternion);
- const worldPos = activePositionRef.current || { x: -10000, y: -10000 };
- const activePointer = getGardenPositionFunc(config)(worldPos);
const active = editPlantMode
? {
x: currentPlant?.x || -10000,
y: currentPlant?.y || -10000,
}
- : {
- x: activePointer.x,
- y: activePointer.y,
- };
- const clickToAddMode = getMode() == Mode.clickToAdd;
- plants.forEach((plant, index) => {
- const spreadRadii = getSpreadRadii({
- activeDragSpread,
- inactiveSpread: plant.spread,
- radius: plant.size / 2,
- });
- const scale = (spreadVisible || !plant.id || editPlantMode)
- ? spreadRadii.inactive
- : 0;
- const position = get3DPosition({ x: plant.x, y: plant.y });
- tempPosition.set(
- position.x,
- position.y,
- getPlantZ(plant.size, plant),
- );
- tempScale.set(scale, scale, scale);
- tempMatrix.compose(tempPosition, tempQuaternion, tempScale);
- mesh.setMatrixAt(index, tempMatrix);
- if (mesh.setColorAt) {
- const overlap = getSpreadOverlap({
- spreadRadii,
- activeDragXY: {
- x: round(active.x),
- y: round(active.y),
- z: 0,
- },
- plantXY: {
- x: round(plant.x),
- y: round(plant.y),
- z: 0,
- },
+ : getGardenPositionFunc(config)(
+ activePositionRef.current || { x: -10000, y: -10000 });
+ const activeKey = (clickToAddMode || editPlantMode)
+ ? `${round(active.x)}:${round(active.y)}`
+ : "";
+ const updateKey = [
+ spreadVisible,
+ editPlantMode,
+ clickToAddMode,
+ hasTransientPlant,
+ plantId,
+ activeDragSpread || "",
+ activeKey,
+ ].join(":");
+ if (!updateState.needsInstanceUpdate &&
+ updateState.lastUpdateKey == updateKey) { return; }
+ perfMeasure("spreadFrameUpdateMs", () => {
+ plants.forEach((plant, index) => {
+ const spreadRadii = getSpreadRadii({
+ activeDragSpread,
+ inactiveSpread: plant.spread,
+ radius: plant.size / 2,
});
- const color = (plant.id && (plantId != plant.id))
- ? overlap.color.rgb
- : [1, 1, 1];
- const insideColor =
- (clickToAddMode || editPlantMode) ? color : [0, 1, 0];
- tempColor.setRGB(insideColor[0], insideColor[1], insideColor[2]);
- mesh.setColorAt(index, tempColor);
- }
+ const scale = (spreadVisible || !plant.id || editPlantMode)
+ ? spreadRadii.inactive
+ : 0;
+ const position = get3DPosition({ x: plant.x, y: plant.y });
+ tempPosition.set(
+ position.x,
+ position.y,
+ getPlantZ(plant.size, plant),
+ );
+ tempScale.set(scale, scale, scale);
+ tempMatrix.compose(tempPosition, tempQuaternion, tempScale);
+ mesh.setMatrixAt(index, tempMatrix);
+ if (mesh.setColorAt) {
+ let insideColor = [0, 1, 0];
+ if (clickToAddMode || editPlantMode) {
+ const overlap = getSpreadOverlap({
+ spreadRadii,
+ activeDragXY: {
+ x: round(active.x),
+ y: round(active.y),
+ z: 0,
+ },
+ plantXY: {
+ x: round(plant.x),
+ y: round(plant.y),
+ z: 0,
+ },
+ });
+ insideColor = (plant.id && (plantId != plant.id))
+ ? overlap.color.rgb
+ : [1, 1, 1];
+ }
+ tempColor.setRGB(insideColor[0], insideColor[1], insideColor[2]);
+ mesh.setColorAt(index, tempColor);
+ }
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; }
});
- mesh.instanceMatrix.needsUpdate = true;
- if (mesh.instanceColor) { mesh.instanceColor.needsUpdate = true; }
+ updateState.needsInstanceUpdate = false;
+ updateState.lastUpdateKey = updateKey;
});
const onClick = (event: ThreeEvent) => {
+ if (clickWasDragged(event)) { return; }
const instanceId = event.instanceId;
if (isUndefined(instanceId)) { return; }
const plant = plants[instanceId];
@@ -236,11 +299,13 @@ export const PlantSpreadInstances = (props: PlantSpreadInstancesProps) => {
};
return
{
}}
depthWrite={false} />
;
-};
+});
export const getBoundsCenter = (config: Config) => () =>
diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx
index d60892698d..3243026010 100644
--- a/frontend/three_d_garden/garden/point.tsx
+++ b/frontend/three_d_garden/garden/point.tsx
@@ -1,9 +1,20 @@
import React from "react";
import { SpecialStatus, TaggedGenericPointer, Xyz } from "farmbot";
import { Config } from "../config";
-import { Group, MeshPhongMaterial } from "../components";
+import {
+ Group, InstancedMesh, MeshPhongMaterial,
+} from "../components";
import { Cylinder, Sphere, Torus } from "@react-three/drei";
-import { DoubleSide } from "three";
+import {
+ DoubleSide,
+ Euler,
+ InstancedMesh as InstancedMeshType,
+ Matrix4,
+ Mesh as ThreeMesh,
+ Quaternion,
+ Vector3,
+} from "three";
+import { ThreeEvent } from "@react-three/fiber";
import { getWorldPositionFunc } from "../helpers";
import { useNavigate } from "react-router";
import { Path } from "../../internal_urls";
@@ -17,6 +28,7 @@ import { HOVER_OBJECT_MODES, RenderOrder } from "../constants";
import {
BillboardRef, ImageRef, RadiusRef, TorusRef,
} from "../bed/objects/pointer_objects";
+import { clickWasDragged } from "../click_event";
const POINT_PIN_RADIUS = 12.5;
const POINT_PIN_HEIGHT = 50;
@@ -47,7 +59,8 @@ export const Point = (props: PointProps) => {
y: point.body.y,
z: props.getZ(point.body.x, point.body.y),
}}
- onClick={() => {
+ onClick={(event) => {
+ if (clickWasDragged(event)) { return; }
if (point.body.id && !isUndefined(props.dispatch) && props.visible &&
!HOVER_OBJECT_MODES.includes(getMode())) {
props.dispatch(setPanelOpen(true));
@@ -60,6 +73,193 @@ export const Point = (props: PointProps) => {
/>;
};
+interface PointInstance {
+ point: TaggedGenericPointer;
+ position: [number, number, number];
+ radius: number;
+}
+
+interface PointInstanceBucket {
+ color: string | undefined;
+ alpha: number;
+ points: PointInstance[];
+ ringPoints: PointInstance[];
+}
+
+export interface PointInstancesProps {
+ points: TaggedGenericPointer[];
+ config: Config;
+ dispatch?: Function;
+ visible: boolean;
+ getZ(x: number, y: number): number;
+}
+
+const pointAlpha = (point: TaggedGenericPointer) =>
+ point.specialStatus !== SpecialStatus.SAVED ? 0.5 : 1;
+
+const pointBucketKey = (point: TaggedGenericPointer) =>
+ `${point.body.meta.color || ""}-${pointAlpha(point)}`;
+
+const getPointInstanceBuckets = (
+ points: TaggedGenericPointer[],
+ config: Config,
+ getZ: (x: number, y: number) => number,
+) => {
+ const getWorldPosition = getWorldPositionFunc(config);
+ const buckets: Record = {};
+ points.forEach(point => {
+ const alpha = pointAlpha(point);
+ const key = pointBucketKey(point);
+ const instance = {
+ point,
+ position: getWorldPosition({
+ x: point.body.x,
+ y: point.body.y,
+ z: getZ(point.body.x, point.body.y),
+ }),
+ radius: point.body.radius,
+ };
+ buckets[key] ||= {
+ color: point.body.meta.color,
+ alpha,
+ points: [],
+ ringPoints: [],
+ };
+ buckets[key].points.push(instance);
+ if (point.body.radius > 0) { buckets[key].ringPoints.push(instance); }
+ });
+ return Object.values(buckets);
+};
+
+interface PointInstanceBucketProps extends PointInstancesProps {
+ bucket: PointInstanceBucket;
+}
+
+const PointBucketInstances = (props: PointInstanceBucketProps) => {
+ const { bucket, dispatch, visible } = props;
+ const navigate = useNavigate();
+ // eslint-disable-next-line no-null/no-null
+ const pinRef = React.useRef(null);
+ // eslint-disable-next-line no-null/no-null
+ const sphereRef = React.useRef(null);
+ // eslint-disable-next-line no-null/no-null
+ const ringRef = React.useRef(null);
+ const tempMatrix = React.useMemo(() => new Matrix4(), []);
+ const tempPosition = React.useMemo(() => new Vector3(), []);
+ const pinRotation = React.useMemo(() =>
+ new Quaternion().setFromEuler(new Euler(Math.PI / 2, 0, 0)), []);
+ const noRotation = React.useMemo(() => new Quaternion(), []);
+ const noScale = React.useMemo(() => new Vector3(1, 1, 1), []);
+ const ringScale = React.useMemo(() => new Vector3(), []);
+
+ React.useEffect(() => {
+ const pinMesh = pinRef.current;
+ const sphereMesh = sphereRef.current;
+ if (!pinMesh?.setMatrixAt || !sphereMesh?.setMatrixAt) { return; }
+ bucket.points.forEach((instance, index) => {
+ const [x, y, z] = instance.position;
+ tempPosition.set(x, y, z + POINT_PIN_HEIGHT / 2);
+ tempMatrix.compose(tempPosition, pinRotation, noScale);
+ pinMesh.setMatrixAt(index, tempMatrix);
+ tempPosition.set(x, y, z + POINT_PIN_HEIGHT);
+ tempMatrix.compose(tempPosition, noRotation, noScale);
+ sphereMesh.setMatrixAt(index, tempMatrix);
+ });
+ pinMesh.instanceMatrix.needsUpdate = true;
+ sphereMesh.instanceMatrix.needsUpdate = true;
+ }, [bucket.points, noRotation, noScale, pinRotation, tempMatrix, tempPosition]);
+
+ React.useEffect(() => {
+ const ringMesh = ringRef.current;
+ if (!ringMesh?.setMatrixAt) { return; }
+ bucket.ringPoints.forEach((instance, index) => {
+ const [x, y, z] = instance.position;
+ tempPosition.set(x, y, z);
+ ringScale.set(
+ instance.radius,
+ instance.radius,
+ POINT_CYLINDER_SCALE_FACTOR,
+ );
+ tempMatrix.compose(tempPosition, noRotation, ringScale);
+ ringMesh.setMatrixAt(index, tempMatrix);
+ });
+ ringMesh.instanceMatrix.needsUpdate = true;
+ }, [bucket.ringPoints, noRotation, ringScale, tempMatrix, tempPosition]);
+
+ const onClick = (instances: PointInstance[]) =>
+ (event: ThreeEvent) => {
+ if (clickWasDragged(event)) { return; }
+ const instanceId = event.instanceId;
+ if (isUndefined(instanceId)) { return; }
+ const point = instances[instanceId]?.point;
+ if (point?.body.id && dispatch && visible &&
+ !HOVER_OBJECT_MODES.includes(getMode())) {
+ dispatch(setPanelOpen(true));
+ navigate(Path.points(point.body.id));
+ }
+ };
+
+ return <>
+
+
+
+
+
+
+
+
+ {bucket.ringPoints.length > 0 &&
+
+
+
+ }
+ >;
+};
+
+export const PointInstances = React.memo((props: PointInstancesProps) => {
+ const buckets = React.useMemo(
+ () => getPointInstanceBuckets(props.points, props.config, props.getZ),
+ [props.points, props.config, props.getZ]);
+ return <>
+ {buckets.map(bucket =>
+ )}
+ >;
+});
+
export interface DrawnPointProps {
designer: DesignerState;
usePosition: boolean;
@@ -95,7 +295,7 @@ export const DrawnPoint = (props: DrawnPointProps) => {
interface PointBaseProps {
pointName: string;
position?: Record;
- onClick?: () => void;
+ onClick?: (event: ThreeEvent) => void;
color: string | undefined;
radius: number;
alpha: number;
@@ -138,7 +338,7 @@ const PointBase = (props: PointBaseProps) => {
opacity={1 * alpha} />
- {radius > 0 &&
+ {(radius > 0 || torusRef) &&
{
+ (torusRef as React.MutableRefObject).current = node;
+};
+
const HollowCylinder = (
{ radius, color, alpha, torusRef }: HollowCylinderProps,
) => {
+ const setTorusRef = React.useCallback((node: ThreeMesh | null) => {
+ if (!torusRef) { return; }
+ const maybeMesh = node as Partial | null;
+ if (!node || maybeMesh?.scale) {
+ setTorusRefCurrent(torusRef, node);
+ }
+ }, [torusRef]);
return torusRef
?
diff --git a/frontend/three_d_garden/garden/solar.tsx b/frontend/three_d_garden/garden/solar.tsx
index be72a50ddf..3a10432670 100644
--- a/frontend/three_d_garden/garden/solar.tsx
+++ b/frontend/three_d_garden/garden/solar.tsx
@@ -1,9 +1,18 @@
import React from "react";
-import { Shape } from "three";
-import { Extrude, Line } from "@react-three/drei";
+import {
+ DoubleSide, ExtrudeGeometry, InstancedMesh as ThreeInstancedMesh,
+ Object3D, Shape,
+} from "three";
+import { Line } from "@react-three/drei";
+import { animated, useSpring } from "@react-spring/three";
+import { SpringValue } from "@react-spring/core";
import { threeSpace } from "../helpers";
import { Config } from "../config";
-import { Group, Mesh, BoxGeometry, MeshPhongMaterial } from "../components";
+import {
+ Group, Mesh, BoxGeometry, MeshPhongMaterial, InstancedMesh,
+} from "../components";
+import { easeInOutCubic, useFocusTransition } from "../focus_transition";
+import { RenderOrder } from "../constants";
export interface SolarProps {
config: Config;
@@ -12,6 +21,11 @@ export interface SolarProps {
const panelWidth = 540;
const panelLength = 1040;
+const panelDepth = 30;
+const cellDepth = 2;
+const cellZ = panelDepth / 2 + cellDepth + 1;
+const AnimatedMeshPhongMaterial = animated(MeshPhongMaterial);
+const AnimatedLine = animated(Line);
const cell2D = () => {
const cellSize = 95;
@@ -28,8 +42,8 @@ const cell2D = () => {
return path;
};
-const cellArray = () => {
- const cells = [];
+const cellPositions = () => {
+ const positions: [number, number, number][] = [];
const cellSize = 100;
const cellsWide = Math.floor(panelWidth / cellSize);
const cellsLong = Math.floor(panelLength / cellSize);
@@ -38,32 +52,85 @@ const cellArray = () => {
for (let y = 0; y < cellsLong; y++) {
const xPos = x * cellSize - (panelWidth / 2) + 20 + 2.5;
const yPos = y * cellSize - (panelLength / 2) + 20 + 2.5;
- cells.push(
-
-
-
-
- );
+ positions.push([xPos, yPos, cellZ]);
}
}
- return cells;
+ return positions;
};
-const SolarPanel = () => {
+const CELL_POSITIONS = cellPositions();
+
+interface SolarMaterialProps {
+ opacity: number | SpringValue;
+ color: string;
+ side?: typeof DoubleSide;
+}
+
+const SolarMaterial = (props: SolarMaterialProps) =>
+ ;
+
+const SolarCells = (props: { opacity: SolarMaterialProps["opacity"] }) => {
+ const geometry = React.useMemo(
+ () => new ExtrudeGeometry(cell2D(), {
+ steps: 1,
+ depth: cellDepth,
+ bevelEnabled: false,
+ }),
+ [],
+ );
+ const setRef = React.useCallback((mesh: ThreeInstancedMesh | null) => {
+ if (!mesh || typeof mesh.setMatrixAt != "function") { return; }
+ const dummy = new Object3D();
+ CELL_POSITIONS.map((position, index) => {
+ dummy.position.set(...position);
+ dummy.updateMatrix();
+ mesh.setMatrixAt(index, dummy.matrix);
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ }, []);
+
+ return
+
+ ;
+};
+
+const SolarPanel = (props: { opacity: SolarMaterialProps["opacity"] }) => {
return
-
-
-
+
+
+
- {cellArray()}
+
;
};
export const Solar = (props: SolarProps) => {
const { config } = props;
const zGround = -config.bedZOffset - config.bedHeight;
- return
+ const transition = useFocusTransition();
+ const visible = config.solar || props.activeFocus == "What you need to provide";
+ const { opacity } = useSpring({
+ opacity: visible ? 1 : 0,
+ immediate: !transition.enabled,
+ config: {
+ duration: transition.duration,
+ easing: easeInOutCubic,
+ },
+ });
+ const rendered = transition.enabled || visible;
+ if (!rendered) { return undefined; }
+
+ return
{
]}
rotation={[0, 0, Math.PI]}>
-
+
-
+
- {
zGround + 20,
]]}
color={"yellow"}
+ transparent={true}
+ opacity={opacity}
lineWidth={5} />
;
};
diff --git a/frontend/three_d_garden/garden/sun.tsx b/frontend/three_d_garden/garden/sun.tsx
index 7ea48ccfc9..a27634294a 100644
--- a/frontend/three_d_garden/garden/sun.tsx
+++ b/frontend/three_d_garden/garden/sun.tsx
@@ -15,7 +15,7 @@ import { useFrame } from "@react-three/fiber";
import SunCalc from "suncalc";
import { range } from "lodash";
import moment from "moment";
-import { SEASON_DURATIONS } from "../../promo/constants";
+import { Season, SEASON_DURATIONS } from "../../promo/constants";
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { ASSETS, BigDistance } from "../constants";
@@ -23,32 +23,33 @@ const shadowBias = -0.0005;
const shadowRadius = 8;
const shadowBlurSamples = 8;
const shadowBuffer = 1000;
-const SUN_COLOR = ["#FFD700", "#FFEA00", "#FFF700", "#FFE066"];
+const SUN_COLOR = "#FFD700";
+const DAY_SECONDS = 24 * 60 * 60;
+const SUN_TIME_STEP_SECONDS = 60;
+const BELOW_HORIZON_SUN_SPEED = 10;
+const BELOW_HORIZON_SPEED_INCLINATION = -10;
+const sunAnimationCache: Record = {};
+const SEASON_SUN_DATES: Record = {
+ [Season.Spring]: [2, 20],
+ [Season.Summer]: [5, 21],
+ [Season.Fall]: [8, 22],
+ [Season.Winter]: [11, 21],
+};
export const getCycleLength = (season: string) =>
SEASON_DURATIONS[season] || 20;
+interface SunAnimationSample {
+ animationSeconds: number;
+ sunSeconds: number;
+}
+
export interface SunProps {
config: Config;
startTimeRef?: React.RefObject;
skyRef: React.RefObject;
}
-const SUN_COUNT = 1;
-const offset = 50;
-const SUN_OFFSETS: [number, number][] = [
- [0, 0],
- [0, offset],
- [offset, offset],
- [offset, 0],
-];
-
-const offsetSunPos =
- (sunPos: Vector3, index: number): [number, number, number] => {
- const offset = SUN_OFFSETS[index];
- return [sunPos.x + offset[0], sunPos.y + offset[1], sunPos.z];
- };
-
export const calcSunCoordinate = (
date: Date,
heading: number,
@@ -63,6 +64,51 @@ export const calcSunCoordinate = (
};
};
+export const getAnimatedSeasonDate = (
+ season: string,
+ elapsedSeconds: number,
+ dayStart = moment().utc().startOf("day").toDate(),
+) => {
+ const totalCycle = getCycleLength(season);
+ const clampedElapsed = Math.min(Math.max(elapsedSeconds, 0), totalCycle);
+ const seasonDayStart = getSeasonDayStart(season, dayStart);
+ const samples = getSunAnimationSamples(seasonDayStart);
+ const totalAnimationSeconds = samples[samples.length - 1].animationSeconds;
+ const targetAnimationSeconds =
+ clampedElapsed / totalCycle * totalAnimationSeconds;
+ const sample = samples.find(({ animationSeconds }) =>
+ animationSeconds >= targetAnimationSeconds) || samples[samples.length - 1];
+ const date = new Date(seasonDayStart.getTime() + sample.sunSeconds * 1000);
+ return date;
+};
+
+const getSeasonDayStart = (season: string, dayStart: Date) => {
+ const seasonDate = SEASON_SUN_DATES[season];
+ if (!seasonDate) { return dayStart; }
+ const [month, day] = seasonDate;
+ return new Date(Date.UTC(2016, month, day));
+};
+
+const getSunAnimationSamples = (dayStart: Date): SunAnimationSample[] => {
+ const cacheKey = dayStart.toISOString().slice(0, 10);
+ const cachedSamples = sunAnimationCache[cacheKey];
+ if (cachedSamples) { return cachedSamples; }
+ const samples: SunAnimationSample[] = [];
+ let animationSeconds = 0;
+ for (let sunSeconds = 0; sunSeconds <= DAY_SECONDS;
+ sunSeconds += SUN_TIME_STEP_SECONDS) {
+ samples.push({ animationSeconds, sunSeconds });
+ const date = new Date(dayStart.getTime() + sunSeconds * 1000);
+ const { inclination } = calcSunCoordinate(date, 0, 35, 0);
+ const speed = inclination < BELOW_HORIZON_SPEED_INCLINATION
+ ? BELOW_HORIZON_SUN_SPEED
+ : 1;
+ animationSeconds += SUN_TIME_STEP_SECONDS / speed;
+ }
+ sunAnimationCache[cacheKey] = samples;
+ return samples;
+};
+
const toRad = (degrees: number) => degrees * Math.PI / 180;
const polarToCartesian = (
radius: number,
@@ -140,17 +186,17 @@ export const Sun = (props: SunProps) => {
config.sunAzimuth,
BigDistance.sunActual);
- const lightRefs = React.useRef<(ThreeDirectionalLight | null)[]>([]);
- const sphereRefs = React.useRef<(Mesh | null)[]>([]);
+ // eslint-disable-next-line no-null/no-null
+ const lightRef = React.useRef(null);
+ // eslint-disable-next-line no-null/no-null
+ const debugSunRef = React.useRef(null);
// eslint-disable-next-line no-null/no-null
const sunRef = React.useRef(null);
// eslint-disable-next-line no-null/no-null
const sunFlatRef = React.useRef(null);
// eslint-disable-next-line no-null/no-null
const lineRef = React.useRef(null);
- const [points, setPoints] = React.useState(
- range(SUN_COUNT).map(index => new Vector3(...offsetSunPos(sunPos, index))),
- );
+ const [point, setPoint] = React.useState(sunPos);
// eslint-disable-next-line no-null/no-null
const starsRef = React.useRef(null);
const origin = new Vector3(0, 0, 0);
@@ -192,33 +238,22 @@ export const Sun = (props: SunProps) => {
useFrame(() => {
if (!config.animateSeasons || !props.startTimeRef) { return; }
- const totalCycle = getCycleLength(config.plants);
const currentTime = performance.now() / 1000;
const t = currentTime - props.startTimeRef.current;
- const timeOffset = Math.min(t / totalCycle, 1) * 24 * 60 * 60;
- const date = moment().utc().startOf("day").add(timeOffset, "seconds").toDate();
- const { azimuth, inclination } = calcSunCoordinate(date, 0, 52, 0);
+ const date = getAnimatedSeasonDate(config.plants, t);
+ const { azimuth, inclination } = calcSunCoordinate(date, 0, 35, 0);
const sunFactor = calcSunI(inclination);
- const position = (index: number) => {
- const sunPos = sunPosition(inclination, azimuth, BigDistance.sunActual);
- return offsetSunPos(sunPos, index);
- };
+ const position = sunPosition(inclination, azimuth, BigDistance.sunActual);
setSunSky(sunFactor, config.sun);
- lightRefs.current.forEach((light, index) => {
- if (light) {
- light.position?.set(...position(index));
- light.intensity =
- sunIntensity * config.sun / 100 * sunFactor;
- }
- });
+ if (lightRef.current) {
+ lightRef.current.position?.set(position.x, position.y, position.z);
+ lightRef.current.intensity =
+ sunIntensity * config.sun / 100 * sunFactor;
+ }
- sphereRefs.current.forEach((sphere, index) => {
- if (sphere) {
- sphere.position.set(...position(index));
- }
- });
+ debugSunRef.current?.position.set(position.x, position.y, position.z);
const visualPos = sunPosition(inclination, azimuth, BigDistance.sunVisual);
sunRef.current?.position?.set(visualPos.x, visualPos.y, visualPos.z);
@@ -226,52 +261,40 @@ export const Sun = (props: SunProps) => {
sunFlatRef.current?.position?.set(flatPos.x, flatPos.y, flatPos.z);
if (lineRef.current) {
- const newPoints = range(SUN_COUNT)
- // eslint-disable-next-line @react-three/no-new-in-loop
- .map(index => new Vector3(...position(index)));
- setPoints(newPoints);
+ setPoint(position);
}
});
return
- {range(SUN_COUNT).map(index => {
- const position = offsetSunPos(sunPos, index);
- const color = SUN_COLOR[index];
- const intensity = sunIntensity * config.sun / 100 * renderedSunFactor;
- return
- {
- if (el) { lightRefs.current[index] = el; }
- }}
- intensity={intensity * 4 / SUN_COUNT}
- color={sunColor}
- castShadow={true}
- shadow-bias={shadowBias}
- shadow-radius={shadowRadius}
- shadow-blurSamples={shadowBlurSamples}
- shadow-mapSize-width={1024}
- shadow-mapSize-height={1024}
- shadow-camera-near={1}
- shadow-camera-far={BigDistance.sunAffect}
- shadow-camera-left={-shadowBounds}
- shadow-camera-right={shadowBounds}
- shadow-camera-top={shadowBounds}
- shadow-camera-bottom={-shadowBounds}
- position={position}
- />
- {config.lightsDebug &&
- }
- {config.lightsDebug &&
- t}>
- { if (el) { sphereRefs.current[index] = el; } }}
- args={[500, 16, 16]}
- position={position}>
-
-
- }
- ;
- })}
+
+ {config.lightsDebug &&
+ }
+ {config.lightsDebug &&
+ t}>
+
+
+
+ }
{
config.sunInclination,
config.sunAzimuth,
BigDistance.sunVisual)}>
-
+
{config.lightsDebug && }
@@ -287,7 +310,7 @@ export const Sun = (props: SunProps) => {
ref={sunFlatRef}
args={[500, 8, 8]}
position={sunPosition(0, config.sunAzimuth, BigDistance.ground)}>
-
+
}
;
};
diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx
index fc8bc6b67a..78c8a0c31b 100644
--- a/frontend/three_d_garden/garden/weed.tsx
+++ b/frontend/three_d_garden/garden/weed.tsx
@@ -2,8 +2,17 @@ import React from "react";
import { TaggedWeedPointer, Xyz } from "farmbot";
import { Config } from "../config";
import { ASSETS, HOVER_OBJECT_MODES, RenderOrder } from "../constants";
-import { Group, MeshPhongMaterial } from "../components";
-import { Image, Billboard, Sphere } from "@react-three/drei";
+import {
+ Group, InstancedMesh, MeshBasicMaterial, MeshPhongMaterial, PlaneGeometry,
+} from "../components";
+import { Image, Billboard, Sphere, useTexture } from "@react-three/drei";
+import {
+ InstancedMesh as InstancedMeshType,
+ Matrix4,
+ Quaternion,
+ Vector3,
+} from "three";
+import { ThreeEvent, useFrame } from "@react-three/fiber";
import { getWorldPositionFunc } from "../helpers";
import { useNavigate } from "react-router";
import { Path } from "../../internal_urls";
@@ -11,6 +20,7 @@ import { isUndefined } from "lodash";
import { setPanelOpen } from "../../farm_designer/panel_header";
import { getMode } from "../../farm_designer/map/util";
import { RadiusRef, BillboardRef, ImageRef } from "../bed/objects/pointer_objects";
+import { clickWasDragged } from "../click_event";
export const WEED_IMG_SIZE_FRACTION = 0.89;
@@ -28,7 +38,8 @@ export const Weed = (props: WeedProps) => {
return {
+ onClick={(event) => {
+ if (clickWasDragged(event)) { return; }
if (weed.body.id && !isUndefined(props.dispatch) && props.visible &&
!HOVER_OBJECT_MODES.includes(getMode())) {
props.dispatch(setPanelOpen(true));
@@ -48,7 +59,7 @@ export const Weed = (props: WeedProps) => {
interface WeedBaseProps {
pointName: string;
position?: Record;
- onClick?: () => void;
+ onClick?: (event: ThreeEvent) => void;
color: string | undefined;
radius: number;
alpha: number;
@@ -99,3 +110,223 @@ export const WeedBase = (props: WeedBaseProps) => {
;
};
+
+interface WeedInstance {
+ weed: TaggedWeedPointer;
+ position: [number, number, number];
+ weedSize: number;
+ iconSize: number;
+}
+
+interface WeedColorBucket {
+ color: string | undefined;
+ weeds: WeedInstance[];
+}
+
+export interface WeedInstancesProps {
+ weeds: TaggedWeedPointer[];
+ config: Config;
+ dispatch?: Function;
+ visible: boolean;
+ getZ(x: number, y: number): number;
+}
+
+const weedSize = (weed: TaggedWeedPointer) =>
+ weed.body.radius == 0 ? 50 : weed.body.radius;
+
+const getWeedInstances = (
+ weeds: TaggedWeedPointer[],
+ config: Config,
+ getZ: (x: number, y: number) => number,
+) => {
+ const getWorldPosition = getWorldPositionFunc(config);
+ return weeds.map(weed => {
+ const size = weedSize(weed);
+ return {
+ weed,
+ position: getWorldPosition({
+ x: weed.body.x,
+ y: weed.body.y,
+ z: getZ(weed.body.x, weed.body.y),
+ }),
+ weedSize: size,
+ iconSize: size * WEED_IMG_SIZE_FRACTION,
+ };
+ });
+};
+
+const getWeedColorBuckets = (weeds: WeedInstance[]) => {
+ const buckets: Record = {};
+ weeds.forEach(weed => {
+ const color = weed.weed.body.meta.color;
+ const key = color || "";
+ buckets[key] ||= { color, weeds: [] };
+ buckets[key].weeds.push(weed);
+ });
+ return Object.values(buckets);
+};
+
+interface WeedIconUpdateState {
+ lastCameraQuaternion: Quaternion;
+ hasCameraQuaternion: boolean;
+ needsMatrixUpdate: boolean;
+}
+
+const newWeedIconUpdateState = (): WeedIconUpdateState => ({
+ lastCameraQuaternion: new Quaternion(),
+ hasCameraQuaternion: false,
+ needsMatrixUpdate: true,
+});
+
+const useNavigateToWeed = (
+ dispatch: Function | undefined,
+ visible: boolean,
+) => {
+ const navigate = useNavigate();
+ return (weed: TaggedWeedPointer | undefined) => {
+ if (weed?.body.id && dispatch && visible &&
+ !HOVER_OBJECT_MODES.includes(getMode())) {
+ dispatch(setPanelOpen(true));
+ navigate(Path.weeds(weed.body.id));
+ }
+ };
+};
+
+interface WeedIconInstancesProps extends WeedInstancesProps {
+ weedInstances: WeedInstance[];
+}
+
+const WeedIconInstances = (props: WeedIconInstancesProps) => {
+ const { weedInstances, dispatch, visible } = props;
+ const texture = useTexture(ASSETS.other.weed);
+ const navigateToWeed = useNavigateToWeed(dispatch, visible);
+ // eslint-disable-next-line no-null/no-null
+ const instancedRef = React.useRef(null);
+ const updateStateRef =
+ React.useRef(newWeedIconUpdateState());
+ const getUpdateState = () => {
+ const current =
+ updateStateRef.current as Partial | undefined;
+ if (!current?.lastCameraQuaternion) {
+ updateStateRef.current = newWeedIconUpdateState();
+ }
+ return updateStateRef.current;
+ };
+ const tempMatrix = React.useMemo(() => new Matrix4(), []);
+ const tempPosition = React.useMemo(() => new Vector3(), []);
+ const tempScale = React.useMemo(() => new Vector3(), []);
+ const tempQuaternion = React.useMemo(() => new Quaternion(), []);
+
+ React.useEffect(() => {
+ getUpdateState().needsMatrixUpdate = true;
+ }, [weedInstances]);
+
+ useFrame(state => {
+ const mesh = instancedRef.current;
+ if (!mesh?.setMatrixAt || visible === false) { return; }
+ const updateState = getUpdateState();
+ const cameraChanged = !updateState.hasCameraQuaternion
+ || !updateState.lastCameraQuaternion.equals(state.camera.quaternion);
+ if (!updateState.needsMatrixUpdate && !cameraChanged) { return; }
+ tempQuaternion.copy(state.camera.quaternion);
+ weedInstances.forEach((weed, index) => {
+ const [x, y, z] = weed.position;
+ tempPosition.set(x, y, z + weed.iconSize / 2);
+ tempScale.set(weed.iconSize, weed.iconSize, 1);
+ tempMatrix.compose(tempPosition, tempQuaternion, tempScale);
+ mesh.setMatrixAt(index, tempMatrix);
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ updateState.lastCameraQuaternion.copy(state.camera.quaternion);
+ updateState.hasCameraQuaternion = true;
+ updateState.needsMatrixUpdate = false;
+ });
+
+ const onClick = (event: ThreeEvent) => {
+ if (clickWasDragged(event)) { return; }
+ const instanceId = event.instanceId;
+ if (isUndefined(instanceId)) { return; }
+ navigateToWeed(weedInstances[instanceId]?.weed);
+ };
+
+ return
+
+
+ ;
+};
+
+interface WeedRadiusInstancesProps extends WeedInstancesProps {
+ bucket: WeedColorBucket;
+}
+
+const WeedRadiusInstances = (props: WeedRadiusInstancesProps) => {
+ const { bucket, dispatch, visible } = props;
+ const navigateToWeed = useNavigateToWeed(dispatch, visible);
+ // eslint-disable-next-line no-null/no-null
+ const instancedRef = React.useRef(null);
+ const tempMatrix = React.useMemo(() => new Matrix4(), []);
+ const tempPosition = React.useMemo(() => new Vector3(), []);
+ const noRotation = React.useMemo(() => new Quaternion(), []);
+ const tempScale = React.useMemo(() => new Vector3(), []);
+
+ React.useEffect(() => {
+ const mesh = instancedRef.current;
+ if (!mesh?.setMatrixAt) { return; }
+ bucket.weeds.forEach((weed, index) => {
+ const [x, y, z] = weed.position;
+ tempPosition.set(x, y, z + weed.iconSize / 2);
+ tempScale.set(weed.weedSize, weed.weedSize, weed.weedSize);
+ tempMatrix.compose(tempPosition, noRotation, tempScale);
+ mesh.setMatrixAt(index, tempMatrix);
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ }, [bucket.weeds, noRotation, tempMatrix, tempPosition, tempScale]);
+
+ const onClick = (event: ThreeEvent) => {
+ if (clickWasDragged(event)) { return; }
+ const instanceId = event.instanceId;
+ if (isUndefined(instanceId)) { return; }
+ navigateToWeed(bucket.weeds[instanceId]?.weed);
+ };
+
+ return
+
+
+ ;
+};
+
+export const WeedInstances = React.memo((props: WeedInstancesProps) => {
+ const weedInstances = React.useMemo(
+ () => getWeedInstances(props.weeds, props.config, props.getZ),
+ [props.weeds, props.config, props.getZ]);
+ const buckets = React.useMemo(
+ () => getWeedColorBuckets(weedInstances),
+ [weedInstances]);
+ return <>
+
+ {buckets.map(bucket =>
+ )}
+ >;
+});
diff --git a/frontend/three_d_garden/garden/zoom_beacons.tsx b/frontend/three_d_garden/garden/zoom_beacons.tsx
index 0556064838..bbde52f670 100644
--- a/frontend/three_d_garden/garden/zoom_beacons.tsx
+++ b/frontend/three_d_garden/garden/zoom_beacons.tsx
@@ -2,30 +2,39 @@ import { Sphere, Html, Line } from "@react-three/drei";
import React from "react";
import { Config, PositionConfig } from "../config";
import { FOCI, getCameraOffset, setUrlParam } from "../zoom_beacons_constants";
-import { useSpring, animated } from "@react-spring/three";
-import { Group, Mesh, MeshPhongMaterial } from "../components";
+import { animated, useSpring } from "@react-spring/three";
+import { SpringValue, to } from "@react-spring/core";
+import { Group, MeshPhongMaterial, Mesh } from "../components";
import { isDesktop } from "../../screen_size";
import { RenderOrder } from "../constants";
+import {
+ easeInOutCubic, useFocusTransition, useFocusVisibilityClass,
+} from "../focus_transition";
const beaconColor = "#0266b5";
const AnimatedMesh = animated(Mesh);
+const AnimatedGroup = animated(Group);
const AnimatedMeshPhongMaterial = animated(MeshPhongMaterial);
+type Focus = ReturnType[number];
export interface ZoomBeaconsProps {
config: Config;
configPosition: PositionConfig;
activeFocus: string;
setActiveFocus(focus: string): void;
+ loadInOpacity?: SpringValue;
+ loadInScale?: SpringValue | number;
}
interface BeaconPulseProps {
beaconSize: number;
animate: boolean;
+ parentOpacity: SpringValue;
}
const BeaconPulse = (props: BeaconPulseProps) => {
- const { beaconSize, animate } = props;
+ const { beaconSize, animate, parentOpacity } = props;
const { scale, opacity } = useSpring({
from: { scale: 1, opacity: 0.75 },
to: async (next) => {
@@ -43,12 +52,122 @@ const BeaconPulse = (props: BeaconPulseProps) => {
renderOrder={RenderOrder.beacons}>
+ pulse * parent)}
+ depthWrite={false}
transparent={true} />
;
};
+interface BeaconVisualProps {
+ activeFocus: string;
+ beaconSize: number;
+ hovered: boolean;
+ onClick(): void;
+ onPointerEnter(): void;
+ onPointerLeave(): void;
+ config: Config;
+ loadInOpacity?: SpringValue;
+ loadInScale?: SpringValue | number;
+}
+
+const BeaconVisual = (props: BeaconVisualProps) => {
+ const transition = useFocusTransition();
+ const visible = !props.activeFocus;
+ const [rendered, setRendered] = React.useState(visible);
+ const { opacity } = useSpring({
+ opacity: visible ? 1 : 0,
+ immediate: !transition.enabled,
+ config: {
+ duration: transition.duration,
+ easing: easeInOutCubic,
+ },
+ onRest: () => {
+ if (transition.enabled && !visible) {
+ setRendered(false);
+ }
+ },
+ });
+
+ React.useEffect(() => {
+ if (transition.enabled && visible) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setRendered(true);
+ }
+ }, [transition.enabled, visible]);
+
+ if (!rendered && !visible) { return undefined; }
+ const beaconOpacity = props.loadInOpacity
+ ? to([opacity, props.loadInOpacity], (focus, load) => focus * load)
+ : opacity;
+
+ return
+
+
+
+ } />
+ ;
+};
+
+interface BeaconInfoProps {
+ focus: Focus;
+ active: boolean;
+ onExit(): void;
+}
+
+const BeaconInfo = (props: BeaconInfoProps) => {
+ const transition = useFocusVisibilityClass(props.active);
+ if (!transition.mounted) { return undefined; }
+ const className = [
+ "beacon-info",
+ "focus-transition-opacity",
+ transition.className,
+ ].join(" ");
+ return
+ e.stopPropagation()}
+ onPointerMove={e => e.stopPropagation()}>
+
+
{props.focus.label}
+
+ ❌
+
+
+ {props.focus.info.description}
+
+ ;
+};
+
export const ZoomBeacons = (props: ZoomBeaconsProps) => {
const [hoveredFocus, setHoveredFocus] = React.useState("");
const { activeFocus, setActiveFocus } = props;
@@ -60,6 +179,19 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => {
return
{FOCI(props.config, props.configPosition).map(focus => {
const camera = getCameraOffset(focus);
+ const exitFocus = () => {
+ setActiveFocus("");
+ setUrlParam("focus", "");
+ };
+ const enterFocus = () => {
+ if (activeFocus) { return; }
+ setActiveFocus(focus.label);
+ setUrlParam("focus", focus.label);
+ setHoveredFocus("");
+ if (gardenBedDiv) {
+ gardenBedDiv.style.cursor = "";
+ }
+ };
return
{props.config.zoomBeaconDebug &&
@@ -71,19 +203,19 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => {
}
- {
- setActiveFocus(activeFocus ? "" : focus.label);
- setUrlParam("focus", focus.label);
- setHoveredFocus("");
- if (gardenBedDiv) {
- gardenBedDiv.style.cursor = "";
- }
- }}
+ {
+ if (activeFocus) { return; }
setHoveredFocus(focus.label);
if (gardenBedDiv) {
- gardenBedDiv.style.cursor = activeFocus ? "zoom-out" : "zoom-in";
+ gardenBedDiv.style.cursor = "zoom-in";
}
}}
onPointerLeave={() => {
@@ -91,44 +223,11 @@ export const ZoomBeacons = (props: ZoomBeaconsProps) => {
if (gardenBedDiv) {
gardenBedDiv.style.cursor = "";
}
- }}
- receiveShadow={false}
- castShadow={false}
- visible={!activeFocus}
- args={[
- beaconSize
- * (hoveredFocus == focus.label ? 1.5 : 1)
- * ((!activeFocus && props.config.sizePreset == "Genesis XL") ? 1.5 : 1),
- 12,
- 12,
- ]}>
-
-
- {!activeFocus &&
- }
- {activeFocus == focus.label &&
-
- e.stopPropagation()}
- onPointerMove={e => e.stopPropagation()}>
-
-
{focus.label}
-
{
- setActiveFocus("");
- setUrlParam("focus", "");
- }}>
- ❌
-
-
- {focus.info.description}
-
- }
+ }} />
+
;
})}
;
diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx
index c508c6a54b..dabc2eb724 100644
--- a/frontend/three_d_garden/garden_model.tsx
+++ b/frontend/three_d_garden/garden_model.tsx
@@ -13,17 +13,17 @@ import {
OrthographicCamera as ThreeOrthographicCamera,
PerspectiveCamera as ThreePerspectiveCamera,
} from "three";
-import { Bot } from "./bot";
import { AddPlantProps, Bed } from "./bed";
import {
Sky, Solar, Sun, sunPosition, ZoomBeacons,
PlantInstances,
PlantSpreadInstances,
- Point, Grid, Clouds, Ground, Weed,
+ PointInstances, Grid, Clouds, Ground, WeedInstances,
ThreeDGardenPlant,
NorthArrow,
skyColor,
ThreeDPlantLabel,
+ ZoomBeaconsProps,
} from "./garden";
import { Config, PositionConfig } from "./config";
import { useSpring, animated } from "@react-spring/three";
@@ -44,14 +44,85 @@ import { SlotWithTool } from "../resources/interfaces";
import { cameraInit } from "./camera";
import { filterSoilPoints, getSurface } from "./triangles";
import { BigDistance } from "./constants";
-import { getZFunc } from "./triangle_functions";
+import { getZFunc, serializeTriangles } from "./triangle_functions";
import { Visualization } from "./visualization";
import { GroupOrderVisual } from "./group_order_visual";
import { MoistureReadings } from "./garden/moisture_texture";
import { FPSProbe } from "./fps_probe";
import { CameraSelectionUI } from "./camera_selection_ui";
+import {
+ PerfMark, perfMark, perfMeasure, usePerfRenderCount,
+} from "../performance/perf";
+import {
+ botLoadInConfig, FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup,
+ ThreeDLoadProgress, ThreeDLoadProgressOverlay, ThreeDLoadStepId,
+ useThreeDLoadProgress,
+} from "./progressive_load";
+import {
+ FocusTransitionProvider, FocusVisibilityGroup, SmoothCameraControls,
+ useSmoothCamera,
+} from "./focus_transition";
const AnimatedGroup = animated(Group);
+const LazyBot = React.lazy(() =>
+ import("./bot").then(module => ({ default: module.Bot })));
+export const SMOOTH_XL_CAMERA_BED_SCALE = 1.9;
+export const SMOOTH_XL_CAMERA_HEIGHT_SCALE = 1.45;
+
+interface ZoomBeaconsLoadInProps extends ZoomBeaconsProps {
+ reveal?: boolean;
+ onRest?: () => void;
+}
+
+const ZoomBeaconsLoadIn = (props: ZoomBeaconsLoadInProps) => {
+ const { onRest, reveal: revealProp, ...zoomBeaconProps } = props;
+ const reveal = revealProp !== false;
+ const { scale, opacity } = useSpring({
+ from: { scale: 0.35, opacity: 0 },
+ to: {
+ scale: reveal ? 1 : 0.35,
+ opacity: reveal ? 1 : 0,
+ },
+ immediate: !reveal,
+ onRest: () => reveal && onRest?.(),
+ config: {
+ tension: 220,
+ friction: 26,
+ },
+ });
+
+ return
+
+ ;
+};
+
+interface SceneBoundaryProps {
+ markName?: string;
+ loadProgress?: ThreeDLoadProgress;
+ loadStep?: ThreeDLoadStepId;
+ reveal?: boolean;
+ markReadyOnMount?: boolean;
+ children: React.ReactNode;
+}
+
+const SceneBoundary = (props: SceneBoundaryProps) => {
+ const reveal = props.reveal !== false;
+ const markReadyOnMount = props.markReadyOnMount !== false;
+ return
+
+ {props.children}
+
+ {reveal && markReadyOnMount && props.loadStep && props.loadProgress &&
+ }
+ {reveal && props.markName && }
+ ;
+};
export interface GardenModelProps {
config: Config;
@@ -70,11 +141,19 @@ export interface GardenModelProps {
images?: TaggedImage[];
sensorReadings?: TaggedSensorReading[];
sensors?: TaggedSensor[];
+ smoothFocusTransitions?: boolean;
+ plantIconCapacities?: Record;
+ plantInstanceCapacity?: number;
+ onDetailsRevealStart?(): void;
+ onLoadComplete?(): void;
}
// eslint-disable-next-line complexity
export const GardenModel = (props: GardenModelProps) => {
- const { config, addPlantProps, threeDPlants } = props;
+ usePerfRenderCount("GardenModel");
+ const {
+ config, addPlantProps, onDetailsRevealStart, onLoadComplete, threeDPlants,
+ } = props;
const dispatch = addPlantProps?.dispatch;
const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera;
@@ -106,8 +185,13 @@ export const GardenModel = (props: GardenModelProps) => {
};
const isXL = config.sizePreset == "Genesis XL";
+ let modelScale = 1;
+ if (!props.smoothFocusTransitions && isXL) {
+ modelScale = 1.75;
+ }
const { scale } = useSpring({
- scale: isXL ? 1.75 : 1,
+ scale: modelScale,
+ immediate: props.smoothFocusTransitions && !config.animate,
config: {
tension: 300,
friction: 40,
@@ -119,18 +203,81 @@ export const GardenModel = (props: GardenModelProps) => {
const topDownCameraAngle = config.topDown
? baseAngle + heading * Math.PI / 180
: undefined;
- const camera = getCamera(
- config,
- props.configPosition,
- props.activeFocus,
- cameraInit({
- topDown: config.topDown,
- viewpointHeading: config.viewpointHeading,
- bedSize: { x: config.bedLengthOuter, y: config.bedWidthOuter },
- }));
+ const cameraBedScale = props.smoothFocusTransitions && isXL
+ ? SMOOTH_XL_CAMERA_BED_SCALE
+ : 1;
+ const cameraBedSize = React.useMemo(() => ({
+ x: config.bedLengthOuter * cameraBedScale,
+ y: config.bedWidthOuter * cameraBedScale,
+ }), [
+ config.bedLengthOuter,
+ config.bedWidthOuter,
+ cameraBedScale,
+ ]);
+ const defaultCamera = React.useMemo(
+ () => {
+ const nextCamera = cameraInit({
+ topDown: config.topDown,
+ viewpointHeading: config.viewpointHeading,
+ bedSize: cameraBedSize,
+ });
+ return props.smoothFocusTransitions && isXL
+ ? {
+ ...nextCamera,
+ position: [
+ nextCamera.position[0],
+ nextCamera.position[1],
+ nextCamera.position[2] * SMOOTH_XL_CAMERA_HEIGHT_SCALE,
+ ] as typeof nextCamera.position,
+ }
+ : nextCamera;
+ }, [
+ cameraBedSize,
+ config.topDown,
+ config.viewpointHeading,
+ isXL,
+ props.smoothFocusTransitions,
+ ]);
+ const camera = props.activeFocus
+ ? getCamera(config, props.configPosition, props.activeFocus, defaultCamera)
+ : defaultCamera;
const [controlsCamera, setControlsCamera] =
// eslint-disable-next-line no-null/no-null
React.useState(null);
+ const [controls, setControls] =
+ // eslint-disable-next-line no-null/no-null
+ React.useState(null);
+ const loadProgress = useThreeDLoadProgress();
+ const environmentReveal = loadProgress.isStepAllowed("environment");
+ const bedReveal = loadProgress.isStepAllowed("bed");
+ const gridReveal = loadProgress.isStepAllowed("grid");
+ const plantsReveal = loadProgress.isStepAllowed("plants");
+ const weedsReveal = loadProgress.isStepAllowed("weeds");
+ const pointsReveal = loadProgress.isStepAllowed("points");
+ const farmbotReveal = loadProgress.isStepAllowed("farmbot");
+ const detailsReveal = loadProgress.isStepAllowed("details");
+ const detailsRevealNotified = React.useRef(false);
+ const loadCompleteNotified = React.useRef(false);
+ const markLoadStep = loadProgress.markStep;
+ const markDetailsLoaded = React.useCallback(() => {
+ markLoadStep("details");
+ }, [markLoadStep]);
+
+ React.useEffect(() => {
+ perfMark("garden_model_mounted");
+ }, []);
+
+ React.useEffect(() => {
+ if (!detailsReveal || detailsRevealNotified.current) { return; }
+ detailsRevealNotified.current = true;
+ onDetailsRevealStart?.();
+ }, [detailsReveal, onDetailsRevealStart]);
+
+ React.useEffect(() => {
+ if (!loadProgress.complete || loadCompleteNotified.current) { return; }
+ loadCompleteNotified.current = true;
+ onLoadComplete?.();
+ }, [loadProgress.complete, onLoadComplete]);
const showPlants = !addPlantProps
|| !!addPlantProps.getConfigValue(BooleanSetting.show_plants);
@@ -143,13 +290,16 @@ export const GardenModel = (props: GardenModelProps) => {
const showSpread = !!addPlantProps?.getConfigValue(BooleanSetting.show_spread);
const soilPoints = React.useMemo(
- () => filterSoilPoints({ points: props.mapPoints, config }),
+ () => perfMeasure("soilPointFilterMs", () =>
+ filterSoilPoints({ points: props.mapPoints, config })),
[props.mapPoints, config]);
const soilSurface = React.useMemo(() =>
- getSurface(soilPoints), [soilPoints]);
+ perfMeasure("soilSurfaceMs", () => getSurface(soilPoints)), [soilPoints]);
React.useEffect(() => {
- sessionStorage.setItem("soilSurfaceTriangles",
- JSON.stringify(soilSurface.triangles));
+ perfMeasure("soilStorageMs", () => {
+ sessionStorage.setItem("soilSurfaceTriangles",
+ serializeTriangles(soilSurface.triangles));
+ });
}, [soilSurface.triangles]);
const getZ = React.useMemo(
() => getZFunc(soilSurface.triangles, -config.soilHeight),
@@ -159,10 +309,23 @@ export const GardenModel = (props: GardenModelProps) => {
BooleanSetting.show_moisture_interpolation_map);
const showMoistureReadings = !!props.addPlantProps?.getConfigValue(
BooleanSetting.show_sensor_readings);
+ const sceneDetailsLoadIn =
+ config.scene == "Lab" || config.scene == "Greenhouse";
+ const animatedDetailsLoadIn = sceneDetailsLoadIn || config.zoomBeacons;
const topDownAtStart = !!props.addPlantProps?.getConfigValue(
BooleanSetting.top_down_view);
const topDownZoomLevel = 0.25 * 3000 / config.bedLengthOuter;
+ const targetZoom = config.topDown ? topDownZoomLevel : 1;
+ const focusTransitionsEnabled =
+ !!props.smoothFocusTransitions && config.animate;
+ const renderedCamera = useSmoothCamera({
+ camera,
+ zoom: targetZoom,
+ enabled: focusTransitionsEnabled,
+ cameraObject: controlsCamera,
+ controls,
+ });
// eslint-disable-next-line no-null/no-null
const skyRef = React.useRef(null);
@@ -171,6 +334,7 @@ export const GardenModel = (props: GardenModelProps) => {
const plantLabelNodes = React.useMemo(
() => {
+ if (!config.labels && !config.labelsOnHover) { return undefined; }
if (config.labelsOnHover) {
if (hoveredPlant === undefined) { return undefined; }
const plant = threeDPlants[hoveredPlant];
@@ -190,60 +354,40 @@ export const GardenModel = (props: GardenModelProps) => {
},
[threeDPlants, config, getZ, hoveredPlant]);
- const pointNodes = React.useMemo(
- () => props.mapPoints?.map(point =>
- ),
- [props.mapPoints, showPoints, config, getZ, dispatch]);
+ const plantInstancesVisible = props.smoothFocusTransitions
+ ? showPlants
+ : plantsVisible;
+ let cameraScale: number | typeof scale = scale;
+ if (props.smoothFocusTransitions || props.activeFocus) {
+ cameraScale = 1;
+ }
+ const cameraProps = focusTransitionsEnabled
+ ? {}
+ : { position: renderedCamera.position, zoom: renderedCamera.zoom };
+ const orbitControlProps = focusTransitionsEnabled
+ ? {}
+ : { target: renderedCamera.target };
- const weedNodes = React.useMemo(
- () => props.weeds?.map(weed =>
- ),
- [props.weeds, showWeeds, config, getZ, dispatch]);
-
- // eslint-disable-next-line no-null/no-null
- return console.log(e.intersections.map(x => x.object.name))
- : undefined}>
-
- {config.stats && }
- {config.stats && }
- {config.zoomBeacons && }
-
-
-
-
-
-
-
- {controlsCamera &&
+ return
+ {/* eslint-disable-next-line no-null/no-null */}
+ console.log(e.intersections.map(x => x.object.name))
+ : undefined}>
+
+
+
+
+
+ {controlsCamera &&
{
enableZoom={config.zoom}
enablePan={config.pan}
dampingFactor={0.2}
- target={camera.target}
+ {...orbitControlProps}
minZoom={config.lightsDebug ? 0 : 0.05}
maxZoom={10}
minDistance={config.lightsDebug ? 50 : 500}
maxDistance={config.lightsDebug ? BigDistance.devZoom : BigDistance.zoom} />}
-
- {config.viewCube && }
-
-
-
-
-
-
- {showMoistureMap && props.config.moistureDebug &&
- }
- {showFarmbot &&
- }
-
- {plantLabelNodes}
-
-
-
-
-
-
-
- {pointNodes}
-
-
- {weedNodes}
+
+
+
+
+
+
+
+
+
+
+
+
+ loadProgress.markStep("bed")}
+ distance={config.bedHeight + config.bedZOffset}>
+
+
+
+
+ loadProgress.markStep("grid")}>
+
+
+
+
+ loadProgress.markStep("plants")}
+ distance={200}>
+
+ {plantLabelNodes}
+
+
+
+
+
+
+
+
+ loadProgress.markStep("weeds")}
+ distance={200}>
+
+ {(props.weeds?.length || 0) > 0 &&
+ }
+
+
+
+
+ loadProgress.markStep("points")}
+ distance={config.columnLength + 1000}>
+
+ {(props.mapPoints?.length || 0) > 0 &&
+ }
+
+
+
+
+ {showFarmbot &&
+ loadProgress.markStep("farmbot")}
+ config={botLoadInConfig}
+ distance={config.columnLength + 1500}
+ fadeIn={true}
+ preserveDepthWrite={true}>
+
+ }
+
+
+ {config.stats && }
+ {config.stats && }
+ {config.zoomBeacons &&
+ }
+
+ {config.viewCube && }
+
+ {showMoistureMap && props.config.moistureDebug &&
+ }
+
+ {props.addPlantProps?.designer.visualizedSequence &&
+ }
+
+
+
+ {config.cameraSelectionView &&
+ }
+ {detailsReveal && !animatedDetailsLoadIn &&
+ }
+
-
-
-
-
-
- {config.cameraSelectionView &&
- }
- ;
+ ;
};
diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx
index 722dec76cd..7aeb769d2e 100644
--- a/frontend/three_d_garden/index.tsx
+++ b/frontend/three_d_garden/index.tsx
@@ -12,19 +12,22 @@ import {
} from "farmbot";
import { SlotWithTool } from "../resources/interfaces";
import { NavigateFunction } from "react-router";
-import { FilePath, Path } from "../internal_urls";
+import { Path } from "../internal_urls";
import { t } from "../i18next_wrapper";
import { Actions, Content, DeviceSetting } from "../constants";
import { isMobile } from "../screen_size";
import { Help } from "../ui";
import { BooleanSetting } from "../session_keys";
import { LayerToggle } from "../farm_designer/map/legend/layer_toggle";
-import { GetWebAppConfigValue, setWebAppConfigValue } from "../config_storage/actions";
+import {
+ GetWebAppConfigValue, setWebAppConfigValue,
+} from "../config_storage/actions";
import { DesignerState } from "../farm_designer/interfaces";
import { setPanelOpen } from "../farm_designer/panel_header";
import { ThreeDGardenPlant } from "./garden";
import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources";
import { isTopDown } from "./helpers";
+import { perfMark, usePerfRenderCount } from "../performance/perf";
export interface ThreeDGardenProps {
config: Config;
@@ -43,41 +46,35 @@ export interface ThreeDGardenProps {
}
export const ThreeDGarden = (props: ThreeDGardenProps) => {
+ usePerfRenderCount("ThreeDGarden");
+ React.useEffect(() => {
+ perfMark("three_d_garden_mounted");
+ }, []);
return
-
-
-
- {t("Loading interactive 3D FarmBot...")}
-
- }>
-
-
+
;
};
diff --git a/frontend/three_d_garden/progressive_load.tsx b/frontend/three_d_garden/progressive_load.tsx
new file mode 100644
index 0000000000..2890819cbb
--- /dev/null
+++ b/frontend/three_d_garden/progressive_load.tsx
@@ -0,0 +1,328 @@
+import React from "react";
+import { animated, useSpring } from "@react-spring/three";
+import { Html } from "@react-three/drei";
+import { Group } from "./components";
+import { Object3D } from "three";
+import { createFocusMaterialBinding } from "./focus_transition";
+
+const AnimatedGroup = animated(Group);
+
+export const THREE_D_LOAD_STEPS = [
+ { id: "environment", label: "Loading environment" },
+ { id: "bed", label: "Loading bed and soil" },
+ { id: "grid", label: "Loading grid" },
+ { id: "plants", label: "Loading plants" },
+ { id: "weeds", label: "Loading weeds" },
+ { id: "points", label: "Loading points" },
+ { id: "farmbot", label: "Loading FarmBot" },
+ { id: "details", label: "Loading scene details" },
+] as const;
+
+export const THREE_D_LOAD_PROGRESS_FADE_MS = 300;
+
+export type ThreeDLoadStepId = typeof THREE_D_LOAD_STEPS[number]["id"];
+type ReadyStepTimes = Partial
>;
+type StepDependencies = Record;
+
+export const THREE_D_LOAD_STEP_DEPENDENCIES: StepDependencies = {
+ environment: [],
+ bed: ["environment"],
+ grid: ["bed"],
+ plants: ["grid"],
+ weeds: ["grid"],
+ points: ["grid"],
+ farmbot: ["grid"],
+ details: ["farmbot"],
+};
+
+interface ReadyAction {
+ step: ThreeDLoadStepId;
+ elapsed: number;
+}
+
+const readyStepReducer =
+ (state: ReadyStepTimes, action: ReadyAction): ReadyStepTimes =>
+ state[action.step] === undefined
+ ? { ...state, [action.step]: action.elapsed }
+ : state;
+
+const now = () =>
+ typeof performance == "undefined" ? Date.now() : performance.now();
+
+export interface ThreeDLoadProgress {
+ readyStepTimes: ReadyStepTimes;
+ currentStep: typeof THREE_D_LOAD_STEPS[number] | undefined;
+ progress: number;
+ complete: boolean;
+ markStep(step: ThreeDLoadStepId): void;
+ isStepAllowed(step: ThreeDLoadStepId): boolean;
+}
+
+const rounded = (value: number | undefined) => Math.round(value || 0);
+
+export const useThreeDLoadProgress = (): ThreeDLoadProgress => {
+ const startTimeRef = React.useRef(now());
+ const loggedRef = React.useRef(false);
+ const [readyStepTimes, dispatch] =
+ React.useReducer(readyStepReducer, {} as ReadyStepTimes);
+
+ const markStep = React.useCallback((step: ThreeDLoadStepId) => {
+ dispatch({ step, elapsed: now() - startTimeRef.current });
+ }, []);
+
+ const isStepAllowed = React.useCallback((step: ThreeDLoadStepId) => {
+ return THREE_D_LOAD_STEP_DEPENDENCIES[step]
+ .every(stepId => readyStepTimes[stepId] !== undefined);
+ }, [readyStepTimes]);
+
+ const readyStepCount =
+ THREE_D_LOAD_STEPS.filter(step => readyStepTimes[step.id] !== undefined)
+ .length;
+ const currentStep =
+ THREE_D_LOAD_STEPS.find(step => readyStepTimes[step.id] === undefined);
+ const complete = readyStepCount == THREE_D_LOAD_STEPS.length;
+ const progress = readyStepCount / THREE_D_LOAD_STEPS.length * 100;
+
+ React.useEffect(() => {
+ if (!complete || loggedRef.current) { return; }
+ loggedRef.current = true;
+ let totalElapsed = 0;
+ THREE_D_LOAD_STEPS.forEach(step => {
+ const elapsed = readyStepTimes[step.id] || 0;
+ totalElapsed = Math.max(totalElapsed, elapsed);
+ console.log(`[3D load] ${step.label}: ${rounded(elapsed)}ms`);
+ });
+ console.log(`[3D load] Total: ${rounded(totalElapsed)}ms`);
+ }, [complete, readyStepTimes]);
+
+ return React.useMemo(() => ({
+ readyStepTimes,
+ currentStep,
+ progress,
+ complete,
+ markStep,
+ isStepAllowed,
+ }), [
+ complete,
+ currentStep,
+ isStepAllowed,
+ markStep,
+ progress,
+ readyStepTimes,
+ ]);
+};
+
+interface LoadStepReadyProps {
+ step: ThreeDLoadStepId;
+ markStep(step: ThreeDLoadStepId): void;
+}
+
+export const LoadStepReady = (props: LoadStepReadyProps) => {
+ const { markStep, step } = props;
+ React.useEffect(() => {
+ markStep(step);
+ }, [markStep, step]);
+ return undefined;
+};
+
+interface ThreeDLoadProgressOverlayProps {
+ progress: ThreeDLoadProgress;
+ complete?: boolean;
+}
+
+export const ThreeDLoadProgressOverlay =
+ (props: ThreeDLoadProgressOverlayProps) => {
+ const complete = props.complete || props.progress.complete;
+ const [mounted, setMounted] = React.useState(!complete);
+
+ React.useEffect(() => {
+ if (!complete) {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setMounted(true);
+ return;
+ }
+ const timeout = window.setTimeout(() =>
+ setMounted(false), THREE_D_LOAD_PROGRESS_FADE_MS);
+ return () => window.clearTimeout(timeout);
+ }, [complete]);
+
+ if (!mounted) { return undefined; }
+ const className = [
+ "three-d-load-progress",
+ complete ? "three-d-load-progress-complete" : "",
+ ].join(" ");
+ return
+
+
+
{complete ? "Enjoy!" : props.progress.currentStep?.label}
+
+ ;
+ };
+
+const loadInConfig = {
+ tension: 240,
+ friction: 30,
+};
+
+export const botLoadInConfig = {
+ tension: 240,
+ friction: 30,
+ clamp: true,
+};
+
+type LoadInSpringConfig = typeof loadInConfig & {
+ clamp?: boolean;
+};
+
+const canTraverse = (value: unknown): value is Object3D =>
+ !!value
+ && typeof value == "object"
+ && typeof (value as Object3D).traverse == "function";
+
+interface LoadInGroupProps {
+ name: string;
+ children: React.ReactNode;
+ reveal?: boolean;
+ onRest?: () => void;
+ config?: LoadInSpringConfig;
+ fromPosition?: [number, number, number];
+ toPosition?: [number, number, number];
+ fromScale?: number | [number, number, number];
+ toScale?: number | [number, number, number];
+ fadeIn?: boolean;
+ preserveDepthWrite?: boolean;
+}
+
+export const LoadInGroup = (props: LoadInGroupProps) => {
+ const reveal = props.reveal !== false;
+ const fromPosition = props.fromPosition || [0, 0, 0];
+ const toPosition = props.toPosition || [0, 0, 0];
+ const fromScale = props.fromScale || 1;
+ const toScale = props.toScale || 1;
+ const fromOpacity = props.fadeIn ? 0 : 1;
+ const groupRef = React.useRef(undefined);
+ const opacityRef = React.useRef(fromOpacity);
+ const materialBinding = React.useRef | undefined>(undefined);
+ const applyOpacity = React.useCallback((opacity: number) => {
+ opacityRef.current = opacity;
+ if (!props.fadeIn || !canTraverse(groupRef.current)) { return; }
+ materialBinding.current ||= createFocusMaterialBinding(groupRef.current, {
+ preserveDepthWrite: props.preserveDepthWrite,
+ });
+ materialBinding.current.apply(opacity);
+ }, [props.fadeIn, props.preserveDepthWrite]);
+ const setRef = React.useCallback((value: Object3D | null) => {
+ groupRef.current = value || undefined;
+ }, []);
+ const restoreMaterialBinding = React.useCallback(() => {
+ materialBinding.current?.restore();
+ materialBinding.current = undefined;
+ }, []);
+
+ const { position, scale } = useSpring({
+ from: {
+ position: fromPosition,
+ scale: fromScale,
+ opacity: fromOpacity,
+ },
+ to: {
+ position: reveal ? toPosition : fromPosition,
+ scale: reveal ? toScale : fromScale,
+ opacity: reveal ? 1 : fromOpacity,
+ },
+ immediate: !reveal,
+ onChange: result => {
+ const value = result.value as { opacity?: number };
+ applyOpacity(value.opacity ?? 1);
+ },
+ onRest: () => {
+ if (!reveal) { return; }
+ applyOpacity(1);
+ restoreMaterialBinding();
+ props.onRest?.();
+ },
+ config: props.config || loadInConfig,
+ });
+
+ React.useLayoutEffect(() => {
+ if (!props.fadeIn) { return; }
+ applyOpacity(opacityRef.current);
+ }, [applyOpacity, props.fadeIn]);
+
+ React.useEffect(() => restoreMaterialBinding, [restoreMaterialBinding]);
+
+ return
+ {props.children}
+ ;
+};
+
+interface PopInGroupProps {
+ name: string;
+ children: React.ReactNode;
+ reveal?: boolean;
+ onRest?: () => void;
+ distance?: number;
+}
+
+export const PopInGroup = (props: PopInGroupProps) =>
+
+ {props.children}
+ ;
+
+interface FallInGroupProps {
+ name: string;
+ children: React.ReactNode;
+ reveal?: boolean;
+ onRest?: () => void;
+ config?: LoadInSpringConfig;
+ distance?: number;
+ fadeIn?: boolean;
+ preserveDepthWrite?: boolean;
+}
+
+export const FallInGroup = (props: FallInGroupProps) =>
+
+ {props.children}
+ ;
+
+interface GridRevealGroupProps {
+ name: string;
+ children: React.ReactNode;
+ reveal?: boolean;
+ onRest?: () => void;
+}
+
+export const GridRevealGroup = (props: GridRevealGroupProps) =>
+
+ {props.children}
+ ;
diff --git a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx
index 4a834e2e66..c6fcd81cee 100644
--- a/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx
+++ b/frontend/three_d_garden/scenes/__tests__/greenhouse_test.tsx
@@ -18,8 +18,9 @@ describe("", () => {
const { container } = render();
expect(container).toContainHTML("greenhouse-environment");
expect(container).not.toContainHTML("people");
- expect(container).toContainHTML("starter-tray-1");
- expect(container).toContainHTML("starter-tray-2");
+ expect(container).toContainHTML("starter-trays");
+ expect(container).toContainHTML("starter-tray-bases");
+ expect(container).toContainHTML("starter-tray-seedlings");
expect(container).toContainHTML("left-greenhouse-wall");
expect(container).toContainHTML("right-greenhouse-wall");
expect(container).toContainHTML("potted-plant");
@@ -51,4 +52,14 @@ describe("", () => {
expect(container).toContainHTML("greenhouse-environment");
expect(container).not.toContainHTML("people");
});
+
+ it("animates scene details in", () => {
+ const p = fakeProps();
+ const onDetailsLoadInRest = jest.fn();
+ p.config.scene = "Greenhouse";
+ const { container } = render();
+ expect(container).toContainHTML("greenhouse-scene-details-load-in");
+ expect(onDetailsLoadInRest).toHaveBeenCalled();
+ });
});
diff --git a/frontend/three_d_garden/scenes/__tests__/lab_test.tsx b/frontend/three_d_garden/scenes/__tests__/lab_test.tsx
index d64521afe1..31d7c6345f 100644
--- a/frontend/three_d_garden/scenes/__tests__/lab_test.tsx
+++ b/frontend/three_d_garden/scenes/__tests__/lab_test.tsx
@@ -40,4 +40,14 @@ describe("", () => {
expect(container).toContainHTML("shelf");
expect(container).toContainHTML("people");
});
+
+ it("animates scene details in", () => {
+ const p = fakeProps();
+ const onDetailsLoadInRest = jest.fn();
+ p.config.scene = "Lab";
+ const { container } = render();
+ expect(container).toContainHTML("lab-scene-details-load-in");
+ expect(onDetailsLoadInRest).toHaveBeenCalled();
+ });
});
diff --git a/frontend/three_d_garden/scenes/greenhouse.tsx b/frontend/three_d_garden/scenes/greenhouse.tsx
index 16676b2a44..f823f86634 100644
--- a/frontend/three_d_garden/scenes/greenhouse.tsx
+++ b/frontend/three_d_garden/scenes/greenhouse.tsx
@@ -1,15 +1,20 @@
import React from "react";
-import { Box, useTexture } from "@react-three/drei";
+import { Box } from "@react-three/drei";
import { DoubleSide, RepeatWrapping } from "three";
import { ASSETS } from "../constants";
import { threeSpace } from "../helpers";
import { Config } from "../config";
import { Group, MeshPhongMaterial } from "../components";
-import { StarterTray, PottedPlant, GreenhouseWall, People } from "./props";
+import { StarterTrays, PottedPlant, GreenhouseWall, People } from "./props";
+import { PopInGroup } from "../progressive_load";
+import { FocusVisibilityGroup } from "../focus_transition";
+import { useTextureVariant } from "../texture_variants";
export interface GreenhouseProps {
config: Config;
activeFocus: string;
+ reveal?: boolean;
+ onDetailsLoadInRest?(): void;
}
const wallLength = 10000;
@@ -22,84 +27,83 @@ export const Greenhouse = (props: GreenhouseProps) => {
const { config } = props;
const groundZ = -config.bedZOffset - config.bedHeight;
- const shelfWoodTextureBase = useTexture(ASSETS.textures.wood + "?=shelf");
- const shelfWoodTexture = React.useMemo(() => {
- const texture = shelfWoodTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.3, 0.3);
- return texture;
- }, [shelfWoodTextureBase]);
+ const shelfWoodTexture = useTextureVariant(ASSETS.textures.wood, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.3, 0.3],
+ });
return
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
-
+
-
-
-
+
+
+
+
;
};
diff --git a/frontend/three_d_garden/scenes/lab.tsx b/frontend/three_d_garden/scenes/lab.tsx
index 399816b49a..ed4ae7449c 100644
--- a/frontend/three_d_garden/scenes/lab.tsx
+++ b/frontend/three_d_garden/scenes/lab.tsx
@@ -1,15 +1,19 @@
import React from "react";
-import { Box, Extrude, useTexture } from "@react-three/drei";
+import { Box, Extrude } from "@react-three/drei";
import { DoubleSide, Shape, RepeatWrapping } from "three";
import { ASSETS } from "../constants";
import { threeSpace } from "../helpers";
import { Config } from "../config";
import { Desk, People } from "./props";
import { Group, MeshPhongMaterial } from "../components";
+import { PopInGroup } from "../progressive_load";
+import { useTextureVariant } from "../texture_variants";
export interface LabProps {
config: Config;
activeFocus: string;
+ reveal?: boolean;
+ onDetailsLoadInRest?(): void;
}
const wallLength = 10000;
@@ -37,65 +41,68 @@ export const Lab = (props: LabProps) => {
const { config } = props;
const groundZ = -config.bedZOffset - config.bedHeight;
- const shelfWoodTextureBase = useTexture(ASSETS.textures.wood + "?=shelf");
- const shelfWoodTexture = React.useMemo(() => {
- const texture = shelfWoodTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.3, 0.3);
- return texture;
- }, [shelfWoodTextureBase]);
+ const shelfWoodTexture = useTextureVariant(ASSETS.textures.wood, {
+ wrapS: RepeatWrapping,
+ wrapT: RepeatWrapping,
+ repeat: [0.3, 0.3],
+ });
return
-
-
+
-
-
- {[wallHeight / 2, wallHeight / 3].map((shelfHeight, index) => (
-
-
-
- ))}
-
-
-
+
+
+ {[wallHeight / 2, wallHeight / 3].map((shelfHeight, index) => (
+
+
+
+ ))}
+
+
+
+
;
};
diff --git a/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx b/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx
index 12b115fca6..a437cf37f0 100644
--- a/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx
+++ b/frontend/three_d_garden/scenes/props/__tests__/starter_tray_test.tsx
@@ -1,17 +1,60 @@
import React from "react";
import { render } from "@testing-library/react";
-import { Desk, DeskProps } from "../desk";
-import { clone } from "lodash";
-import { INITIAL } from "../../../config";
-
-describe("", () => {
- const fakeProps = (): DeskProps => ({
- config: clone(INITIAL),
- activeFocus: "",
+import { StarterTray, StarterTrays } from "../starter_tray";
+import { InstancedMesh } from "three";
+
+const mockMesh = () => ({
+ setMatrixAt: jest.fn(),
+ instanceMatrix: { needsUpdate: false },
+}) as unknown as InstancedMesh;
+
+describe("", () => {
+ it("renders a single starter tray", () => {
+ const { container } = render();
+
+ expect(container).toContainHTML("starter-tray");
+ expect(container).toContainHTML("starter-trays");
+ expect(container.querySelectorAll("instancedmesh").length).toEqual(2);
});
+});
+
+describe("", () => {
+ it("renders instanced starter trays and seedlings", () => {
+ const { container } = render();
+
+ expect(container).toContainHTML("starter-tray-bases");
+ expect(container).toContainHTML("starter-tray-seedlings");
+ expect(container.querySelectorAll("instancedmesh").length).toEqual(2);
+ expect(container.querySelectorAll(".billboard").length).toEqual(0);
+ expect(container.querySelectorAll(".image").length).toEqual(0);
+ });
+
+ it("updates tray and seedling instance matrices", () => {
+ const trayMesh = mockMesh();
+ const seedlingMesh = mockMesh();
+ const useRef = React.useRef;
+ const useEffectSpy = jest.spyOn(React, "useEffect")
+ .mockImplementationOnce(effect => {
+ effect();
+ });
+ const useRefSpy = jest.spyOn(React, "useRef")
+ .mockImplementationOnce(() => ({ current: trayMesh }))
+ .mockImplementationOnce(() => ({ current: seedlingMesh }))
+ .mockImplementation(useRef);
+
+ render();
- it("renders", () => {
- const { container } = render();
- expect(container).toContainHTML("desk");
+ expect(trayMesh.setMatrixAt).toHaveBeenCalledTimes(2);
+ expect(seedlingMesh.setMatrixAt).toHaveBeenCalledTimes(140);
+ expect(trayMesh.instanceMatrix.needsUpdate).toBeTruthy();
+ expect(seedlingMesh.instanceMatrix.needsUpdate).toBeTruthy();
+ useRefSpy.mockRestore();
+ useEffectSpy.mockRestore();
});
});
diff --git a/frontend/three_d_garden/scenes/props/desk.tsx b/frontend/three_d_garden/scenes/props/desk.tsx
index 9916c95aec..669aed284f 100644
--- a/frontend/three_d_garden/scenes/props/desk.tsx
+++ b/frontend/three_d_garden/scenes/props/desk.tsx
@@ -1,10 +1,12 @@
import React from "react";
import { RepeatWrapping } from "three";
-import { Box, useTexture } from "@react-three/drei";
+import { Box } from "@react-three/drei";
import { ASSETS } from "../../constants";
import { threeSpace } from "../../helpers";
import { Config } from "../../config";
import { Group, MeshPhongMaterial } from "../../components";
+import { FocusVisibilityGroup } from "../../focus_transition";
+import { useTextureVariant } from "../../texture_variants";
export interface DeskProps {
config: Config;
@@ -21,22 +23,16 @@ const deskWoodDarkness = "#666";
export const Desk = (props: DeskProps) => {
const { config } = props;
const zGround = -config.bedZOffset - config.bedHeight;
- const deskWoodTextureBase = useTexture(ASSETS.textures.wood + "?=desk");
- const deskWoodTexture = React.useMemo(() => {
- const texture = deskWoodTextureBase.clone();
- texture.wrapS = RepeatWrapping;
- texture.wrapT = RepeatWrapping;
- texture.repeat.set(0.3, 0.3);
- return texture;
- }, [deskWoodTextureBase]);
- const screenTextureBase = useTexture(ASSETS.textures.screen + "?=screen");
- const screenTexture = React.useMemo(() => {
- const texture = screenTextureBase.clone();
- texture.rotation = Math.PI / 2;
- texture.wrapT = RepeatWrapping;
- return texture;
- }, [screenTextureBase]);
- return {
- ;
+ ;
};
diff --git a/frontend/three_d_garden/scenes/props/people.tsx b/frontend/three_d_garden/scenes/props/people.tsx
index eb4fd22cde..329994d237 100644
--- a/frontend/three_d_garden/scenes/props/people.tsx
+++ b/frontend/three_d_garden/scenes/props/people.tsx
@@ -5,6 +5,7 @@ import { Config } from "../../config";
import { threeSpace } from "../../helpers";
import { Vector3, DoubleSide } from "three";
import { ASSETS, RenderOrder } from "../../constants";
+import { FocusVisibilityGroup } from "../../focus_transition";
export interface PeopleProps {
config: Config;
@@ -15,7 +16,7 @@ export interface PeopleProps {
export const People = (props: PeopleProps) => {
const { people, config } = props;
const groundZ = -config.bedZOffset - config.bedHeight;
- return
{people.map((person, i) => {
const offset = new Vector3(...person.offset);
@@ -28,7 +29,7 @@ export const People = (props: PeopleProps) => {
;
})}
- ;
+ ;
};
interface DataRecord {
diff --git a/frontend/three_d_garden/scenes/props/starter_tray.tsx b/frontend/three_d_garden/scenes/props/starter_tray.tsx
index 34c62fe266..2b88f5f2ce 100644
--- a/frontend/three_d_garden/scenes/props/starter_tray.tsx
+++ b/frontend/three_d_garden/scenes/props/starter_tray.tsx
@@ -1,8 +1,22 @@
import React from "react";
-import { Box, Billboard, Image } from "@react-three/drei";
-import { DoubleSide } from "three";
+import { useTexture } from "@react-three/drei";
+import { useFrame } from "@react-three/fiber";
+import {
+ DoubleSide,
+ InstancedMesh as InstancedMeshType,
+ Matrix4,
+ Quaternion,
+ Vector3,
+} from "three";
import { ASSETS, RenderOrder } from "../../constants";
-import { Group, MeshPhongMaterial } from "../../components";
+import {
+ BoxGeometry,
+ Group,
+ InstancedMesh,
+ MeshBasicMaterial,
+ MeshPhongMaterial,
+ PlaneGeometry,
+} from "../../components";
import { range } from "lodash";
const length = 250;
@@ -10,31 +24,95 @@ const width = 700;
const height = 50;
const cellSize = 50;
const seedlingSize = 40;
+const trayCells = range(5).flatMap(row =>
+ range(14).map(col => ({
+ x: -width / 2 + cellSize / 2 + col * cellSize,
+ y: -length / 2 + cellSize / 2 + row * cellSize,
+ })));
-export const StarterTray = () => {
+export interface StarterTraysProps {
+ positions: [number, number, number][];
+}
- return
- {
+ // eslint-disable-next-line no-null/no-null
+ const trayRef = React.useRef(null);
+ // eslint-disable-next-line no-null/no-null
+ const seedlingRef = React.useRef(null);
+ const plantTexture = useTexture(ASSETS.other.plant);
+ const matrix = React.useMemo(() => new Matrix4(), []);
+ const position = React.useMemo(() => new Vector3(), []);
+ const scale = React.useMemo(() => new Vector3(), []);
+ const trayQuaternion = React.useMemo(() => new Quaternion(), []);
+ const seedlingQuaternion = React.useMemo(() => new Quaternion(), []);
+
+ React.useEffect(() => {
+ const mesh = trayRef.current;
+ if (!mesh) { return; }
+ props.positions.forEach((trayPosition, index) => {
+ position.set(
+ trayPosition[0],
+ trayPosition[1],
+ trayPosition[2] + height / 2,
+ );
+ scale.set(1, 1, 1);
+ matrix.compose(position, trayQuaternion, scale);
+ mesh.setMatrixAt(index, matrix);
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ }, [matrix, position, props.positions, scale, trayQuaternion]);
+
+ useFrame(state => {
+ const mesh = seedlingRef.current;
+ if (!mesh) { return; }
+ seedlingQuaternion.copy(state.camera.quaternion);
+ scale.set(seedlingSize, seedlingSize, seedlingSize);
+ props.positions.forEach((trayPosition, trayIndex) => {
+ trayCells.forEach((cell, cellIndex) => {
+ const index = trayIndex * trayCells.length + cellIndex;
+ position.set(
+ trayPosition[0] + cell.x,
+ trayPosition[1] + cell.y,
+ trayPosition[2] + height + seedlingSize / 2,
+ );
+ matrix.compose(position, seedlingQuaternion, scale);
+ mesh.setMatrixAt(index, matrix);
+ });
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ });
+
+ return
+
+ frustumCulled={false}>
+
-
- {range(5).map(row =>
- range(14).map(col => {
- const x = -width / 2 + cellSize / 2 + col * cellSize;
- const y = -length / 2 + cellSize / 2 + row * cellSize;
- return
-
- ;
- }),
- )}
+
+
+
+
+
+ ;
+};
+
+export const StarterTray = () => {
+
+ return
+
;
};
diff --git a/frontend/three_d_garden/texture_variants.ts b/frontend/three_d_garden/texture_variants.ts
new file mode 100644
index 0000000000..38f174fe46
--- /dev/null
+++ b/frontend/three_d_garden/texture_variants.ts
@@ -0,0 +1,51 @@
+import { useTexture } from "@react-three/drei";
+import { Texture, type Wrapping } from "three";
+
+export interface TextureVariantOptions {
+ wrapS?: Wrapping;
+ wrapT?: Wrapping;
+ repeat?: [number, number];
+ offset?: [number, number];
+ rotation?: number;
+}
+
+const textureVariantCache = new WeakMap>();
+
+export const textureVariantKey = (options: TextureVariantOptions): string =>
+ [
+ options.wrapS ?? "",
+ options.wrapT ?? "",
+ options.repeat?.join(",") ?? "",
+ options.offset?.join(",") ?? "",
+ options.rotation ?? "",
+ ].join("|");
+
+export const getTextureVariant = (
+ baseTexture: Texture,
+ options: TextureVariantOptions,
+): Texture => {
+ const key = textureVariantKey(options);
+ const variants = textureVariantCache.get(baseTexture) || new Map();
+ const existing = variants.get(key);
+ if (existing) { return existing; }
+
+ const texture = baseTexture.clone();
+ if (options.wrapS != undefined) { texture.wrapS = options.wrapS; }
+ if (options.wrapT != undefined) { texture.wrapT = options.wrapT; }
+ if (options.repeat) { texture.repeat.set(...options.repeat); }
+ if (options.offset) { texture.offset.set(...options.offset); }
+ if (options.rotation != undefined) { texture.rotation = options.rotation; }
+ texture.needsUpdate = true;
+
+ variants.set(key, texture);
+ textureVariantCache.set(baseTexture, variants);
+ return texture;
+};
+
+export const useTextureVariant = (
+ url: string,
+ options: TextureVariantOptions,
+): Texture => {
+ const baseTexture = useTexture(url);
+ return getTextureVariant(baseTexture, options);
+};
diff --git a/frontend/three_d_garden/triangle_functions.ts b/frontend/three_d_garden/triangle_functions.ts
index 1ce0864d72..6bef57faea 100644
--- a/frontend/three_d_garden/triangle_functions.ts
+++ b/frontend/three_d_garden/triangle_functions.ts
@@ -1,7 +1,13 @@
+import { perfEnabled, perfSample } from "../performance/perf";
+
export interface TriangleData {
a: [number, number, number];
b: [number, number, number];
c: [number, number, number];
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
x1: number;
y1: number;
x2: number;
@@ -11,6 +17,49 @@ export interface TriangleData {
det: number;
}
+interface TriangleIndex {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ columns: number;
+ rows: number;
+ cellWidth: number;
+ cellHeight: number;
+ buckets: TriangleData[][];
+}
+
+const MAX_BUCKETS_PER_AXIS = 64;
+const MIN_INDEXED_TRIANGLES = 4;
+
+const triangleData = (
+ a: [number, number, number],
+ b: [number, number, number],
+ c: [number, number, number],
+) => {
+ const [x1, y1] = [a[0], a[1]];
+ const [x2, y2] = [b[0], b[1]];
+ const [x3, y3] = [c[0], c[1]];
+ const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3);
+ if (Math.abs(det) < 1e-10) { return undefined; }
+ return {
+ a,
+ b,
+ c,
+ minX: Math.min(x1, x2, x3),
+ maxX: Math.max(x1, x2, x3),
+ minY: Math.min(y1, y2, y3),
+ maxY: Math.max(y1, y2, y3),
+ x1,
+ y1,
+ x2,
+ y2,
+ x3,
+ y3,
+ det,
+ };
+};
+
export const precomputeTriangles = (
vertices: [number, number, number][],
faces: number[],
@@ -21,33 +70,155 @@ export const precomputeTriangles = (
const a = vertices[faces[i]];
const b = vertices[faces[i + 1]];
const c = vertices[faces[i + 2]];
+ const triangle = triangleData(a, b, c);
+ triangle && triangles.push(triangle);
+ }
- const [x1, y1] = [a[0], a[1]];
- const [x2, y2] = [b[0], b[1]];
- const [x3, y3] = [c[0], c[1]];
+ return triangles;
+};
+
+export const serializeTriangles = (triangles: TriangleData[]) =>
+ JSON.stringify(triangles.map(({ a, b, c }) => [
+ a[0], a[1], a[2],
+ b[0], b[1], b[2],
+ c[0], c[1], c[2],
+ ]));
+
+const pointFromArray = (
+ values: unknown[],
+ offset: number,
+): [number, number, number] | undefined => {
+ const point = values.slice(offset, offset + 3);
+ if (point.length != 3 || !point.every(Number.isFinite)) {
+ return undefined;
+ }
+ return point as [number, number, number];
+};
- const det = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3);
- if (Math.abs(det) < 1e-10) { continue; }
- triangles.push({ a, b, c, x1, y1, x2, y2, x3, y3, det });
+const parseStoredTriangle = (value: unknown): TriangleData | undefined => {
+ if (Array.isArray(value)) {
+ const a = pointFromArray(value, 0);
+ const b = pointFromArray(value, 3);
+ const c = pointFromArray(value, 6);
+ return a && b && c ? triangleData(a, b, c) : undefined;
}
+ if (!value || typeof value != "object") { return undefined; }
+ const { a, b, c } = value as Partial;
+ return Array.isArray(a) && Array.isArray(b) && Array.isArray(c)
+ ? triangleData(a, b, c)
+ : undefined;
+};
- return triangles;
+export const parseStoredTriangles = (storedTriangles: string | null) => {
+ try {
+ const parsed = JSON.parse(storedTriangles || "[]") as unknown;
+ if (!Array.isArray(parsed)) { return []; }
+ return parsed
+ .map(parseStoredTriangle)
+ .filter((triangle): triangle is TriangleData => !!triangle);
+ } catch {
+ return [];
+ }
+};
+
+const clampCell = (value: number, min: number, size: number, count: number) =>
+ Math.min(count - 1, Math.max(0, Math.floor((value - min) / size)));
+
+const bucketIndex = (index: TriangleIndex, column: number, row: number) =>
+ row * index.columns + column;
+
+const buildTriangleIndex = (triangles: TriangleData[]) => {
+ if (triangles.length < MIN_INDEXED_TRIANGLES) { return undefined; }
+ let minX = Infinity;
+ let maxX = -Infinity;
+ let minY = Infinity;
+ let maxY = -Infinity;
+ for (const triangle of triangles) {
+ minX = Math.min(minX, triangle.minX);
+ maxX = Math.max(maxX, triangle.maxX);
+ minY = Math.min(minY, triangle.minY);
+ maxY = Math.max(maxY, triangle.maxY);
+ }
+ if (minX == maxX || minY == maxY) { return undefined; }
+ const bucketCount = Math.min(
+ MAX_BUCKETS_PER_AXIS,
+ Math.max(1, Math.ceil(Math.sqrt(triangles.length))),
+ );
+ const index: TriangleIndex = {
+ minX,
+ maxX,
+ minY,
+ maxY,
+ columns: bucketCount,
+ rows: bucketCount,
+ cellWidth: (maxX - minX) / bucketCount,
+ cellHeight: (maxY - minY) / bucketCount,
+ buckets: Array.from({ length: bucketCount * bucketCount }, () => []),
+ };
+ for (const triangle of triangles) {
+ const minColumn = clampCell(triangle.minX, minX, index.cellWidth,
+ index.columns);
+ const maxColumn = clampCell(triangle.maxX, minX, index.cellWidth,
+ index.columns);
+ const minRow = clampCell(triangle.minY, minY, index.cellHeight,
+ index.rows);
+ const maxRow = clampCell(triangle.maxY, minY, index.cellHeight,
+ index.rows);
+ for (let row = minRow; row <= maxRow; row++) {
+ for (let column = minColumn; column <= maxColumn; column++) {
+ index.buckets[bucketIndex(index, column, row)].push(triangle);
+ }
+ }
+ }
+ return index;
+};
+
+const indexedTrianglesForPoint = (
+ index: TriangleIndex | undefined,
+ triangles: TriangleData[],
+ x: number,
+ y: number,
+) => {
+ if (!index) { return triangles; }
+ if (
+ x < index.minX || x > index.maxX ||
+ y < index.minY || y > index.maxY
+ ) {
+ return [];
+ }
+ const column = clampCell(x, index.minX, index.cellWidth, index.columns);
+ const row = clampCell(y, index.minY, index.cellHeight, index.rows);
+ return index.buckets[bucketIndex(index, column, row)];
};
export const getZFunc = (
triangles: TriangleData[],
fallback: number,
-) =>
- (x: number, y: number) => {
- for (const t of triangles) {
+) => {
+ const cache: Record = {};
+ const measure = perfEnabled();
+ const indexStartedAt = measure ? performance.now() : 0;
+ const index = buildTriangleIndex(triangles);
+ measure && perfSample("getZIndexMs", performance.now() - indexStartedAt);
+ return (x: number, y: number) => {
+ const key = `${x},${y}`;
+ const cached = cache[key];
+ if (cached !== undefined) { return cached; }
+ const startedAt = measure ? performance.now() : 0;
+ for (const t of indexedTrianglesForPoint(index, triangles, x, y)) {
const { a, b, c, x1, y1, x2, y2, x3, y3, det } = t;
const l1 = ((y2 - y3) * (x - x3) + (x3 - x2) * (y - y3)) / det;
const l2 = ((y3 - y1) * (x - x3) + (x1 - x3) * (y - y3)) / det;
const l3 = 1 - l1 - l2;
if (l1 >= 0 && l2 >= 0 && l3 >= 0) {
- return l1 * a[2] + l2 * b[2] + l3 * c[2];
+ cache[key] = l1 * a[2] + l2 * b[2] + l3 * c[2];
+ measure && perfSample("getZMs", performance.now() - startedAt);
+ return cache[key];
}
}
- return fallback;
+ cache[key] = fallback;
+ measure && perfSample("getZMs", performance.now() - startedAt);
+ return cache[key];
};
+};
diff --git a/frontend/three_d_garden/zoom_beacons_constants.tsx b/frontend/three_d_garden/zoom_beacons_constants.tsx
index 7054ba254c..90d5bc942d 100644
--- a/frontend/three_d_garden/zoom_beacons_constants.tsx
+++ b/frontend/three_d_garden/zoom_beacons_constants.tsx
@@ -12,6 +12,8 @@ export interface Camera {
target: VectorXyz;
}
+const TOP_DOWN_FOCUS_AZIMUTH_OFFSET = 25;
+
interface Focus {
label: string;
info: {
@@ -70,7 +72,7 @@ export const FOCI = (config: Config, configPosition: PositionConfig): Focus[] =>
narrow: {
position: [
0,
- -1000,
+ -1000 - TOP_DOWN_FOCUS_AZIMUTH_OFFSET,
config.sizePreset == "Genesis XL" ? 16000 : 8000,
],
target: [
@@ -82,7 +84,7 @@ export const FOCI = (config: Config, configPosition: PositionConfig): Focus[] =>
wide: {
position: [
0,
- 0,
+ -TOP_DOWN_FOCUS_AZIMUTH_OFFSET,
config.sizePreset == "Genesis XL" ? 10000 : 5000,
],
target: [
diff --git a/frontend/tools/__tests__/edit_tool_slot_test.tsx b/frontend/tools/__tests__/edit_tool_slot_test.tsx
index 481ba81199..6bd24e4e3e 100644
--- a/frontend/tools/__tests__/edit_tool_slot_test.tsx
+++ b/frontend/tools/__tests__/edit_tool_slot_test.tsx
@@ -10,7 +10,6 @@ import {
} from "../../__test_support__/resource_index_builder";
import * as crud from "../../api/crud";
import { mapStateToPropsEdit } from "../state_to_props";
-import { SlotEditRows } from "../tool_slot_edit_components";
import { fakeToolTransformProps } from "../../__test_support__/fake_tool_info";
import { EditToolSlotProps } from "../interfaces";
import * as toolGraphics from "../../farm_designer/map/layers/tool_slots/tool_graphics";
@@ -157,11 +156,19 @@ describe("", () => {
it("finds tool", () => {
const p = fakeProps();
const toolSlot = fakeToolSlot();
- p.findToolSlot = () => toolSlot;
+ toolSlot.body.tool_id = 1;
+
const tool = fakeTool();
+ tool.body.id = 1;
+
+ p.findToolSlot = () => toolSlot;
p.findTool = () => tool;
- const wrapper = createWrapper(p);
- expect(wrapper.root.findAllByType(SlotEditRows)[0]?.props.tool).toEqual(tool);
+ p.tools = [tool];
+
+ const { getByText } = render();
+
+ expect(getByText("Tool or Seed Container")).toBeTruthy();
+ expect(getByText(tool.body.name as string)).toBeTruthy();
});
});
diff --git a/frontend/tools/__tests__/index_test.tsx b/frontend/tools/__tests__/index_test.tsx
index 8e23b00802..5927561f26 100644
--- a/frontend/tools/__tests__/index_test.tsx
+++ b/frontend/tools/__tests__/index_test.tsx
@@ -2,7 +2,6 @@ const mockDevice = { readPin: jest.fn((_) => Promise.resolve()) };
import React from "react";
import { act, fireEvent, render } from "@testing-library/react";
-import type { ReactTestInstance } from "react-test-renderer";
import {
RawTools as Tools,
ToolSlotInventoryItem,
@@ -17,7 +16,9 @@ import { Content, Actions } from "../../constants";
import * as crud from "../../api/crud";
import { ToolSelection } from "../tool_slot_edit_components";
import { fakeToolTransformProps } from "../../__test_support__/fake_tool_info";
-import { ToolsProps, ToolSlotInventoryItemProps } from "../interfaces";
+import {
+ ToolsProps, ToolSelectionProps, ToolSlotInventoryItemProps,
+} from "../interfaces";
import * as mapActions from "../../farm_designer/map/actions";
import * as mapUtil from "../../farm_designer/map/util";
import { Mode } from "../../farm_designer/map/interfaces";
@@ -26,11 +27,8 @@ import { DEFAULT_CRITERIA } from "../../point_groups/criteria/interfaces";
import { Path } from "../../internal_urls";
import * as deviceModule from "../../device";
import { NavigationContext } from "../../routes_helpers";
-import { FBSelect } from "../../ui/new_fb_select";
-import {
- createRenderer,
- unmountRenderer,
-} from "../../__test_support__/test_renderer";
+import * as toolSlotEditComponents from "../tool_slot_edit_components";
+import { findElement } from "../../__test_support__/react_element_search";
const originalPathname = location.pathname;
@@ -41,44 +39,9 @@ const renderWithContext = (element: React.ReactElement) =>
,
);
-const findNodeByType = (
- node: React.ReactNode,
- matcher: (type: React.ElementType) => boolean,
-): ReactTestInstance | undefined => {
- if (!node || typeof node === "string" || typeof node === "number") {
- return undefined;
- }
- if (Array.isArray(node)) {
- for (const child of node as React.ReactNode[]) {
- const found = findNodeByType(child, matcher);
- if (found) {
- return found;
- }
- }
- return undefined;
- }
- if (React.isValidElement(node)) {
- const element = node as React.ReactElement<{ children?: React.ReactNode }>;
- if (matcher(element.type as React.ElementType)) {
- return createRenderer(node as React.ReactElement).root;
- }
- let found: ReactTestInstance | undefined;
- React.Children.forEach(element.props.children, (child: React.ReactNode) => {
- if (found) {
- return;
- }
- found = findNodeByType(child, matcher);
- });
- if (found) {
- return found;
- }
- }
- return undefined;
-};
-
describe("", () => {
afterEach(() => {
- history.replaceState(undefined, "", Path.mock(originalPathname));
+ location.pathname = originalPathname;
jest.useRealTimers();
});
@@ -256,7 +219,7 @@ describe("", () => {
const ref = React.createRef();
renderWithContext();
const mountedTool = ref.current?.MountedToolInfo();
- const toolSelection = findNodeByType(
+ const toolSelection = findElement(
mountedTool, type => type === ToolSelection);
act(() => {
toolSelection?.props.onChange({ tool_id: 123 });
@@ -358,7 +321,7 @@ describe("", () => {
beforeEach(() => {
jest.clearAllMocks();
- history.replaceState(undefined, "", Path.mock(originalPathname));
+ location.pathname = originalPathname;
jest.useRealTimers();
jest.spyOn(crud, "edit").mockImplementation(jest.fn());
jest.spyOn(crud, "save").mockImplementation(jest.fn());
@@ -385,19 +348,17 @@ describe("", () => {
it("changes tool", () => {
const p = fakeProps();
- let wrapper: ReturnType;
- act(() => {
- wrapper = createRenderer();
- });
- act(() => {
- wrapper.root.findByType(FBSelect).props.onChange({ value: "1" });
- });
+ const toolSelectionSpy = jest.spyOn(toolSlotEditComponents, "ToolSelection")
+ .mockImplementation((props: ToolSelectionProps) =>
+ ;
diff --git a/frontend/weeds/__tests__/weed_inventory_item_test.tsx b/frontend/weeds/__tests__/weed_inventory_item_test.tsx
index 4186645da5..9214b88789 100644
--- a/frontend/weeds/__tests__/weed_inventory_item_test.tsx
+++ b/frontend/weeds/__tests__/weed_inventory_item_test.tsx
@@ -11,6 +11,8 @@ import * as crud from "../../api/crud";
import { Path } from "../../internal_urls";
beforeEach(() => {
+ window.localStorage.clear();
+ delete window.__fbPerf;
jest.clearAllMocks();
location.pathname = Path.mock(Path.weeds());
jest.spyOn(mapActions, "mapPointClickAction")
@@ -22,6 +24,11 @@ beforeEach(() => {
jest.spyOn(crud, "save").mockImplementation(jest.fn());
});
+afterEach(() => {
+ window.localStorage.clear();
+ delete window.__fbPerf;
+});
+
describe("
/>", () => {
const fakeProps = (): WeedInventoryItemProps => ({
@@ -46,6 +53,7 @@ describe("
/>", () => {
});
it("navigates to weed", () => {
+ window.localStorage.setItem("FB_PERF_BENCHMARK", "true");
const p = fakeProps();
p.tpp.body.id = 1;
const { container } = render(
);
@@ -56,6 +64,8 @@ describe("
/>", () => {
type: Actions.TOGGLE_HOVERED_POINT,
payload: p.tpp.uuid,
});
+ expect(window.__fbPerf?.marks.weed_inventory_item_click.length)
+ .toEqual(1);
});
it("navigates to weed without id", () => {
diff --git a/frontend/weeds/weed_inventory_item.tsx b/frontend/weeds/weed_inventory_item.tsx
index d3da001ae9..b80062300a 100644
--- a/frontend/weeds/weed_inventory_item.tsx
+++ b/frontend/weeds/weed_inventory_item.tsx
@@ -10,6 +10,7 @@ import { mapPointClickAction, selectPoint } from "../farm_designer/map/actions";
import { round } from "lodash";
import { edit, save, destroy } from "../api/crud";
import { FilePath, Path } from "../internal_urls";
+import { perfMark } from "../performance/perf";
export interface WeedInventoryItemProps {
tpp: TaggedWeedPointer;
@@ -19,7 +20,7 @@ export interface WeedInventoryItemProps {
maxSize?: number;
}
-export const WeedInventoryItem = (props: WeedInventoryItemProps) => {
+export const WeedInventoryItem = React.memo((props: WeedInventoryItemProps) => {
const navigate = useNavigate();
const weed = props.tpp.body;
const { tpp, dispatch } = props;
@@ -37,6 +38,7 @@ export const WeedInventoryItem = (props: WeedInventoryItemProps) => {
};
const click = () => {
+ perfMark("weed_inventory_item_click");
if (getMode() == Mode.boxSelect) {
mapPointClickAction(navigate, dispatch, tpp.uuid)();
toggle("leave");
@@ -88,4 +90,6 @@ export const WeedInventoryItem = (props: WeedInventoryItemProps) => {
{`(${round(weed.x)}, ${round(weed.y)}) r${round(weed.radius)}`}
;
-};
+});
+
+WeedInventoryItem.displayName = "WeedInventoryItem";
diff --git a/frontend/wizard/__tests__/checks_test.tsx b/frontend/wizard/__tests__/checks_test.tsx
index f1a851b6a8..2fcfe5c5f0 100644
--- a/frontend/wizard/__tests__/checks_test.tsx
+++ b/frontend/wizard/__tests__/checks_test.tsx
@@ -13,7 +13,6 @@ const mockDevice = {
import React from "react";
import { fireEvent, render } from "@testing-library/react";
-import type { ReactTestInstance } from "react-test-renderer";
import { bot } from "../../__test_support__/fake_state/bot";
import {
buildResourceIndex, fakeDevice,
@@ -90,7 +89,6 @@ import * as messageCards from "../../messages/cards";
import * as bootSequenceSelector from "../../settings/fbos_settings/boot_sequence_selector";
import * as messageActions from "../../messages/actions";
import * as deviceActions from "../../devices/actions";
-import { DropdownConfig } from "../../photos/camera_calibration/config";
import { PLACEHOLDER_FARMBOT } from "../../photos/images/image_flipper";
import { createRenderer } from "../../__test_support__/test_renderer";
@@ -114,22 +112,6 @@ let emergencyUnlockSpy: jest.SpyInstance;
let findHomeSpy: jest.SpyInstance;
let findAxisLengthSpy: jest.SpyInstance;
-const findNodeByType = (
- node: React.ReactNode,
- matcher: (type: unknown) => boolean,
-): ReactTestInstance | undefined => {
- if (!node || !React.isValidElement(node)) {
- return undefined;
- }
- try {
- return createRenderer(node).root
- .findAll(element => matcher(element.type))
- .filter(item => matcher(item.type))[0];
- } catch {
- return undefined;
- }
-};
-
beforeEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
@@ -951,10 +933,14 @@ describe("", () => {
it("changes origin", () => {
const p = fakeProps();
- const origin = findNodeByType(,
- type => type === DropdownConfig);
- origin?.props.onChange(
- "CAMERA_CALIBRATION_image_bot_origin_location", 2);
+ const fbSelectProps: ui.FBSelectProps[] = [];
+ fbSelectSpy = jest.spyOn(ui, "FBSelect")
+ .mockImplementation(((props: ui.FBSelectProps) => {
+ fbSelectProps.push(props);
+ return ;
+ }) as never);
+ render();
+ fbSelectProps[0].onChange({ label: "Top left", value: 2 });
expect(initSave).toHaveBeenCalledWith("FarmwareEnv", {
key: "CAMERA_CALIBRATION_image_bot_origin_location",
value: "\"TOP_LEFT\"",
diff --git a/lib/tasks/coverage.rake b/lib/tasks/coverage.rake
index 099892a257..4ceb6a8441 100644
--- a/lib/tasks/coverage.rake
+++ b/lib/tasks/coverage.rake
@@ -3,15 +3,15 @@ require "find"
COVERAGE_FILE_PATH = "./coverage_fe/index.html"
JSON_COVERAGE_FILE_PATH = "./coverage_fe/coverage-final.json"
LCOV_FILE_PATH = "./coverage_fe/lcov.info"
-THRESHOLD = 0.001
+THRESHOLD = 0.05
REPO_URL = "https://api.github.com/repos/Farmbot/Farmbot-Web-App"
LATEST_COV_URL = "https://coveralls.io/github/FarmBot/Farmbot-Web-App.json"
COV_API_BUILDS_PER_PAGE = 5
COV_BUILDS_TO_FETCH = 20
-PULL_REQUEST = ENV.fetch("CIRCLE_PULL_REQUEST", "/0")
-CURRENT_BRANCH = ENV.fetch("CIRCLE_BRANCH", "staging") # "staging" or "pull/11"
+PULL_REQUEST = ENV.fetch("GITHUB_PULL_REQUEST", "/0")
+CURRENT_BRANCH = ENV.fetch("GITHUB_REF_NAME", "staging") # "staging" or "pull/11"
BASE_BRANCHES = ["main", "staging"]
-CURRENT_COMMIT = ENV.fetch("CIRCLE_SHA1", "")
+CURRENT_COMMIT = ENV.fetch("GITHUB_SHA", "")
CSS_SELECTOR = ".fraction"
FRACTION_DELIM = "/"
REMOTE_COVERAGE_OVERRIDE = ENV.fetch('REMOTE_COVERAGE_OVERRIDE', '0').to_f
diff --git a/package.json b/package.json
index 88ed025076..7d68d6835c 100644
--- a/package.json
+++ b/package.json
@@ -38,27 +38,27 @@
"mqtt": "mqtt/dist/mqtt.esm.js"
},
"dependencies": {
- "@blueprintjs/core": "6.12.0",
- "@blueprintjs/select": "6.1.9",
+ "@blueprintjs/core": "6.15.0",
+ "@blueprintjs/select": "6.2.1",
"@monaco-editor/react": "4.7.0",
- "@react-spring/three": "10.0.3",
+ "@react-spring/three": "10.1.0",
"@react-three/drei": "10.7.7",
- "@react-three/fiber": "9.6.0",
+ "@react-three/fiber": "9.6.1",
"@rollbar/react": "1.0.0",
- "@types/bun": "1.3.13",
+ "@types/bun": "1.3.14",
"@types/lodash": "4.17.24",
"@types/markdown-it": "14.1.2",
"@types/markdown-it-emoji": "3.0.1",
"@types/promise-timeout": "1.3.3",
- "@types/react": "19.2.14",
+ "@types/react": "19.2.15",
"@types/react-color": "3.0.13",
"@types/react-dom": "19.2.3",
"@types/react-test-renderer": "19.1.0",
"@types/redux-immutable-state-invariant": "2.1.4",
- "@types/three": "0.184.0",
+ "@types/three": "0.184.1",
"@types/ws": "8.18.1",
"@xterm/xterm": "6.0.0",
- "axios": "1.15.2",
+ "axios": "1.16.1",
"bowser": "2.14.1",
"browser-speech": "1.1.1",
"delaunator": "5.1.0",
@@ -66,9 +66,9 @@
"farmbot": "15.9.3",
"fengari": "0.1.5",
"fengari-web": "0.1.4",
- "i18next": "26.0.6",
+ "i18next": "26.2.0",
"lodash": "4.18.1",
- "markdown-it": "14.1.1",
+ "markdown-it": "14.2.0",
"markdown-it-emoji": "3.0.0",
"moment": "2.30.1",
"monaco-editor": "0.55.1",
@@ -77,11 +77,11 @@
"promise-timeout": "1.3.0",
"punycode": "2.3.1",
"querystring-es3": "0.2.1",
- "react": "19.2.5",
+ "react": "19.2.6",
"react-color": "2.19.3",
- "react-dom": "19.2.5",
- "react-redux": "9.2.0",
- "react-router": "7.14.2",
+ "react-dom": "19.2.6",
+ "react-redux": "9.3.0",
+ "react-router": "7.15.1",
"redux": "5.0.1",
"redux-immutable-state-invariant": "2.1.0",
"redux-thunk": "3.1.0",
@@ -104,36 +104,36 @@
"@types/jest": "30.0.0",
"@types/readable-stream": "4.0.23",
"@types/suncalc": "1.9.2",
- "@typescript-eslint/eslint-plugin": "8.59.0",
- "@typescript-eslint/parser": "8.59.0",
- "eslint": "10.2.1",
+ "@typescript-eslint/eslint-plugin": "8.60.0",
+ "@typescript-eslint/parser": "8.60.0",
+ "eslint": "10.4.0",
"eslint-plugin-eslint-comments": "3.2.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jest": "29.15.2",
"eslint-plugin-no-null": "1.0.2",
- "eslint-plugin-promise": "7.2.1",
+ "eslint-plugin-promise": "7.3.0",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"happy-dom": "20.9.0",
- "jest": "30.3.0",
+ "jest": "30.4.2",
"jest-canvas-mock": "2.5.2",
- "jest-cli": "30.3.0",
- "jest-environment-jsdom": "30.3.0",
- "jest-junit": "16.0.0",
+ "jest-cli": "30.4.2",
+ "jest-environment-jsdom": "30.4.1",
+ "jest-junit": "17.0.0",
"jest-skipped-reporter": "0.0.5",
"jshint": "2.13.6",
"madge": "8.0.0",
"path-browserify": "1.0.1",
- "playwright": "1.59.1",
- "postcss": "8.5.10",
+ "playwright": "1.60.0",
+ "postcss": "8.5.15",
"postcss-scss": "4.0.9",
"raf": "3.4.1",
- "react-test-renderer": "19.2.5",
- "sass": "1.99.0",
+ "react-test-renderer": "19.2.6",
+ "sass": "1.100.0",
"sass-lint": "1.13.1",
- "stylelint": "17.8.0",
+ "stylelint": "17.12.0",
"stylelint-config-standard-scss": "17.0.0",
- "ts-jest": "29.4.9",
+ "ts-jest": "29.4.11",
"tslint": "6.1.3"
}
}
diff --git a/public/400.html b/public/400.html
index 39f442cfa4..fc1e2a5d79 100644
--- a/public/400.html
+++ b/public/400.html
@@ -1,31 +1,27 @@
-
-
+
-
- The server cannot process the request due to a client error (400 Bad Request)
-
-
-
-
-
-
-
+ Bad Request (400)
+
+
+
+
-
-
-
-
- The server cannot process the request due to a client error.
- Please check the request and try again. If you're the application owner check the logs for more information.
-
-
-
-
+
+
+
+
+
+ 400
+
+
+ The server cannot process the request due to a client error.
+ Please check the request and try again.
+
+
diff --git a/public/404.html b/public/404.html
index e6419c7b81..be2eb73aad 100755
--- a/public/404.html
+++ b/public/404.html
@@ -4,83 +4,13 @@
-
+
-
+
404
diff --git a/public/405.html b/public/405.html
new file mode 100644
index 0000000000..ffd0e72144
--- /dev/null
+++ b/public/405.html
@@ -0,0 +1,26 @@
+
+
+
+
+ Method Not Allowed (405)
+
+
+
+
+
+
+
+
+
+
+
+ 405
+
+
+ The request method is not allowed.
+ Try going back.
+
+
+
+
+
diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html
index fa1aa430f5..2c14a06030 100644
--- a/public/406-unsupported-browser.html
+++ b/public/406-unsupported-browser.html
@@ -1,20 +1,26 @@
-
+
- Your browser is not supported (406)
-
-
+ Your browser is not supported (406)
+
+
+
-
-
-
Your browser is not supported.
-
Please upgrade your browser to continue.
+
+
+
+
+
+ 406
+
+
+ Your browser is not supported.
+ Please upgrade your browser to continue.
+
-
diff --git a/public/406.html b/public/406.html
new file mode 100644
index 0000000000..26ef88afe7
--- /dev/null
+++ b/public/406.html
@@ -0,0 +1,26 @@
+
+
+
+
+
Not Acceptable (406)
+
+
+
+
+
+
+
+
+
+
+
+ 406
+
+
+ The requested format is not acceptable.
+ Try going back.
+
+
+
+
+
diff --git a/public/422.html b/public/422.html
index 7b5a47a458..bef7004b40 100755
--- a/public/422.html
+++ b/public/422.html
@@ -1,58 +1,26 @@
-
-
The change you wanted was rejected (422)
-
+
+
The change you wanted was rejected (422)
+
+
+
-
-
-
The change you wanted was rejected.
-
Maybe you tried to change something you didn't have access to.
-
-
If you are the application owner check the logs for more information.
+
+
+
+
+
+ 422
+
+
+ The change you wanted was rejected.
+ Maybe you tried to change something you didn't have access to.
+
+
+
diff --git a/public/429.html b/public/429.html
new file mode 100644
index 0000000000..75f9bae357
--- /dev/null
+++ b/public/429.html
@@ -0,0 +1,26 @@
+
+
+
+
+
Too Many Requests (429)
+
+
+
+
+
+
+
+
+
+
+
+ 429
+
+
+ Too many requests were sent.
+ Please wait a moment and try again.
+
+
+
+
+
diff --git a/public/500.html b/public/500.html
index 90318ed0a9..8a17910654 100755
--- a/public/500.html
+++ b/public/500.html
@@ -4,91 +4,20 @@
-
+
-
-
+
- Sorry, something went wrong.
+ 500
+ Sorry, something went wrong.
You can try going back, but if the problem persists, please let us know.
-
diff --git a/public/501.html b/public/501.html
new file mode 100644
index 0000000000..e49121b005
--- /dev/null
+++ b/public/501.html
@@ -0,0 +1,26 @@
+
+
+
+
+
Not Implemented (501)
+
+
+
+
+
+
+
+
+
+
+
+ 501
+
+
+ The requested feature is not implemented.
+ Try going back.
+
+
+
+
+
diff --git a/public/app-resources/languages/zh.json b/public/app-resources/languages/zh.json
index 649226cf01..25f99b128f 100644
--- a/public/app-resources/languages/zh.json
+++ b/public/app-resources/languages/zh.json
@@ -1052,7 +1052,6 @@
"live chat": "在线聊天",
"Load (%)": "负载 (%)",
"Loading": "加载中",
- "Loading interactive 3D FarmBot...": "正在加载交互式3D FarmBot...",
"Loading slots": "加载槽位",
"Loading...": "加载中...",
"Local IP": "本地 IP",
diff --git a/public/error-pages.css b/public/error-pages.css
new file mode 100644
index 0000000000..0039a8ca34
--- /dev/null
+++ b/public/error-pages.css
@@ -0,0 +1,49 @@
+body {
+ min-width: 100vw;
+ min-height: 100vh;
+ background: linear-gradient(-135deg, #089460, #159ACC);
+ background-size: 100% 100%;
+ overflow: hidden;
+ text-shadow: 0px 1px 1px #555;
+}
+
+img {
+ display: block;
+ margin: 0 auto 2rem;
+ max-width: 20rem;
+}
+
+h1,
+h4,
+a {
+ font-family: "Cabin", "Cabin Fallback", sans-serif;
+ text-align: center;
+ color: #fff;
+}
+
+h1 {
+ font-weight: 600;
+ font-size: 4rem;
+ margin-bottom: 1rem;
+}
+
+h4,
+a {
+ font-size: 1.2rem;
+ font-weight: 400;
+}
+
+a {
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+a:active,
+a:visited {
+ color: #fff;
+}
+
+.content {
+ padding: 0 2rem;
+ margin: 4rem auto 0;
+}
diff --git a/public/promo_loading_image.avif b/public/promo_loading_image.avif
deleted file mode 100644
index f6c7cb8f2a..0000000000
Binary files a/public/promo_loading_image.avif and /dev/null differ
diff --git a/public/promo_loading_image_full_bot.avif b/public/promo_loading_image_full_bot.avif
deleted file mode 100644
index 49813140ac..0000000000
Binary files a/public/promo_loading_image_full_bot.avif and /dev/null differ
diff --git a/scripts/ci/combine-render-images b/scripts/ci/combine-render-images
new file mode 100755
index 0000000000..be6820d6f9
--- /dev/null
+++ b/scripts/ci/combine-render-images
@@ -0,0 +1,151 @@
+#!/usr/bin/env bun
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+
+const requiredEnv = name => {
+ const value = process.env[name];
+ if (!value) {
+ console.error(`${name} is required`);
+ process.exit(1);
+ }
+ return value;
+};
+
+const output = `/tmp/${requiredEnv('COMBINED_RENDER_IMAGE')}.png`;
+const screenshotName = requiredEnv('SCREENSHOT_NAME');
+const fpsSamplesName = requiredEnv('FPS_SAMPLES_NAME');
+const sceneMetricsName = requiredEnv('SCENE_METRICS_NAME');
+const feCoverageName = requiredEnv('FE_COVERAGE_NAME');
+const imagePath = name => `/tmp/${name}.png`;
+const imageData = filePath => {
+ const data = fs.readFileSync(filePath).toString('base64');
+ return `data:image/png;base64,${data}`;
+};
+
+const metricRow = name => [
+ [`${name} screenshot`, imagePath(`${screenshotName}_${name}`)],
+ [`${name} fps`, imagePath(`${fpsSamplesName}_${name}`)],
+ [`${name} metrics`, imagePath(`${sceneMetricsName}_${name}`)],
+];
+const screenshotCell = name => [
+ name.replace(/_/g, ' '),
+ imagePath(`${screenshotName}_${name}`),
+];
+const rows = [
+ metricRow('promo_xl'),
+ metricRow('app'),
+ metricRow('stress'),
+ metricRow('water'),
+ [
+ screenshotCell('login'),
+ screenshotCell('os'),
+ screenshotCell('demo'),
+ ],
+ [
+ screenshotCell('password_reset'),
+ screenshotCell('404'),
+ ['fe coverage', imagePath(feCoverageName)],
+ ],
+];
+
+const escapeHtml = value => String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+const cell = ([title, filePath]) => {
+ if (!fs.existsSync(filePath)) {
+ return `
+
+ ${escapeHtml(title)}
+ Missing ${escapeHtml(path.basename(filePath))}
+ `;
+ }
+ return `
+
+ ${escapeHtml(title)}
+
+ `;
+};
+const rowHtml = rows
+ .map(row => `
${row.map(cell).join('')}`)
+ .join('');
+const html = `
+
+
+
+
+
+
+ ${rowHtml}
+
+`;
+
+async function main() {
+ fs.mkdirSync(path.dirname(output), { recursive: true });
+ const browser = await chromium.launch({
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ ],
+ });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1800, height: 1200 } });
+ await page.setContent(html, { waitUntil: 'load' });
+ await page.screenshot({ path: output, fullPage: true });
+ console.log(`COMBINED_RENDER_IMAGE=${output}`);
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch(error => {
+ console.error('Failed to combine render images:', error.message || error);
+ process.exitCode = 1;
+});
diff --git a/scripts/ci/create-compare-link b/scripts/ci/create-compare-link
new file mode 100755
index 0000000000..499d8e0364
--- /dev/null
+++ b/scripts/ci/create-compare-link
@@ -0,0 +1,19 @@
+#!/usr/bin/env python3
+import json
+import os
+import sys
+import urllib.request
+
+deploys_url = "https://api.github.com/repos/Farmbot/Farmbot-Web-App/deployments"
+compare_url_base = "https://github.com/Farmbot/Farmbot-Web-App/compare/"
+github_sha = os.environ["GITHUB_SHA"]
+
+try:
+ with urllib.request.urlopen(deploys_url, timeout=30) as response:
+ deployments = json.load(response)
+except Exception as error:
+ print(f"Failed to read deployments: {error}", file=sys.stderr)
+ deployments = []
+
+last_sha = deployments[0].get("sha", "") if deployments else ""
+print(f"{compare_url_base}{last_sha}...{github_sha}")
diff --git a/scripts/ci/export-render-env b/scripts/ci/export-render-env
new file mode 100755
index 0000000000..d466e8fa0e
--- /dev/null
+++ b/scripts/ci/export-render-env
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+render_env="scripts/ci/render-env"
+github_env="${GITHUB_ENV:?GITHUB_ENV is required}"
+
+set -a
+# shellcheck disable=SC1091
+. "$render_env"
+set +a
+
+while IFS= read -r line; do
+ case "$line" in
+ ': "${'*':='*'}"')
+ name="${line#*: \"\$\{}"
+ name="${name%%:=*}"
+ echo "$name=${!name}" >> "$github_env"
+ ;;
+ esac
+done < "$render_env"
diff --git a/scripts/ci/git-publish-render-artifacts b/scripts/ci/git-publish-render-artifacts
new file mode 100755
index 0000000000..a62b5d7ebd
--- /dev/null
+++ b/scripts/ci/git-publish-render-artifacts
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+: "${ARTIFACT_BRANCH:?ARTIFACT_BRANCH is required}"
+: "${COMBINED_RENDER_IMAGE:?COMBINED_RENDER_IMAGE is required}"
+: "${CHOSEN_FPS:?CHOSEN_FPS is required}"
+: "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}"
+: "${FE_COVERAGE_NAME:?FE_COVERAGE_NAME is required}"
+: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
+: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}"
+: "${GITHUB_ENV:?GITHUB_ENV is required}"
+
+worktree=$(mktemp -d)
+git config user.name "github-actions[bot]"
+git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+if git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then
+ git fetch origin "$ARTIFACT_BRANCH"
+ git worktree add "$worktree" "origin/$ARTIFACT_BRANCH"
+else
+ git worktree add --detach "$worktree"
+ git -C "$worktree" switch --orphan "$ARTIFACT_BRANCH"
+fi
+
+bun scripts/ci/combine-render-images
+
+mkdir -p "$worktree/build/latest" "$worktree/build/${GITHUB_RUN_ID}"
+for image in "$COMBINED_RENDER_IMAGE"; do
+ cp "/tmp/$image.png" "$worktree/build/latest/$image.png"
+ cp "/tmp/$image.png" "$worktree/build/${GITHUB_RUN_ID}/$image.png"
+done
+cp "/tmp/$CHOSEN_FPS.csv" "$worktree/build/latest/$CHOSEN_FPS.csv"
+cp "/tmp/$CHOSEN_FPS.csv" "$worktree/build/${GITHUB_RUN_ID}/$CHOSEN_FPS.csv"
+for metric in "/tmp/$SCENE_METRICS_NAME"*.csv "/tmp/$FE_COVERAGE_NAME.csv"; do
+ [ -f "$metric" ] && cp "$metric" "$worktree/$(basename "$metric")"
+done
+git -C "$worktree" add build
+for artifact in "$worktree/$SCENE_METRICS_NAME"*.csv "$worktree/$FE_COVERAGE_NAME.csv"; do
+ [ -f "$artifact" ] && git -C "$worktree" add "$(basename "$artifact")"
+done
+git -C "$worktree" commit -m "Update CI image artifacts for ${GITHUB_RUN_ID}" || true
+git -C "$worktree" push origin "HEAD:$ARTIFACT_BRANCH"
+
+raw_base="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${ARTIFACT_BRANCH}/build/${GITHUB_RUN_ID}"
+render_summary_url="${raw_base}/${COMBINED_RENDER_IMAGE}.png"
+echo "RENDER_SUMMARY_URL=${render_summary_url}"
+echo "RENDER_SUMMARY_URL=${render_summary_url}" >> "$GITHUB_ENV"
diff --git a/scripts/ci/git-restore-artifact-branch-file b/scripts/ci/git-restore-artifact-branch-file
new file mode 100755
index 0000000000..13de65686b
--- /dev/null
+++ b/scripts/ci/git-restore-artifact-branch-file
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+source_pattern="${1:?source path is required}"
+: "${ARTIFACT_BRANCH:?ARTIFACT_BRANCH is required}"
+
+if ! git ls-remote --exit-code --heads origin "$ARTIFACT_BRANCH" >/dev/null; then
+ exit 0
+fi
+
+git fetch origin "$ARTIFACT_BRANCH"
+
+git ls-tree -r --name-only "origin/$ARTIFACT_BRANCH" | while IFS= read -r source_path; do
+ case "$source_path" in
+ $source_pattern)
+ destination_path="/tmp/$source_path"
+ mkdir -p "$(dirname "$destination_path")"
+ git show "origin/$ARTIFACT_BRANCH:$source_path" > "$destination_path"
+ ;;
+ esac
+done
diff --git a/scripts/ci/percent-change b/scripts/ci/percent-change
new file mode 100755
index 0000000000..55647cdb52
--- /dev/null
+++ b/scripts/ci/percent-change
@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+import argparse
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--new", required=True, type=float)
+parser.add_argument("--old", required=True, type=float)
+args = parser.parse_args()
+
+print("n/a" if args.old == 0 else f"{((args.new - args.old) / args.old) * 100:.2f}")
diff --git a/scripts/ci/previous-fps-value b/scripts/ci/previous-fps-value
new file mode 100755
index 0000000000..21a6f88026
--- /dev/null
+++ b/scripts/ci/previous-fps-value
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+import csv
+import os
+import sys
+
+def required_env(name):
+ value = os.environ.get(name)
+ if value is None:
+ print(f"{name} is required", file=sys.stderr)
+ raise SystemExit(1)
+ return value
+
+chosen_metrics = required_env("CHOSEN_METRICS")
+fallback = required_env("FALLBACK_FPS_VALUE")
+path = f"/tmp/{chosen_metrics}.csv"
+
+try:
+ with open(path, newline="") as f:
+ rows = list(csv.DictReader(f))
+except FileNotFoundError:
+ rows = []
+
+values = []
+for row in rows[:-1]:
+ try:
+ values.append(float(row["FPS"]))
+ except (KeyError, TypeError, ValueError):
+ pass
+
+print(f"{values[-1]:.2f}" if values else fallback)
diff --git a/scripts/ci/render-env b/scripts/ci/render-env
new file mode 100644
index 0000000000..e915a0078f
--- /dev/null
+++ b/scripts/ci/render-env
@@ -0,0 +1,13 @@
+: "${CHOSEN_NAME:=promo_xl}"
+: "${SCREENSHOT_NAME:=screenshot}"
+: "${CHOSEN_IMAGE:=${SCREENSHOT_NAME}_${CHOSEN_NAME}}"
+: "${FPS_SAMPLES_NAME:=fps_samples}"
+: "${CHOSEN_FPS:=${FPS_SAMPLES_NAME}_${CHOSEN_NAME}}"
+: "${SCENE_METRICS_NAME:=scene_metrics}"
+: "${CHOSEN_METRICS:=${SCENE_METRICS_NAME}_${CHOSEN_NAME}}"
+: "${COMBINED_RENDER_IMAGE:=render_summary}"
+: "${FE_COVERAGE_NAME:=fe_coverage}"
+: "${SCENE_METRICS_HEADER:=epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes}"
+: "${ARTIFACT_BRANCH:=ci-artifacts}"
+: "${FALLBACK_FPS_VALUE:=100}"
+: "${FALLBACK_FE_COVERAGE_VALUE:=99.4}"
diff --git a/scripts/ci/render-url-records b/scripts/ci/render-url-records
new file mode 100755
index 0000000000..0b5f4d9992
--- /dev/null
+++ b/scripts/ci/render-url-records
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+import json
+import sys
+
+with open(sys.argv[1], encoding="utf-8") as f:
+ entries = json.load(f)
+
+for entry in entries:
+ print("\034".join([
+ entry["name"],
+ entry["mode"],
+ entry["url"],
+ entry.get("click", ""),
+ entry.get("state", ""),
+ ]))
diff --git a/scripts/ci/render-urls.json b/scripts/ci/render-urls.json
new file mode 100644
index 0000000000..aa0924e64b
--- /dev/null
+++ b/scripts/ci/render-urls.json
@@ -0,0 +1,49 @@
+[
+ {
+ "name": "promo_xl",
+ "mode": "fps",
+ "url": "http://localhost:3000/promo?sizePreset=Genesis+XL&promoSpread=true"
+ },
+ {
+ "name": "login",
+ "mode": "screenshot",
+ "url": "http://localhost:3000/"
+ },
+ {
+ "name": "os",
+ "mode": "screenshot",
+ "url": "http://localhost:3000/os"
+ },
+ {
+ "name": "demo",
+ "mode": "screenshot",
+ "url": "http://localhost:3000/demo"
+ },
+ {
+ "name": "password_reset",
+ "mode": "screenshot",
+ "url": "http://localhost:3000/password_reset/abc"
+ },
+ {
+ "name": "404",
+ "mode": "screenshot",
+ "url": "http://localhost:3000/404"
+ },
+ {
+ "name": "app",
+ "mode": "fps",
+ "url": "http://localhost:3000/try_farmbot?productLine=genesis_1.8"
+ },
+ {
+ "name": "stress",
+ "mode": "fps",
+ "url": "http://localhost:3000/try_farmbot?productLine=genesis_xl_1.8_stress_1000"
+ },
+ {
+ "name": "water",
+ "mode": "fps",
+ "url": "http://localhost:3000/app/designer/sequences/Water_all",
+ "click": "Run",
+ "state": "app"
+ }
+]
diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render
new file mode 100755
index 0000000000..1270244039
--- /dev/null
+++ b/scripts/ci/run-playwright-render
@@ -0,0 +1,99 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ -f scripts/ci/render-env ]; then
+ set -a
+ # shellcheck disable=SC1091
+ . scripts/ci/render-env
+ set +a
+fi
+
+if [ -z "${GITHUB_ACTIONS:-}" ]; then
+ export OPEN_WINDOW="${OPEN_WINDOW:-1}"
+fi
+
+attempts="${ATTEMPTS:-2}"
+: "${CHOSEN_NAME:?CHOSEN_NAME is required}"
+: "${SCREENSHOT_NAME:?SCREENSHOT_NAME is required}"
+: "${FPS_SAMPLES_NAME:?FPS_SAMPLES_NAME is required}"
+: "${SCENE_METRICS_NAME:?SCENE_METRICS_NAME is required}"
+: "${SCENE_METRICS_HEADER:?SCENE_METRICS_HEADER is required}"
+GITHUB_ENV="${GITHUB_ENV:-/tmp/render-github-env}"
+URLS_JSON="${URLS_JSON:-scripts/ci/render-urls.json}"
+chosen_seen=false
+
+while IFS=$'\034' read -r -u 3 name mode url click state; do
+ screenshot_path="/tmp/${SCREENSHOT_NAME}_${name}.png"
+ fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv"
+ scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv"
+
+ for _ in $(seq 1 "$attempts"); do
+ fps_args=(
+ --name "$name"
+ --url "$url"
+ --screenshot-path "$screenshot_path"
+ --fps-samples-path "$fps_samples_path"
+ )
+ [ -n "$click" ] && fps_args+=(--click "$click")
+ [ -n "$state" ] && fps_args+=(--state "$state")
+
+ echo -e "\n\n\n"
+ if [ "$mode" = "screenshot" ]; then
+ echo "Attempting screenshot via ${url}"
+ else
+ echo "Attempting FPS check via ${url}"
+ fi
+ if ! sudo docker compose exec -T web curl -I "${url}" >/dev/null; then
+ continue
+ fi
+
+ if [ "$mode" = "screenshot" ]; then
+ if ! screenshot_output=$(bun scripts/fps.js --screenshot-only "${fps_args[@]}"); then
+ echo "${screenshot_output}"
+ continue
+ fi
+ echo "${screenshot_output}"
+ continue 2
+ fi
+
+ if ! fps_output=$(bun scripts/fps.js "${fps_args[@]}"); then
+ echo "${fps_output}"
+ continue
+ fi
+ echo "${fps_output}"
+
+ fps_value=$(echo "${fps_output}" | awk -F= '/^FPS_VALUE=/{print $2; exit}')
+ scene_metrics=$(echo "${fps_output}" | awk -F= '/^SCENE_METRICS=/{print $2; exit}')
+ if [ ! -f "$scene_metrics_path" ]; then
+ printf '%s\n' "$SCENE_METRICS_HEADER" > "$scene_metrics_path"
+ fi
+ printf '%s\n' "$scene_metrics" >> "$scene_metrics_path"
+ echo "SCENE_METRICS=${scene_metrics_path}"
+
+ if [ "$name" = "${CHOSEN_NAME}" ]; then
+ echo "FPS_VALUE=${fps_value}" >> "$GITHUB_ENV"
+ echo "SCENE_METRICS=${scene_metrics}" >> "$GITHUB_ENV"
+ chosen_seen=true
+ fi
+
+ continue 2
+ done
+
+ echo "FPS check failed for ${url}"
+ exit 1
+done 3< <(scripts/ci/render-url-records "$URLS_JSON")
+
+if [ "$chosen_seen" != "true" ]; then
+ echo "Chosen FPS scenario '${CHOSEN_NAME}' was not found in ${URLS_JSON}"
+ exit 1
+fi
+
+if [ -z "${GITHUB_ACTIONS:-}" ]; then
+ echo -e "\n\n\n"
+ echo "Plotting metrics..."
+ for csv in /tmp/*.csv; do
+ [ -f "$csv" ] || continue
+ bun scripts/metric_plot.js "$csv"
+ done
+ echo -e "\n\n\n"
+fi
diff --git a/scripts/ci/send-notification b/scripts/ci/send-notification
new file mode 100755
index 0000000000..faaee307e1
--- /dev/null
+++ b/scripts/ci/send-notification
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+import json
+import os
+import sys
+import urllib.request
+
+webhook_url = os.environ.get("SLACK_WEBHOOK_URL")
+if not webhook_url:
+ raise SystemExit(0)
+
+payload = json.dumps({
+ "text": " ".join(sys.argv[1:]),
+ "channel": "#software",
+}).encode()
+request = urllib.request.Request(
+ webhook_url,
+ data=payload,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+)
+
+try:
+ urllib.request.urlopen(request, timeout=30)
+except Exception as error:
+ print(f"Failed to send notification: {error}", file=sys.stderr)
diff --git a/scripts/ci/test_python_scripts.py b/scripts/ci/test_python_scripts.py
new file mode 100644
index 0000000000..c62c7f035d
--- /dev/null
+++ b/scripts/ci/test_python_scripts.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+import contextlib
+import io
+import json
+import os
+from pathlib import Path
+import runpy
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+
+ROOT = Path(__file__).resolve().parents[2]
+CI = ROOT / "scripts" / "ci"
+
+
+class FakeResponse:
+ def __init__(self, body):
+ self.body = body
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, _exc_type, _exc_value, _traceback):
+ return False
+
+ def read(self):
+ return self.body.encode()
+
+
+def run_script(name, argv=None, env=None):
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ script = CI / name
+ with patch.object(sys, "argv", [str(script), *(argv or [])]):
+ with patch.dict(os.environ, env or {}, clear=True):
+ with contextlib.redirect_stdout(stdout):
+ with contextlib.redirect_stderr(stderr):
+ try:
+ runpy.run_path(str(script), run_name="__main__")
+ except SystemExit as error:
+ code = error.code if isinstance(error.code, int) else 1
+ else:
+ code = 0
+ return code, stdout.getvalue(), stderr.getvalue()
+
+
+class CiPythonScriptTest(unittest.TestCase):
+ def test_percent_change_reports_percent(self):
+ code, stdout, stderr = run_script("percent-change", [
+ "--new", "110",
+ "--old", "100",
+ ])
+ self.assertEqual(code, 0)
+ self.assertEqual(stdout, "10.00\n")
+ self.assertEqual(stderr, "")
+
+ def test_percent_change_handles_zero_old_value(self):
+ code, stdout, _stderr = run_script("percent-change", [
+ "--new", "110",
+ "--old", "0",
+ ])
+ self.assertEqual(code, 0)
+ self.assertEqual(stdout, "n/a\n")
+
+ def test_previous_fps_value_reads_previous_row(self):
+ metrics_name = f"scene_metrics_test_{os.getpid()}"
+ path = Path("/tmp") / f"{metrics_name}.csv"
+ path.write_text(
+ "epoch,FPS\n"
+ "1,80\n"
+ "2,90\n"
+ "3,100\n",
+ )
+ try:
+ code, stdout, stderr = run_script("previous-fps-value", env={
+ "CHOSEN_METRICS": metrics_name,
+ "FALLBACK_FPS_VALUE": "100",
+ })
+ finally:
+ path.unlink(missing_ok=True)
+
+ self.assertEqual(code, 0)
+ self.assertEqual(stdout, "90.00\n")
+ self.assertEqual(stderr, "")
+
+ def test_previous_fps_value_uses_fallback_without_history(self):
+ code, stdout, _stderr = run_script("previous-fps-value", env={
+ "CHOSEN_METRICS": f"missing_metrics_{os.getpid()}",
+ "FALLBACK_FPS_VALUE": "100",
+ })
+ self.assertEqual(code, 0)
+ self.assertEqual(stdout, "100\n")
+
+ def test_render_url_records_outputs_field_separated_records(self):
+ with tempfile.NamedTemporaryFile("w", delete=False) as file:
+ json.dump([{
+ "name": "promo",
+ "mode": "fps",
+ "url": "http://localhost:3000/promo",
+ "click": "Run",
+ "state": "app",
+ }], file)
+ file_path = file.name
+ try:
+ code, stdout, stderr = run_script(
+ "render-url-records", [file_path])
+ finally:
+ Path(file_path).unlink(missing_ok=True)
+
+ self.assertEqual(code, 0)
+ self.assertEqual(
+ stdout, "promo\034fps\034http://localhost:3000/promo\034Run\034app\n")
+ self.assertEqual(stderr, "")
+
+ def test_create_compare_link_uses_latest_deployment_sha(self):
+ deployments = json.dumps([{"sha": "old123"}])
+ with patch("urllib.request.urlopen", return_value=FakeResponse(deployments)):
+ code, stdout, stderr = run_script("create-compare-link", env={
+ "GITHUB_SHA": "new456",
+ })
+
+ self.assertEqual(code, 0)
+ self.assertEqual(
+ stdout, "https://github.com/Farmbot/Farmbot-Web-App/compare/old123...new456\n")
+ self.assertEqual(stderr, "")
+
+ def test_send_notification_skips_without_webhook(self):
+ code, stdout, stderr = run_script("send-notification", ["hello"])
+ self.assertEqual(code, 0)
+ self.assertEqual(stdout, "")
+ self.assertEqual(stderr, "")
+
+ def test_send_notification_posts_payload(self):
+ calls = []
+
+ def fake_urlopen(request, timeout):
+ calls.append((request, timeout))
+ return object()
+
+ with patch("urllib.request.urlopen", side_effect=fake_urlopen):
+ code, stdout, stderr = run_script("send-notification", ["hello", "world"], env={
+ "SLACK_WEBHOOK_URL": "https://example.test/webhook",
+ })
+
+ self.assertEqual(code, 0)
+ self.assertEqual(stdout, "")
+ self.assertEqual(stderr, "")
+ self.assertEqual(len(calls), 1)
+ request, timeout = calls[0]
+ self.assertEqual(timeout, 30)
+ self.assertEqual(request.full_url, "https://example.test/webhook")
+ self.assertEqual(request.get_method(), "POST")
+ self.assertEqual(json.loads(request.data.decode()), {
+ "text": "hello world",
+ "channel": "#software",
+ })
+
+ def test_track_fe_coverage_appends_csv(self):
+ coverage_name = f"fe_coverage_test_{os.getpid()}"
+ csv_path = Path("/tmp") / f"{coverage_name}.csv"
+ with tempfile.NamedTemporaryFile("w", delete=False) as lcov:
+ lcov.write("LF:10\nLH:8\nLF:5\nLH:4\n")
+ lcov_path = lcov.name
+
+ try:
+ code, stdout, stderr = run_script("track-fe-coverage", env={
+ "FE_COVERAGE_NAME": coverage_name,
+ "FALLBACK_FE_COVERAGE_VALUE": "75",
+ "FE_LCOV_PATH": lcov_path,
+ "PATH": os.environ["PATH"],
+ })
+ finally:
+ Path(lcov_path).unlink(missing_ok=True)
+ csv_path.unlink(missing_ok=True)
+
+ self.assertEqual(code, 0)
+ self.assertIn(
+ "Covered lines: 12, Total lines: 15, Coverage: 80.00%", stdout)
+ self.assertIn("80.00% (6.67% change)", stdout)
+ self.assertIn(
+ "percent,covered lines,total lines,percent change\n80.00,12,15,6.67\n", stdout)
+ self.assertEqual(stderr, "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/ci/track-fe-coverage b/scripts/ci/track-fe-coverage
new file mode 100755
index 0000000000..4c4600511c
--- /dev/null
+++ b/scripts/ci/track-fe-coverage
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+import csv
+import os
+from pathlib import Path
+import subprocess
+
+fe_coverage_name = os.environ["FE_COVERAGE_NAME"]
+fallback = os.environ["FALLBACK_FE_COVERAGE_VALUE"]
+lcov_path = Path(os.environ.get("FE_LCOV_PATH", "coverage_fe/lcov.info"))
+csv_path = Path("/tmp") / f"{fe_coverage_name}.csv"
+
+covered = 0
+total = 0
+with lcov_path.open() as lcov:
+ for line in lcov:
+ if line.startswith("LH:"):
+ covered += int(line.split(":", 1)[1])
+ elif line.startswith("LF:"):
+ total += int(line.split(":", 1)[1])
+
+value = f"{(covered / total) * 100:.2f}"
+
+previous = fallback
+if csv_path.exists():
+ with csv_path.open(newline="") as csv_file:
+ reader = csv.reader(csv_file)
+ next(reader, None)
+ values = [float(row[0]) for row in reader if row and row[0]]
+ if values:
+ previous = f"{values[-1]:.2f}"
+
+percent_change = subprocess.check_output([
+ "scripts/ci/percent-change",
+ "--new",
+ value,
+ "--old",
+ previous,
+], text=True).strip()
+
+print(f"Covered lines: {covered}, Total lines: {total}, Coverage: {value}%")
+print(f"{value}% ({percent_change}% change)")
+
+if not csv_path.exists():
+ csv_path.write_text("percent,covered lines,total lines,percent change\n")
+
+with csv_path.open("a", newline="") as csv_file:
+ writer = csv.writer(csv_file)
+ writer.writerow([value, covered, total, percent_change])
+
+print(csv_path.read_text(), end="")
diff --git a/scripts/fps.js b/scripts/fps.js
index b503de4dca..6ba48751ae 100644
--- a/scripts/fps.js
+++ b/scripts/fps.js
@@ -1,45 +1,240 @@
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
+const { prepareStressResources } = require('./fps_stress_resources');
-const url = process.argv[2];
-const screenshotPath = process.argv[3] || 'tmp/fps.png';
+function parseArgs(argv) {
+ const options = {
+ screenshotPath: '/tmp/fps.png',
+ samplesCsvPath: '/tmp/fps_samples.csv',
+ screenshotOnly: false,
+ };
+ const valueOptions = {
+ '--name': 'name',
+ '--url': 'url',
+ '--screenshot-path': 'screenshotPath',
+ '--fps-samples-path': 'samplesCsvPath',
+ '--click': 'click',
+ '--state': 'state',
+ };
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '-h' || arg === '--help') {
+ options.help = true;
+ continue;
+ }
+ if (arg === '--screenshot-only') {
+ options.screenshotOnly = true;
+ continue;
+ }
+ const optionName = valueOptions[arg];
+ if (!optionName) { throw new Error(`Unknown argument: ${arg}`); }
+ const value = argv[i + 1];
+ if (!value || value.startsWith('--')) {
+ throw new Error(`Missing value for ${arg}`);
+ }
+ options[optionName] = value;
+ i++;
+ }
+
+ return options;
+}
+
+const options = parseArgs(process.argv.slice(2));
+const name = options.name;
+const url = options.url;
+const screenshotPath = options.screenshotPath;
+const samplesCsvPath = options.samplesCsvPath;
+const maxLoadingSamples = 240;
+const postLoadSamples = 10;
+const sampleIntervalMs = 1000;
+const ci = Boolean(process.env.CI);
+const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW);
+const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
+const screenshotOnly = options.screenshotOnly;
+const click = options.click;
+const state = options.state ? path.join('/tmp', `${options.state}.json`) : undefined;
+const saveState = path.join('/tmp', `${name}.json`);
+const chromiumArgs = [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--ignore-gpu-blocklist',
+ '--disable-frame-rate-limit',
+ '--disable-gpu-vsync',
+ '--enable-features=Vulkan',
+ '--use-gl=angle',
+ '--enable-gpu-rasterization',
+ '--enable-zero-copy',
+ ...((openWindow && !ci) ? [] : ['--use-angle=vulkan']),
+];
+
+const pageIsLoading = page =>
+ page.evaluate(() => Boolean(document.querySelector('.three-d-load-progress')));
+
+const webglRenderer = page =>
+ page.evaluate(() => {
+ const canvas = document.createElement('canvas');
+ const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
+ if (!gl) { return { status: 'unavailable' }; }
+
+ const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
+ if (!debugInfo) { return { status: 'available' }; }
+
+ return {
+ status: 'available',
+ vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL),
+ renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL),
+ };
+ });
+
+function printUsage() {
+ console.log([
+ 'Usage: bun scripts/fps.js --name
--url [options]',
+ '',
+ 'Options:',
+ ' --name Scenario name used for storage state output.',
+ ' --url Page URL that exposes window.__fps and window.__scene_metrics.',
+ ' --screenshot-path Full-page screenshot PNG path. Default: /tmp/fps.png',
+ ' --fps-samples-path FPS samples CSV path. Default: /tmp/fps_samples.csv',
+ ' --screenshot-only Take a screenshot without FPS metrics.',
+ ' --click Click an element by title after page load.',
+ ' --state Load cookies and localStorage from /tmp/.json.',
+ '',
+ 'Environment:',
+ ' CI Use CI browser launch behavior.',
+ ' DISPLAY Display server used for headed local runs.',
+ ' OPEN_WINDOW Open a visible browser window when DISPLAY is set.',
+ ' PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH Path to a Chrome/Chromium binary.',
+ ].join('\n'));
+}
+
+const csvField = value => {
+ const stringValue = String(value);
+ return /[",\n\r]/.test(stringValue)
+ ? `"${stringValue.replace(/"/g, '""')}"`
+ : stringValue;
+};
+
+function saveFpsSamplesCsv(samples, destination) {
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
+ const rows = [
+ ['elapsed seconds', 'fps', 'loading', 'averaged'],
+ ...samples.map(sample => [
+ Number(sample.elapsedSeconds).toFixed(3),
+ Number(sample.fps).toFixed(2),
+ sample.loading ? 'true' : 'false',
+ sample.averaged ? 'true' : 'false',
+ ]),
+ ];
+ fs.writeFileSync(destination, `${rows
+ .map(row => row.map(csvField).join(','))
+ .join('\n')}\n`);
+}
+
+async function saveStorage(page) {
+ fs.mkdirSync(path.dirname(saveState), { recursive: true });
+ await page.context().storageState({ path: saveState });
+ console.log(`SAVE_STATE=${saveState}`);
+}
async function main() {
+ console.log(`Launching Chromium with args:\n ${chromiumArgs.join('\n ')}\n`);
+ if (executablePath) {
+ console.log(`CHROMIUM_EXECUTABLE_PATH=${executablePath}`);
+ }
const browser = await chromium.launch({
- headless: true,
- args: [
- '--no-sandbox',
- '--disable-setuid-sandbox',
- '--disable-dev-shm-usage',
- '--enable-gpu',
- ],
+ headless: !openWindow,
+ executablePath,
+ args: chromiumArgs,
});
- const page = await browser.newPage();
- page.setDefaultTimeout(120_000);
+ const contextOptions = state ? { storageState: state } : {};
+ if (state) {
+ console.log(`STATE=${state}`);
+ }
+ const context = await browser.newContext(contextOptions);
+ const page = await context.newPage();
+ page.setDefaultTimeout(60_000);
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
- await page.waitForFunction(() => {
- const canvas = document.querySelector('.garden-bed-3d-model canvas');
- return Boolean(canvas && typeof canvas.dataset.engine === 'string');
- });
+ await prepareStressResources(page, url);
+ if (click) {
+ await page.getByTitle(click).click();
+ console.log(`CLICK=${click}`);
+ }
+ if (screenshotOnly) {
+ await page.waitForTimeout(1000);
+ fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
+ await page.screenshot({
+ path: screenshotPath,
+ fullPage: true,
+ timeout: 60_000,
+ });
+ console.log(`SCREENSHOT=${screenshotPath}`);
+ return;
+ }
+ const renderer = await webglRenderer(page);
+ console.log(`WEBGL_STATUS=${renderer.status}`);
+ if (renderer.vendor) { console.log(`WEBGL_VENDOR=${renderer.vendor}`); }
+ if (renderer.renderer) { console.log(`WEBGL_RENDERER=${renderer.renderer}`); }
await page.waitForFunction(() => typeof window.__fps !== 'undefined');
- const samples = 10;
- const takeSample = 5;
- let lastSample = 0;
- let validCount = 0;
- for (let i = 0; i < samples; i++) {
+ let averagePostLoadSample = 0;
+ let loadingSampleCount = 0;
+ let postLoadCount = 0;
+ const postLoadValues = [];
+ const sampleValues = [];
+ let loading = true;
+ const maxSamples = maxLoadingSamples + postLoadSamples;
+ const startMs = Date.now();
+ let loadedSeen = false;
+ for (let i = 0; i < maxSamples; i++) {
const v = await page.evaluate(() => window.__fps);
const n = Number(v);
- if (Number.isFinite(n) && validCount <= takeSample) {
- lastSample = n;
- validCount++;
+ const elapsedSeconds = (Date.now() - startMs) / 1000;
+ const pageLoading = await pageIsLoading(page);
+ if (pageLoading) {
+ loadedSeen = false;
+ loading = true;
+ } else {
+ loading = !loadedSeen;
+ loadedSeen = true;
}
- console.log(`Sample ${i + 1}/${samples}: ${n}`);
- await page.waitForTimeout(1000);
+ let averaged = false;
+ if (loading) {
+ loadingSampleCount++;
+ } else {
+ postLoadCount++;
+ postLoadValues.push(n);
+ averaged = true;
+ }
+ const sample = {
+ fps: n,
+ elapsedSeconds,
+ loading,
+ averaged,
+ };
+ sampleValues.push(sample);
+ const status = loading
+ ? 'loading'
+ : `loaded ${postLoadCount}/${postLoadSamples}`;
+ const averagedMarker = averaged ? ' <---' : '';
+ console.log(`Sample ${i + 1} (${status}, ${elapsedSeconds.toFixed(2)}s): ${n.toFixed(2)}fps${averagedMarker}`);
+ if (postLoadCount >= postLoadSamples) { break; }
+ if (loadingSampleCount >= maxLoadingSamples) { break; }
+ await page.waitForTimeout(sampleIntervalMs);
+ }
+ if (loading) {
+ throw new Error(`3D load did not finish after ${sampleValues.length} samples`);
}
- console.log(`FPS_VALUE=${lastSample.toFixed(2)}`);
+ averagePostLoadSample =
+ postLoadValues.reduce((total, value) => total + value, 0)
+ / postLoadValues.length;
+ if (!Number.isFinite(averagePostLoadSample)) {
+ throw new Error('Average post-load FPS was not a valid value');
+ }
+ console.log(`FPS_VALUE=${averagePostLoadSample.toFixed(2)}`);
const data = await page.evaluate(() => window.__scene_metrics);
console.log(`SCENE_METRICS=${data}`);
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
@@ -49,6 +244,9 @@ async function main() {
timeout: 60_000,
});
console.log(`FPS_SCREENSHOT=${screenshotPath}`);
+ saveFpsSamplesCsv(sampleValues, samplesCsvPath);
+ console.log(`FPS_SAMPLES_CSV=${samplesCsvPath}`);
+ await saveStorage(page);
} catch (err) {
console.error('Failed to read window.__fps:', err.message || err);
process.exitCode = 1;
@@ -57,7 +255,15 @@ async function main() {
}
}
-main().catch((err) => {
- console.error('Unexpected error:', err);
+if (options.help) {
+ printUsage();
+ process.exitCode = 0;
+} else if (!name || !url) {
+ printUsage();
process.exitCode = 1;
-});
+} else {
+ main().catch((err) => {
+ console.error('Unexpected error:', err);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/fps_stress_resources.js b/scripts/fps_stress_resources.js
new file mode 100644
index 0000000000..7d64deb95f
--- /dev/null
+++ b/scripts/fps_stress_resources.js
@@ -0,0 +1,62 @@
+const stressResourceTimeoutMs = 180_000;
+const stressResourceSettleMs = 5_000;
+
+const productLineFromUrl = value => new URL(value).searchParams.get('productLine');
+
+const stressResourceCountFromProductLine = productLine => {
+ const match = productLine && productLine.match(/_stress_(\d+)$/);
+ return match ? Number(match[1]) : undefined;
+};
+
+const stressResourceCountFromUrl = value =>
+ stressResourceCountFromProductLine(productLineFromUrl(value));
+
+const isStressTryFarmbotUrl = value =>
+ new URL(value).pathname.includes('/try_farmbot')
+ && Number.isFinite(stressResourceCountFromUrl(value));
+
+const appUrlFor = value => new URL('/app/designer/plants', value).toString();
+
+const waitForStressResources = async (page, stressResourceCount) => {
+ await page.waitForFunction(() => localStorage.getItem('session'), {
+ timeout: stressResourceTimeoutMs,
+ });
+ await page.waitForFunction(async expected => {
+ const session = JSON.parse(localStorage.getItem('session'));
+ const headers = { Authorization: session.token.encoded };
+ const json = path => fetch(path, { headers }).then(response => {
+ if (!response.ok) { throw new Error(`${response.status} ${path}`); }
+ return response.json();
+ });
+ const [points, images, sensorReadings] = await Promise.all([
+ json('/api/points'),
+ json('/api/images'),
+ json('/api/sensor_readings'),
+ ]);
+ const plants = points.filter(point => point.pointer_type == 'Plant').length;
+ const weeds = points.filter(point => point.pointer_type == 'Weed').length;
+ const soilHeightPoints = points
+ .filter(point => point.name == 'Soil Height').length;
+ return plants >= expected
+ && weeds >= expected
+ && soilHeightPoints >= expected
+ && images.length >= expected
+ && sensorReadings.length >= expected;
+ }, stressResourceCount, { timeout: stressResourceTimeoutMs });
+};
+
+const prepareStressResources = async (page, url) => {
+ if (!isStressTryFarmbotUrl(url)) { return; }
+ const stressResourceCount = stressResourceCountFromUrl(url);
+ console.log(`Waiting for ${stressResourceCount} stress resources...`);
+ await waitForStressResources(page, stressResourceCount);
+ console.log(`Stress resources found. Waiting ${stressResourceSettleMs}ms...`);
+ await page.waitForTimeout(stressResourceSettleMs);
+ const appUrl = appUrlFor(url);
+ console.log(`Stress resources loaded. Navigating to ${appUrl}`);
+ await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
+};
+
+module.exports = {
+ prepareStressResources,
+};
diff --git a/scripts/metric_plot.js b/scripts/metric_plot.js
new file mode 100644
index 0000000000..157b6d6952
--- /dev/null
+++ b/scripts/metric_plot.js
@@ -0,0 +1,402 @@
+const fs = require('fs');
+const path = require('path');
+
+const escapeSvgText = value => String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+const formatStat = value => value.toFixed(2);
+const formatPoint = value => Number(value.toFixed(2));
+const seriesColors = [
+ '#0969da',
+ '#1a7f37',
+ '#cf222e',
+ '#8250df',
+ '#bf8700',
+ '#0a7ea4',
+ '#d12470',
+ '#57606a',
+ '#953800',
+ '#116329',
+];
+const normalizeMetricSamples = (samples, valueKey = 'value') => samples
+ .map((sample, index) => {
+ const value = typeof sample === 'object' ? sample[valueKey] : sample;
+ const x = typeof sample === 'object'
+ ? Number(sample.x ?? sample.elapsedSeconds ?? index)
+ : index;
+ return {
+ value: Number(value),
+ x,
+ index,
+ loading: typeof sample === 'object' ? sample.loading : undefined,
+ averaged: typeof sample === 'object' ? sample.averaged : undefined,
+ };
+ })
+ .filter(({ value, x }) => Number.isFinite(value) && Number.isFinite(x));
+
+const normalizeSeries = (series, valueKey) => series
+ .map((item, index) => ({
+ name: item.name || `Series ${index + 1}`,
+ color: item.color || seriesColors[index % seriesColors.length],
+ samples: normalizeMetricSamples(item.samples || [], valueKey),
+ }))
+ .filter(item => item.samples.length);
+
+function buildMetricPlotSvg(samples, options = {}) {
+ const inputSamples = samples || [];
+ const width = options.width || 640;
+ const height = options.height || 320;
+ const highlightIndex = options.highlightIndex;
+ const title = escapeSvgText(options.title || 'Metric samples');
+ const xLabel = escapeSvgText(options.xLabel || 'Samples');
+ const valueKey = options.valueKey || 'value';
+ const series = normalizeSeries(options.series || [{
+ name: options.seriesName || title,
+ samples: inputSamples,
+ }], valueKey);
+ const multiSeries = series.length > 1;
+ const margin = {
+ top: multiSeries ? 76 : 52,
+ right: 24,
+ bottom: multiSeries ? 52 : 44,
+ left: 54,
+ };
+ const plotWidth = width - margin.left - margin.right;
+ const plotHeight = height - margin.top - margin.bottom;
+ const finite = series.flatMap(item => item.samples);
+ const values = finite.map(({ value }) => value);
+ const statSamples = options.statsAfterLoaded
+ ? finite.filter(({ loading }) => loading === false)
+ : finite;
+ const statValues = statSamples.map(({ value }) => value);
+ const xValues = finite
+ .map(({ x }) => x)
+ .filter(value => Number.isFinite(value));
+ const minSample = statValues.length ? Math.min(...statValues) : 0;
+ const maxSample = statValues.length ? Math.max(...statValues) : 0;
+ const avgSample = statValues.length
+ ? statValues.reduce((total, value) => total + value, 0) / statValues.length
+ : 0;
+ const lastSample = statValues.length ? statValues[statValues.length - 1] : 0;
+ const yMinSample = values.length ? Math.min(...values) : 0;
+ const minValue = values.length ? yMinSample : 0;
+ const yMaxSample = values.length > 1
+ ? [...values].sort((a, b) => b - a)[1]
+ : maxSample;
+ const maxValue = Math.max(1, yMaxSample);
+ const valueRange = maxValue - minValue || 1;
+ const minX = Math.min(0, xValues.length ? Math.min(...xValues) : 0);
+ const maxX = Math.max(1, xValues.length ? Math.max(...xValues) : inputSamples.length - 1);
+ const xRange = maxX - minX || 1;
+ const xFor = x => margin.left + (
+ (x - minX) / xRange
+ ) * plotWidth;
+ const yFor = value => {
+ const y = margin.top + ((maxValue - value) / valueRange) * plotHeight;
+ return Math.max(margin.top, Math.min(height - margin.bottom, y));
+ };
+ const lines = series
+ .map(item => {
+ const points = item.samples
+ .map(({ value, x }) =>
+ `${formatPoint(xFor(x))},${formatPoint(yFor(value))}`)
+ .join(' ');
+ return points
+ ? ``
+ : '';
+ })
+ .join('');
+ const circles = multiSeries
+ ? ''
+ : (series[0]?.samples || [])
+ .map(({ value, x, index }) => {
+ const highlighted = index === highlightIndex;
+ return [
+ ``,
+ ].join('');
+ })
+ .join('');
+ const gridValues = [maxValue, (maxValue + minValue) / 2, minValue];
+ const grid = gridValues
+ .map(value => {
+ const y = formatPoint(yFor(value));
+ return [
+ ``,
+ `${formatStat(value)}`,
+ ].join('');
+ })
+ .join('');
+ const tickInterval = maxX <= 10 ? 1 : maxX <= 100 ? 10 : 100;
+ const xTicks = Array.from({ length: Math.floor(maxX / tickInterval) + 1 },
+ (_value, index) => {
+ const tickValue = index * tickInterval;
+ const x = formatPoint(xFor(tickValue));
+ return [
+ ``,
+ `${tickValue}`,
+ ].join('');
+ })
+ .join('');
+ const firstLoaded = series[0]?.samples.find(({ loading }) => loading === false);
+ const loadedMarker = firstLoaded
+ ? [
+ ``,
+ `Loaded`,
+ ].join('')
+ : '';
+ const averageValue = Number(options.averageValue);
+ const averageLine = Number.isFinite(averageValue)
+ ? [
+ ``,
+ `avg ${formatStat(averageValue)}`,
+ ].join('')
+ : '';
+ const displayedAverage = Number.isFinite(averageValue)
+ ? averageValue
+ : avgSample;
+ const stats = options.hideStats
+ ? ''
+ : escapeSvgText(statValues.length
+ ? `min ${formatStat(minSample)} avg ${formatStat(displayedAverage)} max ${formatStat(maxSample)} last ${formatStat(lastSample)}`
+ : 'No valid samples');
+ const legend = multiSeries
+ ? series
+ .map((item, index) => {
+ const x = margin.left + (index % 5) * 112;
+ const y = 48 + Math.floor(index / 5) * 16;
+ return [
+ ``,
+ `${escapeSvgText(item.name)}`,
+ ].join('');
+ })
+ .join('')
+ : '';
+
+ return `
+
+
+
+
+
+
+
+
+`;
+}
+
+async function saveMetricPlot(browser, samples, destination, options = {}) {
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
+ const plotPage = await browser.newPage({ viewport: { width: 640, height: 320 } });
+ try {
+ await plotPage.setContent(buildMetricPlotSvg(samples, options), { waitUntil: 'load' });
+ await plotPage.locator('svg').screenshot({
+ path: destination,
+ timeout: 60_000,
+ });
+ } finally {
+ await plotPage.close();
+ }
+}
+
+const parseCsvLine = line => {
+ const fields = [];
+ let field = '';
+ let quoted = false;
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+ if (char === '"' && quoted && line[i + 1] === '"') {
+ field += '"';
+ i++;
+ } else if (char === '"') {
+ quoted = !quoted;
+ } else if (char === ',' && !quoted) {
+ fields.push(field.trim());
+ field = '';
+ } else {
+ field += char;
+ }
+ }
+ fields.push(field.trim());
+ return fields;
+};
+
+const parseCsv = content => {
+ const lines = content.split(/\r?\n/).filter(line => line.trim());
+ const headers = lines.length ? parseCsvLine(lines[0]) : [];
+ const rows = lines.slice(1).map(line => {
+ const fields = parseCsvLine(line);
+ return Object.fromEntries(headers.map((header, index) => [header, fields[index]]));
+ });
+ return { headers, rows };
+};
+
+const title = (basename, prefix) => {
+ const name = basename.replace(/\.csv$/, '').replace(/_/g, ' ');
+ const suffix = name.replace(prefix.toLowerCase(), '').trim();
+ return suffix ? `${prefix}: ${suffix}` : prefix;
+}
+
+const inferCsvPlot = ({ headers, rows }, filename = '') => {
+ const basename = path.basename(filename);
+ const isSceneMetricsCsv = /^scene_metrics(?:_[^/]+)?\.csv$/.test(basename);
+ const firstHeader = headers[0];
+ const xHeader = headers.find(header =>
+ ['elapsed seconds', 'elapsedSeconds'].includes(header));
+ const sceneMetricExcludedHeaders = ['epoch', 'Points', 'Lines'];
+ const sceneMetricValueFor = (row, header) => {
+ const value = Number(row[header]);
+ if (header === 'FPS') { return value ? 1000 / value : NaN; }
+ return header === 'Triangles' ? value / 1000 : value;
+ };
+ const valueFor = (row, header) => Number(row[header]);
+ const sceneMetricLabelFor = header => {
+ if (header === 'FPS') { return 'Frame time (ms)'; }
+ return header === 'Triangles' ? 'Triangles (k)' : header;
+ };
+ const sampleFor = (row, index, valueHeader) => ({
+ x: xHeader ? row[xHeader] : index,
+ value: valueFor(row, valueHeader),
+ loading: row.loading === undefined ? undefined : row.loading === 'true',
+ averaged: row.averaged === undefined ? undefined : row.averaged === 'true',
+ });
+ const plottableHeaders = headers
+ .filter(header => !sceneMetricExcludedHeaders.includes(header))
+ .filter(header => rows.some(row => Number.isFinite(sceneMetricValueFor(row, header))));
+
+ if (headers.includes('percent')) {
+ return {
+ title: 'Frontend coverage',
+ xLabel: 'Runs',
+ samples: rows.map((row, index) => sampleFor(row, index, 'percent')),
+ };
+ }
+ if (headers.includes('FPS')) {
+ if (isSceneMetricsCsv) {
+ return {
+ title: title(basename, 'Scene metrics'),
+ xLabel: 'Runs',
+ hideStats: true,
+ series: plottableHeaders.map(header => ({
+ name: sceneMetricLabelFor(header),
+ samples: rows.map((row, index) => ({
+ x: index,
+ value: sceneMetricValueFor(row, header),
+ })),
+ })),
+ };
+ }
+ return {
+ title: title(basename, 'FPS samples'),
+ xLabel: 'Runs',
+ samples: rows.map((row, index) => sampleFor(row, index, 'FPS')),
+ };
+ }
+ if (headers.includes('fps')) {
+ const averagedValues = rows
+ .filter(row => row.averaged === 'true')
+ .map(row => Number(row.fps))
+ .filter(Number.isFinite);
+ const averageValue = averagedValues.length
+ ? averagedValues.reduce((total, value) => total + value, 0)
+ / averagedValues.length
+ : undefined;
+ const highlightIndex = rows.findIndex(row => row.chosen === 'true');
+ return {
+ title: title(basename, 'FPS samples'),
+ xLabel: xHeader ? 'Seconds' : 'Samples',
+ samples: rows.map((row, index) => sampleFor(row, index, 'fps')),
+ statsAfterLoaded: true,
+ ...(Number.isFinite(averageValue) ? { averageValue } : {}),
+ ...(highlightIndex >= 0 ? { highlightIndex } : {}),
+ };
+ }
+ throw new Error(`No plottable metric found in ${basename || firstHeader || 'CSV'}`);
+};
+
+const buildCsvPlotSvg = (content, options = {}) => {
+ const inferred = inferCsvPlot(parseCsv(content), options.filename);
+ return buildMetricPlotSvg(inferred.samples, { ...inferred, ...options });
+};
+
+async function saveCsvPlot(browser, csvPath, destination, options = {}) {
+ const content = fs.readFileSync(csvPath, 'utf8');
+ const inferred = inferCsvPlot(parseCsv(content), csvPath);
+ return saveMetricPlot(browser, inferred.samples, destination, {
+ ...inferred,
+ ...options,
+ });
+}
+
+function printUsage() {
+ console.log([
+ 'Usage: bun scripts/metric_plot.js [plot_path]',
+ '',
+ 'Supported CSV inputs:',
+ ' fps_samples.csv Plots the fps column against elapsed seconds.',
+ ' fe_coverage.csv Plots the percent column.',
+ ' scene_metrics.csv Plots numeric columns.',
+ '',
+ 'Arguments:',
+ ' csv_path CSV file to plot.',
+ ' plot_path Optional PNG output path. Default: /tmp/.png',
+ ].join('\n'));
+}
+
+async function main() {
+ const csvPath = process.argv[2];
+ if (!csvPath || csvPath === '-h' || csvPath === '--help') {
+ printUsage();
+ process.exitCode = csvPath ? 0 : 1;
+ return;
+ }
+
+ const destination = process.argv[3]
+ || path.join('/tmp', `${path.basename(csvPath, '.csv')}.png`);
+ const { chromium } = require('playwright');
+ const browser = await chromium.launch({
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ ],
+ });
+ try {
+ await saveCsvPlot(browser, csvPath, destination);
+ console.log(`CSV_PLOT=${destination}`);
+ } finally {
+ await browser.close();
+ }
+}
+
+module.exports = {
+ buildCsvPlotSvg,
+ buildMetricPlotSvg,
+ escapeSvgText,
+ inferCsvPlot,
+ parseCsv,
+ saveCsvPlot,
+ saveMetricPlot,
+};
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.error('Failed to plot CSV:', err.message || err);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/perf/stress_1000_3d.js b/scripts/perf/stress_1000_3d.js
new file mode 100644
index 0000000000..f422d26cd9
--- /dev/null
+++ b/scripts/perf/stress_1000_3d.js
@@ -0,0 +1,711 @@
+const { chromium } = require("playwright");
+const crypto = require("crypto");
+const fs = require("fs");
+const path = require("path");
+
+const DEFAULT_URL = "http://localhost:3000";
+const PRODUCT_LINE = "genesis_xl_1.8_stress_1000";
+const DEMO_USER = "farmbot_demo";
+const TIMEOUT = 180_000;
+const DEFAULT_VIEWPORT = { width: 3840, height: 2160 };
+const DEFAULT_SAMPLE_MS = 12_000;
+
+const parseArgs = () => {
+ const [command = "run", ...rest] = process.argv.slice(2);
+ const args = { command };
+ for (let i = 0; i < rest.length; i += 2) {
+ args[rest[i].replace(/^--/, "")] = rest[i + 1];
+ }
+ return args;
+};
+
+const median = values => {
+ const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
+ if (sorted.length == 0) { return undefined; }
+ return sorted[Math.floor(sorted.length / 2)];
+};
+
+const percentile = (values, p) => {
+ const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
+ if (sorted.length == 0) { return undefined; }
+ return sorted[Math.ceil((p / 100) * sorted.length) - 1];
+};
+
+const summary = runs => {
+ const metric = key => median(runs.map(run => run[key]));
+ return {
+ pageReadyMs: metric("pageReadyMs"),
+ coreReadyMs: metric("coreReadyMs"),
+ fullReadyMs: metric("fullReadyMs"),
+ fpsMedian: metric("fpsMedian"),
+ frameP95Ms: metric("frameP95Ms"),
+ navPlantMs: metric("navPlantMs"),
+ navPointMs: metric("navPointMs"),
+ navWeedMs: metric("navWeedMs"),
+ togglePlantsMs: metric("togglePlantsMs"),
+ togglePointsMs: metric("togglePointsMs"),
+ toggleWeedsMs: metric("toggleWeedsMs"),
+ toggleSpreadMs: metric("toggleSpreadMs"),
+ toggleFarmbotMs: metric("toggleFarmbotMs"),
+ jsEncodedBytes: metric("jsEncodedBytes"),
+ jsTransferBytes: metric("jsTransferBytes"),
+ jsResourceCount: metric("jsResourceCount"),
+ modelEncodedBytes: metric("modelEncodedBytes"),
+ modelTransferBytes: metric("modelTransferBytes"),
+ modelResourceCount: metric("modelResourceCount"),
+ threeDGardenMapRenders: metric("threeDGardenMapRenders"),
+ gardenModelRenders: metric("gardenModelRenders"),
+ threeDGardenRenders: metric("threeDGardenRenders"),
+ plantInventoryItemRenders: metric("plantInventoryItemRenders"),
+ drawCalls: metric("drawCalls"),
+ triangles: metric("triangles"),
+ webglGeometries: metric("webglGeometries"),
+ webglTextures: metric("webglTextures"),
+ sceneObjects: metric("sceneObjects"),
+ sceneMeshes: metric("sceneMeshes"),
+ sceneInstancedMeshes: metric("sceneInstancedMeshes"),
+ usedJSHeapSize: metric("usedJSHeapSize"),
+ getZBatchMs: metric("getZBatchMs"),
+ getZCalls: metric("getZCalls"),
+ getZIndexMs: metric("getZIndexMs"),
+ getZP95Ms: metric("getZP95Ms"),
+ soilPointFilterMs: metric("soilPointFilterMs"),
+ soilSurfaceMs: metric("soilSurfaceMs"),
+ soilStorageMs: metric("soilStorageMs"),
+ soilStorageCalls: metric("soilStorageCalls"),
+ imageTextureSetupMs: metric("imageTextureSetupMs"),
+ imageWrapperSetupMs: metric("imageWrapperSetupMs"),
+ soilTextureRenders: metric("soilTextureRenders"),
+ spreadFrameUpdateMs: metric("spreadFrameUpdateMs"),
+ moistureSurfaceMs: metric("moistureSurfaceMs"),
+ moistureInstanceNodesMs: metric("moistureInstanceNodesMs"),
+ };
+};
+
+const firstMark = (marks, ...names) => {
+ for (const name of names) {
+ const value = marks[name]?.[0];
+ if (Number.isFinite(value)) { return value; }
+ }
+};
+
+const maxMark = (marks, names) => {
+ const values = names
+ .map(name => marks[name]?.[0])
+ .filter(Number.isFinite);
+ return values.length > 0 ? Math.max(...values) : undefined;
+};
+
+const nextPaint = page =>
+ page.evaluate(() => new Promise(resolve =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve))));
+
+const resourceSummary = async page => page.evaluate(() => {
+ const jsResources = performance.getEntriesByType("resource")
+ .filter(entry => entry.name.match(/\.js(\?|$)/));
+ const modelResources = performance.getEntriesByType("resource")
+ .filter(entry => entry.name.match(/\.(glb|gltf)(\?|$)/));
+ const sum = key => jsResources
+ .reduce((total, entry) => total + (entry[key] || 0), 0);
+ const modelSum = key => modelResources
+ .reduce((total, entry) => total + (entry[key] || 0), 0);
+ const largestModels = modelResources
+ .map(entry => ({
+ name: entry.name.split("/").pop(),
+ transferSize: entry.transferSize || 0,
+ encodedBodySize: entry.encodedBodySize || 0,
+ decodedBodySize: entry.decodedBodySize || 0,
+ duration: entry.duration || 0,
+ }))
+ .sort((a, b) => b.encodedBodySize - a.encodedBodySize)
+ .slice(0, 10);
+ const largestJs = jsResources
+ .map(entry => ({
+ name: entry.name.split("/").pop(),
+ transferSize: entry.transferSize || 0,
+ encodedBodySize: entry.encodedBodySize || 0,
+ decodedBodySize: entry.decodedBodySize || 0,
+ duration: entry.duration || 0,
+ }))
+ .sort((a, b) => b.encodedBodySize - a.encodedBodySize)
+ .slice(0, 10);
+ return {
+ jsResourceCount: jsResources.length,
+ jsTransferBytes: sum("transferSize"),
+ jsEncodedBytes: sum("encodedBodySize"),
+ jsDecodedBytes: sum("decodedBodySize"),
+ largestJs,
+ modelResourceCount: modelResources.length,
+ modelTransferBytes: modelSum("transferSize"),
+ modelEncodedBytes: modelSum("encodedBodySize"),
+ modelDecodedBytes: modelSum("decodedBodySize"),
+ largestModels,
+ };
+});
+
+const runtimeSummary = async page => page.evaluate(() => {
+ const parseLegacySceneMetrics = () => {
+ const values = (window.__scene_metrics || "")
+ .split(",")
+ .map(value => Number(value.trim()));
+ return {
+ calls: values[2],
+ triangles: values[3],
+ geometries: values[6],
+ textures: values[7],
+ total: values[8],
+ meshes: values[9],
+ instancedMeshes: values[10],
+ };
+ };
+ const legacy = parseLegacySceneMetrics();
+ const render = window.__threeDRenderMetrics || {};
+ const scene = window.__collectThreeDSceneMetrics?.() || {};
+ const memory = performance.memory || {};
+ return {
+ drawCalls: render.calls ?? legacy.calls,
+ triangles: render.triangles ?? legacy.triangles,
+ webglGeometries: render.geometries ?? legacy.geometries,
+ webglTextures: render.textures ?? legacy.textures,
+ sceneObjects: scene.total ?? legacy.total,
+ sceneMeshes: scene.meshes ?? legacy.meshes,
+ sceneInstancedMeshes: scene.instancedMeshes ?? legacy.instancedMeshes,
+ usedJSHeapSize: memory.usedJSHeapSize,
+ totalJSHeapSize: memory.totalJSHeapSize,
+ jsHeapSizeLimit: memory.jsHeapSizeLimit,
+ };
+});
+
+const createDemoSession = async (browser, baseUrl) => {
+ const secret = crypto.randomUUID().replaceAll("-", "");
+ const page = await browser.newPage();
+ await page.goto(`${baseUrl}/demo`, { waitUntil: "domcontentloaded" });
+ await page.addScriptTag({ path: require.resolve("mqtt/dist/mqtt.min.js") });
+ const session = await page.evaluate(async ({ demoUser, line, value }) => {
+ const configResponse = await fetch("/api/global_config");
+ const config = await configResponse.json();
+ const topic = `demos/${value}`;
+ const client = window.mqtt.connect(config.MQTT_WS, {
+ username: demoUser,
+ password: "required, but not used.",
+ });
+ const tokenPromise = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ client.end(true);
+ reject(new Error("Timed out waiting for demo token over MQTT."));
+ }, 180_000);
+ client.on("connect", () => {
+ client.subscribe(topic, error => error && reject(error));
+ });
+ client.on("message", (_topic, buffer) => {
+ clearTimeout(timeout);
+ client.end(true);
+ resolve(buffer.toString());
+ });
+ client.on("error", reject);
+ });
+ const response = await fetch("/api/demo_account", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ secret: value, product_line: line }),
+ });
+ if (!response.ok) {
+ throw new Error(`${response.status} ${response.statusText}`);
+ }
+ return tokenPromise;
+ }, {
+ demoUser: DEMO_USER,
+ line: PRODUCT_LINE,
+ value: secret,
+ });
+ await page.close();
+ return session;
+};
+
+const authHeader = session => JSON.parse(session).token.encoded;
+
+const apiJson = async (baseUrl, session, endpoint, options = {}) => {
+ const response = await fetch(`${baseUrl}${endpoint}`, {
+ ...options,
+ headers: {
+ Authorization: authHeader(session),
+ "Content-Type": "application/json",
+ ...(options.headers || {}),
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`${response.status} ${response.statusText}: ${endpoint}`);
+ }
+ return response.json();
+};
+
+const setFarmwareEnv = async (baseUrl, session, key, value) => {
+ const envs = await apiJson(baseUrl, session, "/api/farmware_envs");
+ const existing = envs.find(env => env.key == key);
+ if (existing) {
+ await apiJson(baseUrl, session, `/api/farmware_envs/${existing.id}`, {
+ method: "PUT",
+ body: JSON.stringify({ value }),
+ });
+ } else {
+ await apiJson(baseUrl, session, "/api/farmware_envs", {
+ method: "POST",
+ body: JSON.stringify({ key, value }),
+ });
+ }
+};
+
+const waitFor3D = async page => {
+ await page.waitForFunction(() => {
+ const canvas = document.querySelector(".garden-bed-3d-model canvas");
+ return Boolean(canvas && typeof canvas.dataset.engine == "string");
+ }, { timeout: TIMEOUT });
+ await page.waitForFunction(() => typeof window.__fps == "number", {
+ timeout: TIMEOUT,
+ });
+};
+
+const waitForGardenRender = async (page, beforeRenderCount) => {
+ await page.waitForFunction(before => {
+ const count = window.__fbPerf?.counts?.["render.GardenModel"] || 0;
+ return count > before;
+ }, beforeRenderCount, { timeout: 10_000 }).catch(() => undefined);
+ await nextPaint(page);
+};
+
+const openSoilHeightSection = async page => {
+ const section = page.locator(".points-section-header")
+ .filter({ hasText: "Soil Height" })
+ .first();
+ if (await section.count() == 0) { return; }
+ await section.click();
+ await page.locator(".point-search-item").first()
+ .waitFor({ timeout: 10_000 }).catch(() => undefined);
+};
+
+const clickAndMeasure = async (
+ page,
+ route,
+ itemSelector,
+ panelSelector,
+ prepare,
+) => {
+ await page.goto(route, { waitUntil: "domcontentloaded" });
+ await waitFor3D(page);
+ const item = page.locator(itemSelector).first();
+ let count = await item.count();
+ if (count == 0 && prepare) {
+ await prepare(page);
+ count = await item.count();
+ }
+ if (count == 0) { return undefined; }
+ if (!await item.isVisible()) { return undefined; }
+ const startedAt = await page.evaluate(() => performance.now());
+ await item.click();
+ await page.waitForSelector(panelSelector, { timeout: TIMEOUT });
+ return page.evaluate(start => performance.now() - start, startedAt);
+};
+
+const measureLayerToggle = async (page, labelText) => {
+ const toggle = page.locator("fieldset")
+ .filter({ hasText: labelText })
+ .locator(".fb-layer-toggle")
+ .first();
+ const count = await toggle.count();
+ if (count == 0) { return undefined; }
+ if (!await toggle.isVisible()) { return undefined; }
+ const beforeRenderCount = await page.evaluate(() =>
+ window.__fbPerf?.counts?.["render.GardenModel"] || 0);
+ const startedAt = await page.evaluate(() => performance.now());
+ await toggle.click();
+ await waitForGardenRender(page, beforeRenderCount);
+ const elapsed = await page.evaluate(start => performance.now() - start,
+ startedAt);
+ const beforeRestoreRenderCount = await page.evaluate(() =>
+ window.__fbPerf?.counts?.["render.GardenModel"] || 0);
+ await toggle.click();
+ await waitForGardenRender(page, beforeRestoreRenderCount);
+ return elapsed;
+};
+
+const ensureLayerVisible = async (page, labelText) => {
+ const toggle = page.locator("fieldset")
+ .filter({ hasText: labelText })
+ .locator(".fb-layer-toggle")
+ .first();
+ const count = await toggle.count();
+ if (count == 0) { return; }
+ if (!await toggle.isVisible()) { return; }
+ const className = await toggle.getAttribute("class");
+ if (className?.includes("green")) { return; }
+ const beforeRenderCount = await page.evaluate(() =>
+ window.__fbPerf?.counts?.["render.GardenModel"] || 0);
+ await toggle.click();
+ await waitForGardenRender(page, beforeRenderCount);
+};
+
+const collectRun = async (browser, baseUrl, session, runIndex, options) => {
+ const context = await browser.newContext({
+ viewport: options.viewport,
+ });
+ await context.addInitScript(value => {
+ window.localStorage.setItem("session", value.session);
+ window.localStorage.setItem("FB_PERF_BENCHMARK", "true");
+ window.localStorage.setItem("FPS_LOGS", "false");
+ }, { session });
+ const page = await context.newPage();
+ page.on("pageerror", error => console.error("pageerror", error));
+ page.on("console", message => {
+ if (message.type() == "error") {
+ console.error("console", message.text());
+ }
+ });
+ page.setDefaultTimeout(TIMEOUT);
+ const appUrl = `${baseUrl}/app/designer/plants?fb_perf=1`;
+ await page.goto(appUrl, { waitUntil: "domcontentloaded" });
+ await waitFor3D(page);
+ if (options.moistureMap) {
+ await ensureLayerVisible(page, "Moisture");
+ }
+ await nextPaint(page);
+ const resources = await resourceSummary(page);
+ await page.waitForTimeout(options.sampleMs);
+ const runtime = await runtimeSummary(page);
+ const perf = await page.evaluate(() => window.__fbPerf);
+ const marks = perf?.marks || {};
+ const samples = perf?.samples || {};
+ const counts = perf?.counts || {};
+ const fpsSamples = samples.fps || [];
+ const frameSamples = samples.frame_ms || [];
+ const getZSamples = samples.getZMs || [];
+ const getZIndexSamples = samples.getZIndexMs || [];
+ const soilPointFilterSamples = samples.soilPointFilterMs || [];
+ const soilSurfaceSamples = samples.soilSurfaceMs || [];
+ const soilStorageSamples = samples.soilStorageMs || [];
+ const imageTextureSetupSamples = samples.imageTextureSetupMs || [];
+ const imageWrapperSetupSamples = samples.imageWrapperSetupMs || [];
+ const spreadFrameUpdateSamples = samples.spreadFrameUpdateMs || [];
+ const moistureSurfaceSamples = samples.moistureSurfaceMs || [];
+ const moistureInstanceNodeSamples = samples.moistureInstanceNodesMs || [];
+ const togglePlantsMs = await measureLayerToggle(page, "Plants");
+ const togglePointsMs = await measureLayerToggle(page, "Points");
+ const toggleWeedsMs = await measureLayerToggle(page, "Weeds");
+ const toggleSpreadMs = await measureLayerToggle(page, "Spread");
+ const toggleFarmbotMs = await measureLayerToggle(page, "FarmBot");
+ const navPlantMs = await clickAndMeasure(
+ page,
+ `${baseUrl}/app/designer/plants?fb_perf=1`,
+ ".plant-search-item",
+ ".plant-info-panel",
+ );
+ const navPointMs = await clickAndMeasure(
+ page,
+ `${baseUrl}/app/designer/points?fb_perf=1`,
+ ".point-search-item",
+ ".point-info-panel",
+ openSoilHeightSection,
+ );
+ const navWeedMs = await clickAndMeasure(
+ page,
+ `${baseUrl}/app/designer/weeds?fb_perf=1`,
+ ".weed-search-item",
+ ".weed-info-panel",
+ );
+ await context.close();
+ return {
+ runIndex,
+ pageReadyMs: firstMark(
+ marks,
+ "three_d_map_mounted",
+ "three_d_garden_mounted",
+ ),
+ coreReadyMs: firstMark(
+ marks,
+ "three_d_core_ready",
+ "garden_model_rendered",
+ ),
+ fullReadyMs: maxMark(marks, [
+ "three_d_bot_ready",
+ "three_d_bed_ready",
+ "three_d_grid_ready",
+ "three_d_core_ready",
+ "three_d_decorations_ready",
+ "three_d_details_ready",
+ "three_d_visualizations_ready",
+ "three_d_camera_ui_ready",
+ "three_d_debug_ready",
+ "three_d_ground_ready",
+ "three_d_moisture_debug_ready",
+ "three_d_points_ready",
+ "three_d_weeds_ready",
+ ]) || marks.garden_model_mounted?.[0],
+ fpsMedian: median(fpsSamples),
+ frameP95Ms: percentile(frameSamples, 95),
+ getZBatchMs: getZSamples.reduce((total, value) => total + value, 0),
+ getZCalls: getZSamples.length,
+ getZIndexMs: getZIndexSamples
+ .reduce((total, value) => total + value, 0),
+ getZP95Ms: percentile(getZSamples, 95),
+ soilPointFilterMs: soilPointFilterSamples
+ .reduce((total, value) => total + value, 0),
+ soilSurfaceMs: soilSurfaceSamples
+ .reduce((total, value) => total + value, 0),
+ soilStorageMs: soilStorageSamples
+ .reduce((total, value) => total + value, 0),
+ soilStorageCalls: soilStorageSamples.length,
+ imageTextureSetupMs: imageTextureSetupSamples
+ .reduce((total, value) => total + value, 0),
+ imageWrapperSetupMs: imageWrapperSetupSamples
+ .reduce((total, value) => total + value, 0),
+ soilTextureRenders: counts.soilTextureRenders || 0,
+ spreadFrameUpdateMs: spreadFrameUpdateSamples
+ .reduce((total, value) => total + value, 0),
+ moistureSurfaceMs: moistureSurfaceSamples
+ .reduce((total, value) => total + value, 0),
+ moistureInstanceNodesMs: moistureInstanceNodeSamples
+ .reduce((total, value) => total + value, 0),
+ navPlantMs,
+ navPointMs,
+ navWeedMs,
+ togglePlantsMs,
+ togglePointsMs,
+ toggleWeedsMs,
+ toggleSpreadMs,
+ toggleFarmbotMs,
+ ...resources,
+ ...runtime,
+ threeDGardenMapRenders: counts["render.ThreeDGardenMap"],
+ gardenModelRenders: counts["render.GardenModel"],
+ threeDGardenRenders: counts["render.ThreeDGarden"],
+ plantInventoryItemRenders: counts["render.PlantInventoryItem"],
+ };
+};
+
+const runBenchmark = async args => {
+ const baseUrl = args["base-url"] || DEFAULT_URL;
+ const runs = Number(args.runs || 5);
+ const warmups = Number(args.warmups || 1);
+ const out = args.out || "tmp/perf/stress_1000_3d.json";
+ const lowDetail = ["1", "true"].includes(args["low-detail"]);
+ const viewport = {
+ width: Number(args.width || DEFAULT_VIEWPORT.width),
+ height: Number(args.height || DEFAULT_VIEWPORT.height),
+ };
+ const sampleMs = Number(args["sample-ms"] || DEFAULT_SAMPLE_MS);
+ const browser = await chromium.launch({
+ headless: true,
+ args: [
+ "--no-sandbox",
+ "--disable-setuid-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-frame-rate-limit",
+ "--disable-gpu-vsync",
+ "--enable-gpu",
+ ],
+ });
+ try {
+ const session = await createDemoSession(browser, baseUrl);
+ if (lowDetail) {
+ await setFarmwareEnv(baseUrl, session, "3D_lowDetail", "1");
+ }
+ if (["1", "true"].includes(args["moisture-map"])) {
+ await apiJson(baseUrl, session, "/api/web_app_config/", {
+ method: "PUT",
+ body: JSON.stringify({
+ show_sensor_readings: true,
+ show_moisture_interpolation_map: true,
+ }),
+ });
+ }
+ const measuredRuns = [];
+ for (let i = 0; i < warmups + runs; i++) {
+ const run = await collectRun(browser, baseUrl, session, i, {
+ viewport,
+ sampleMs,
+ moistureMap: ["1", "true"].includes(args["moisture-map"]),
+ });
+ console.log(`${i < warmups ? "warmup" : "run"} ${i + 1}`, run);
+ if (i >= warmups) { measuredRuns.push(run); }
+ }
+ const result = {
+ productLine: PRODUCT_LINE,
+ createdAt: new Date().toISOString(),
+ viewport,
+ sampleMs,
+ runs: measuredRuns,
+ summary: summary(measuredRuns),
+ };
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ fs.writeFileSync(out, `${JSON.stringify(result, undefined, 2)}\n`);
+ console.log(`Wrote ${out}`);
+ console.log(result.summary);
+ } finally {
+ await browser.close();
+ }
+};
+
+const getSession = async (browser, baseUrl, sessionFile) => {
+ if (sessionFile && fs.existsSync(sessionFile)) {
+ return fs.readFileSync(sessionFile, "utf8");
+ }
+ const session = await createDemoSession(browser, baseUrl);
+ if (sessionFile) {
+ fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
+ fs.writeFileSync(sessionFile, session);
+ }
+ return session;
+};
+
+const screenshot = async args => {
+ const baseUrl = args["base-url"] || DEFAULT_URL;
+ const out = args.out || "tmp/perf/three_d_garden.png";
+ const viewport = {
+ width: Number(args.width || DEFAULT_VIEWPORT.width),
+ height: Number(args.height || DEFAULT_VIEWPORT.height),
+ };
+ const browser = await chromium.launch({
+ headless: true,
+ args: [
+ "--no-sandbox",
+ "--disable-setuid-sandbox",
+ "--disable-dev-shm-usage",
+ "--enable-gpu",
+ ],
+ });
+ try {
+ const session = await getSession(browser, baseUrl, args["session-file"]);
+ const context = await browser.newContext({ viewport });
+ await context.addInitScript(value => {
+ window.localStorage.setItem("session", value.session);
+ window.localStorage.setItem("FB_PERF_BENCHMARK", "true");
+ window.localStorage.setItem("FPS_LOGS", "false");
+ }, { session });
+ const page = await context.newPage();
+ page.setDefaultTimeout(TIMEOUT);
+ await page.goto(`${baseUrl}/app/designer/plants?fb_perf=1`, {
+ waitUntil: "domcontentloaded",
+ });
+ await waitFor3D(page);
+ await page.waitForTimeout(Number(args["settle-ms"] || 3_000));
+ await nextPaint(page);
+ const canvas = page.locator(".garden-bed-3d-model canvas").first();
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ await canvas.screenshot({ path: out });
+ await context.close();
+ console.log(`Wrote ${out}`);
+ } finally {
+ await browser.close();
+ }
+};
+
+const imageDiff = async args => {
+ const before = fs.readFileSync(args.before, "base64");
+ const after = fs.readFileSync(args.after, "base64");
+ const threshold = Number(args.threshold || 3);
+ const browser = await chromium.launch({ headless: true });
+ try {
+ const page = await browser.newPage();
+ const result = await page.evaluate(async ({ before, after, threshold }) => {
+ const load = data => new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => resolve(image);
+ image.onerror = reject;
+ image.src = `data:image/png;base64,${data}`;
+ });
+ const [beforeImage, afterImage] =
+ await Promise.all([load(before), load(after)]);
+ const width = beforeImage.width;
+ const height = beforeImage.height;
+ if (width != afterImage.width || height != afterImage.height) {
+ throw new Error("Image dimensions differ.");
+ }
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext("2d");
+ context.drawImage(beforeImage, 0, 0);
+ const beforePixels =
+ context.getImageData(0, 0, width, height).data;
+ context.clearRect(0, 0, width, height);
+ context.drawImage(afterImage, 0, 0);
+ const afterPixels =
+ context.getImageData(0, 0, width, height).data;
+ let diffPixels = 0;
+ let maxDelta = 0;
+ let totalDelta = 0;
+ for (let i = 0; i < beforePixels.length; i += 4) {
+ const delta = Math.max(
+ Math.abs(beforePixels[i] - afterPixels[i]),
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1]),
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2]),
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]),
+ );
+ maxDelta = Math.max(maxDelta, delta);
+ totalDelta += delta;
+ if (delta > threshold) { diffPixels++; }
+ }
+ const pixels = width * height;
+ return {
+ width,
+ height,
+ pixels,
+ diffPixels,
+ diffRatio: diffPixels / pixels,
+ maxDelta,
+ avgDelta: totalDelta / pixels,
+ };
+ }, { before, after, threshold });
+ console.log(result);
+ if (result.diffPixels > 0) { process.exitCode = 1; }
+ } finally {
+ await browser.close();
+ }
+};
+
+const readJson = file => JSON.parse(fs.readFileSync(file, "utf8"));
+
+const compare = args => {
+ const before = readJson(args.before);
+ const after = readJson(args.after);
+ const metric = args.metric;
+ const direction = args.direction || (
+ ["fpsMedian"].includes(metric) ? "up" : "down");
+ const threshold = Number(args.threshold || 10);
+ const beforeValue = before.summary[metric];
+ const afterValue = after.summary[metric];
+ if (!Number.isFinite(beforeValue) || !Number.isFinite(afterValue)) {
+ throw new Error(`Metric ${metric} is missing from benchmark results.`);
+ }
+ const improvement = direction == "up"
+ ? 100 * (afterValue - beforeValue) / beforeValue
+ : 100 * (beforeValue - afterValue) / beforeValue;
+ console.log({
+ metric,
+ direction,
+ before: beforeValue,
+ after: afterValue,
+ improvement: `${improvement.toFixed(2)}%`,
+ threshold: `${threshold}%`,
+ });
+ if (improvement < threshold) {
+ process.exitCode = 1;
+ }
+};
+
+const main = async () => {
+ const args = parseArgs();
+ if (args.command == "compare") {
+ compare(args);
+ } else if (args.command == "screenshot") {
+ await screenshot(args);
+ } else if (args.command == "image-diff") {
+ await imageDiff(args);
+ } else {
+ await runBenchmark(args);
+ }
+};
+
+main().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/spec/controllers/api/ai/ai_controller_spec.rb b/spec/controllers/api/ai/ai_controller_spec.rb
index 3eaebaec00..8b0e81be3d 100644
--- a/spec/controllers/api/ai/ai_controller_spec.rb
+++ b/spec/controllers/api/ai/ai_controller_spec.rb
@@ -121,6 +121,14 @@ def chunk(content, done=nil)
expect(response.body).to eq("red")
end
+ it "handles docs without front matter" do
+ allow(controller).to receive(:get_page_data).and_return("content")
+
+ result = controller.send(:process_dev_docs_page_data, "url")
+
+ expect(result).to eq("")
+ end
+
it "throttles requests" do
sign_in user
payload = {
diff --git a/spec/mutations/devices/create_seed_data_spec.rb b/spec/mutations/devices/create_seed_data_spec.rb
index 53a2e04620..f2a18371e4 100644
--- a/spec/mutations/devices/create_seed_data_spec.rb
+++ b/spec/mutations/devices/create_seed_data_spec.rb
@@ -1,6 +1,24 @@
require "spec_helper"
describe Devices::CreateSeedData do
+ it "accepts stress demo product lines" do
+ expect(described_class::PRODUCT_LINES.fetch("genesis_xl_1.8_stress_250"))
+ .to eq(Devices::Seeders::GenesisXlOneEight)
+ expect(described_class::PRODUCT_LINES.fetch("genesis_xl_1.8_stress_1000"))
+ .to eq(Devices::Seeders::GenesisXlOneEight)
+ end
+
+ it "rejects stress product lines outside of demo accounts" do
+ result = described_class.run(
+ device: FactoryBot.create(:device),
+ product_line: "genesis_xl_1.8_stress_250",
+ )
+
+ expect(result.success?).to be(false)
+ expect(result.errors.message_list)
+ .to include(described_class::STRESS_DEMO_ONLY)
+ end
+
it "passes `none`" do
device = FactoryBot.create(:device)
previous_peripherals_count = device.peripherals.count
diff --git a/spec/mutations/devices/seeders/demo_account_seeder_spec.rb b/spec/mutations/devices/seeders/demo_account_seeder_spec.rb
new file mode 100644
index 0000000000..f558836aea
--- /dev/null
+++ b/spec/mutations/devices/seeders/demo_account_seeder_spec.rb
@@ -0,0 +1,19 @@
+require "spec_helper"
+
+describe Devices::Seeders::DemoAccountSeeder do
+ let(:device) { FactoryBot.create(:device) }
+
+ it "uses stress data for stress demo product lines" do
+ stress_data = instance_double(Devices::Seeders::StressData)
+
+ expect(Devices::Seeders::StressData)
+ .to receive(:new)
+ .with(device, 250)
+ .and_return(stress_data)
+ expect(stress_data).to receive(:seed!)
+
+ described_class
+ .new(device)
+ .after_product_line_seeder("genesis_xl_1.8_stress_250")
+ end
+end
diff --git a/spec/mutations/devices/seeders/stress_data_spec.rb b/spec/mutations/devices/seeders/stress_data_spec.rb
new file mode 100644
index 0000000000..3ea157970a
--- /dev/null
+++ b/spec/mutations/devices/seeders/stress_data_spec.rb
@@ -0,0 +1,33 @@
+require "spec_helper"
+
+describe Devices::Seeders::StressData do
+ let(:device) { FactoryBot.create(:device) }
+
+ it "maps stress product lines to row counts" do
+ expect(described_class.count_for("genesis_xl_1.8_stress_250")).to eq(250)
+ expect(described_class.count_for("genesis_xl_1.8_stress_500")).to eq(500)
+ expect(described_class.count_for("genesis_xl_1.8_stress_750")).to eq(750)
+ expect(described_class.count_for("genesis_xl_1.8_stress_1000")).to eq(1_000)
+ expect(described_class.count_for("genesis_xl_1.8")).to be_nil
+ end
+
+ it "adds stress resources and display settings", :slow do
+ device.web_app_config.update!(map_size_x: 5_900, map_size_y: 2_730)
+
+ described_class.new(device, 3).seed!
+
+ expect(device.plants.count).to eq(3)
+ expect(device.generic_pointers.where(name: "Soil Height").count).to eq(3)
+ expect(device.points.where(pointer_type: "Weed").count).to eq(3)
+ expect(device.images.count).to eq(3)
+ expect(device.images.all? { |image| image.attachment.attached? }).to be(true)
+ expect(device.sensor_readings.count).to eq(3)
+ expect(device.sensor_readings.pluck(:pin).uniq).to eq([59])
+ expect(device.sensor_readings.pluck(:mode).uniq)
+ .to eq([CeleryScriptSettingsBag::ANALOG])
+ expect(device.reload.max_images_count).to eq(3)
+ expect(device.web_app_config.show_sensor_readings).to be(true)
+ expect(device.web_app_config.show_moisture_interpolation_map).to be(true)
+ expect(device.web_app_config.three_d_garden).to be(true)
+ end
+end
diff --git a/spec/mutations/points/destroy_edge_cases_spec.rb b/spec/mutations/points/destroy_edge_cases_spec.rb
index 273f38f632..5f4a3c537b 100644
--- a/spec/mutations/points/destroy_edge_cases_spec.rb
+++ b/spec/mutations/points/destroy_edge_cases_spec.rb
@@ -26,8 +26,8 @@
}])
result = Points::Destroy.run(point_ids: [tool_slot.id], device: device)
errors = result.errors.message_list
- expected = "Could not delete foo tool. Item is in use by the following "\
- "sequence(s): sequence."
+ expected = "Could not delete foo tool. Item is in use by " \
+ "Sequence 'sequence'."
expect(errors).to include(expected)
end
end
diff --git a/spec/mutations/points/destroy_spec.rb b/spec/mutations/points/destroy_spec.rb
index edce38ac07..ead324dc3b 100644
--- a/spec/mutations/points/destroy_spec.rb
+++ b/spec/mutations/points/destroy_spec.rb
@@ -63,8 +63,8 @@
expect(result.errors.message_list.count).to eq(1)
expect(result.errors.message_list.first).to include(params[:name])
coords = [:x, :y, :z].map { |c| points.first[c] }.join(", ")
- expected = "Could not delete the following item(s): point at (#{coords})." \
- " Item(s) are in use by the following sequence(s): Test Case I."
+ expected = "Could not delete point at (#{coords}). Items are in use " \
+ "by Sequence 'Test Case I'."
expect(result.errors.message_list.first).to include(expected)
end
@@ -73,11 +73,147 @@
point_ids = [s.tool_slot.id]
result = Points::Destroy.run(point_ids: point_ids, device: s.device)
expect(result.success?).to be(false)
- expected = "Could not delete Scenario Tool. Item is in use by the " \
- "following sequence(s): Scenario Sequence."
+ expected = "Could not delete Scenario Tool. Item is in use by " \
+ "Sequence 'Scenario Sequence'."
expect(result.errors.message_list).to include(expected)
end
+ it "prevents deletion of points used by farm event fragments" do
+ point = FactoryBot.create(:generic_pointer,
+ device: device,
+ x: 4,
+ y: 5,
+ z: 6)
+ farm_event = FactoryBot.create(:farm_event,
+ device: device,
+ start_time: Time.now + 1.day)
+ Fragment.from_celery(device: device,
+ owner: farm_event,
+ kind: "internal_farm_event",
+ args: {},
+ body: [
+ {
+ kind: "parameter_application",
+ args: {
+ label: "location",
+ data_value: {
+ kind: "point",
+ args: {
+ pointer_type: "GenericPointer",
+ pointer_id: point.id,
+ },
+ },
+ },
+ },
+ ])
+
+ result = Points::Destroy.run(point_ids: [point.id], device: device)
+
+ expect(result.success?).to be(false)
+ expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \
+ "by FarmEvent '#{farm_event.fancy_name}'."
+ expect(result.errors.message[:whoops]).to eq(expected)
+ expect(Point.exists?(point.id)).to be(true)
+ end
+
+ it "prevents deletion of points used by regimen fragments" do
+ point = FactoryBot.create(:generic_pointer,
+ device: device,
+ x: 4,
+ y: 5,
+ z: 6)
+ regimen = FactoryBot.create(:regimen,
+ device: device,
+ name: "Point user")
+ Fragment.from_celery(device: device,
+ owner: regimen,
+ kind: "internal_regimen",
+ args: {},
+ body: [
+ {
+ kind: "parameter_application",
+ args: {
+ label: "location",
+ data_value: {
+ kind: "point",
+ args: {
+ pointer_type: "GenericPointer",
+ pointer_id: point.id,
+ },
+ },
+ },
+ },
+ ])
+
+ result = Points::Destroy.run(point_ids: [point.id], device: device)
+
+ expect(result.success?).to be(false)
+ expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \
+ "by Regimen 'Point user'."
+ expect(result.errors.message[:whoops]).to eq(expected)
+ expect(Point.exists?(point.id)).to be(true)
+ end
+
+ it "reports sequence and fragment point usage in one error" do
+ point = FactoryBot.create(:generic_pointer,
+ device: device,
+ x: 4,
+ y: 5,
+ z: 6)
+ Sequences::Create.run!(device: device,
+ name: "Sequence user",
+ body: [
+ {
+ kind: "move_absolute",
+ args: {
+ location: {
+ kind: "point",
+ args: {
+ pointer_type: "GenericPointer",
+ pointer_id: point.id,
+ },
+ },
+ speed: 100,
+ offset: {
+ kind: "coordinate",
+ args: { x: 0, y: 0, z: 0 },
+ },
+ },
+ },
+ ])
+ farm_event = FactoryBot.create(:farm_event,
+ device: device,
+ start_time: Time.now + 1.day)
+ Fragment.from_celery(device: device,
+ owner: farm_event,
+ kind: "internal_farm_event",
+ args: {},
+ body: [
+ {
+ kind: "parameter_application",
+ args: {
+ label: "location",
+ data_value: {
+ kind: "point",
+ args: {
+ pointer_type: "GenericPointer",
+ pointer_id: point.id,
+ },
+ },
+ },
+ },
+ ])
+
+ result = Points::Destroy.run(point_ids: [point.id], device: device)
+
+ expect(result.success?).to be(false)
+ expect(result.errors.message_list.count).to eq(1)
+ expected = "Could not delete point at (4.0, 5.0, 6.0). Item is in use " \
+ "by FarmEvent '#{farm_event.fancy_name}', " \
+ "Sequence 'Sequence user'."
+ expect(result.errors.message[:whoops]).to eq(expected)
+ end
+
it "handles multiple sequence dep tracking issues at deletion time" do
point = FactoryBot.create(:generic_pointer, device: device, x: 4, y: 5, z: 6)
plant = FactoryBot.create(:plant, device: device, x: 0, y: 1, z: 0)
@@ -152,9 +288,8 @@
.run(point_ids: [point.id, plant.id], device: device)
.errors
.message
- expected = "Could not delete the following item(s): plant at (0.0, 1.0," \
- " 0.0). Item(s) are in use by the following sequence(s): " \
- "Sequence A, Sequence B."
+ expected = "Could not delete plant at (0.0, 1.0, 0.0). Items are " \
+ "in use by Sequence 'Sequence A', Sequence 'Sequence B'."
expect(result[:whoops]).to eq(expected)
end
diff --git a/spec/mutations/sequences/mark_as_spec.rb b/spec/mutations/sequences/mark_as_spec.rb
index e280174f8d..908bd928ec 100644
--- a/spec/mutations/sequences/mark_as_spec.rb
+++ b/spec/mutations/sequences/mark_as_spec.rb
@@ -63,7 +63,7 @@ def sequence(body, locals = nil)
expect(weed_result.success?).to be false
error = weed_result.errors.message_list.first
expect(error).to include("Could not delete weed")
- expect(error).to include("in use by the following sequence(s): #{s.name}")
+ expect(error).to include("in use by Sequence '#{s.name}'")
end
end
@@ -94,7 +94,7 @@ def sequence(body, locals = nil)
expect(plant_result.success?).to be false
error = plant_result.errors.message_list.first
expect(error).to include("Could not delete plant")
- expect(error).to include("in use by the following sequence(s): #{s.name}")
+ expect(error).to include("in use by Sequence '#{s.name}'")
end
end