From 736bde7d7b06023b695513d12363eb9604fe5631 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 21 Nov 2025 14:12:05 -0800 Subject: [PATCH 01/17] upgrade deps --- Gemfile.lock | 30 +++++++++++++++--------------- package.json | 32 ++++++++++++++++---------------- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6effdf408b..d383ede921 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,8 +126,8 @@ GEM logger faraday-follow_redirects (0.4.0) faraday (>= 1, < 3) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) globalid (1.3.0) activesupport (>= 6.1) google-apis-core (1.0.2) @@ -138,7 +138,7 @@ GEM mini_mime (~> 1.1) representable (~> 3.0) retriable (~> 3.1) - google-apis-iamcredentials_v1 (0.25.0) + google-apis-iamcredentials_v1 (0.26.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-storage_v1 (0.57.0) google-apis-core (>= 0.15.0, < 2.a) @@ -149,7 +149,7 @@ GEM base64 (~> 0.2) faraday (>= 1.0, < 3.a) google-cloud-errors (1.5.0) - google-cloud-storage (1.57.0) + google-cloud-storage (1.57.1) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-core (>= 0.18, < 2) @@ -159,7 +159,7 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.15.1) + googleauth (1.16.0) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -168,10 +168,10 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) hashdiff (1.2.1) - hashie (4.1.0) + hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.15.2) + json (2.16.0) jsonapi-renderer (0.2.2) jwt (3.1.2) base64 @@ -205,13 +205,13 @@ GEM marcel (1.1.0) method_source (1.1.0) mini_mime (1.1.5) - minitest (5.26.0) + minitest (5.26.2) multi_json (1.17.0) mutations (0.9.1) activesupport mutex_m (0.3.0) - net-http (0.7.0) - uri + net-http (0.8.0) + uri (>= 0.11.1) net-imap (0.5.12) date net-protocol @@ -241,14 +241,14 @@ GEM pry-rails (0.3.11) pry (>= 0.13.0) public_suffix (6.0.2) - rabbitmq_http_api_client (3.0.0) + rabbitmq_http_api_client (3.2.2) addressable (~> 2.7) faraday (~> 2.9) faraday-follow_redirects (~> 0.3) - hashie (~> 4.1) + hashie (>= 4.1) multi_json (~> 1.15) racc (1.8.1) - rack (2.2.20) + rack (2.2.21) rack-attack (6.8.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -305,7 +305,7 @@ GEM railties (>= 5.2) retriable (3.1.2) rexml (3.4.4) - rollbar (3.6.2) + rollbar (3.7.0) rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -369,7 +369,7 @@ GEM tzinfo-data (1.2025.2) tzinfo (>= 1.0.0) uber (0.1.0) - uri (1.1.0) + uri (1.1.1) valid_url (0.0.4) addressable rails diff --git a/package.json b/package.json index 2a603cb7d7..d0e02ef9d2 100644 --- a/package.json +++ b/package.json @@ -37,26 +37,26 @@ "@parcel/watcher": "2.1.0" }, "dependencies": { - "@blueprintjs/core": "6.3.2", - "@blueprintjs/select": "6.0.6", + "@blueprintjs/core": "6.3.4", + "@blueprintjs/select": "6.0.8", "@monaco-editor/react": "4.7.0", - "@parcel/transformer-sass": "2.16.0", - "@parcel/transformer-typescript-tsc": "2.16.0", + "@parcel/transformer-sass": "2.16.1", + "@parcel/transformer-typescript-tsc": "2.16.1", "@react-spring/three": "10.0.3", "@react-three/drei": "9.122.0", "@react-three/fiber": "8.18.0", "@rollbar/react": "1.0.0", "@types/lodash": "4.17.20", "@types/markdown-it": "14.1.2", - "@types/node": "24.9.2", + "@types/node": "24.10.1", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.2", + "@types/react": "19.2.6", "@types/react-color": "3.0.13", - "@types/react-dom": "19.2.2", + "@types/react-dom": "19.2.3", "@types/three": "0.181.0", "@types/ws": "8.18.1", "@xterm/xterm": "5.5.0", - "axios": "1.13.1", + "axios": "1.13.2", "bowser": "2.12.1", "browser-speech": "1.1.1", "delaunator": "5.0.1", @@ -64,15 +64,15 @@ "farmbot": "15.9.3", "fengari": "0.1.4", "fengari-web": "0.1.4", - "i18next": "25.6.0", + "i18next": "25.6.3", "lodash": "4.17.21", "markdown-it": "14.1.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", - "monaco-editor": "0.54.0", + "monaco-editor": "0.55.1", "mqtt": "5.14.1", - "npm": "11.6.2", - "parcel": "2.16.0", + "npm": "11.6.3", + "parcel": "2.16.1", "process": "0.11.10", "promise-timeout": "1.3.0", "punycode": "1.4.1", @@ -81,14 +81,14 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.9.5", + "react-router": "7.9.6", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", "rollbar": "2.26.5", "suncalc": "1.9.0", "takeme": "0.12.0", - "three": "0.181.0", + "three": "0.181.2", "typescript": "5.9.3", "url": "0.11.4" }, @@ -110,7 +110,7 @@ "eslint": "8.57.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jest": "29.0.1", + "eslint-plugin-jest": "29.2.0", "eslint-plugin-no-null": "1.0.2", "eslint-plugin-promise": "7.2.1", "eslint-plugin-react": "7.37.5", @@ -127,7 +127,7 @@ "raf": "3.4.1", "react-addons-test-utils": "15.6.2", "react-test-renderer": "18.3.1", - "sass": "1.93.3", + "sass": "1.94.2", "sass-lint": "1.13.1", "ts-jest": "29.4.5", "tslint": "5.20.1" From 7db3c9ae0707536a7e7a7231a3bbb490643e68b3 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 10 Dec 2025 14:30:50 -0800 Subject: [PATCH 02/17] add spread to 3D garden --- .../__tests__/three_d_garden_map_test.tsx | 4 +- .../map/legend/garden_map_legend.tsx | 1 - frontend/farm_designer/three_d_garden_map.tsx | 4 +- frontend/three_d_garden/garden/plants.tsx | 50 +++++++++++++++---- frontend/three_d_garden/garden_model.tsx | 2 + 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx index 1f1c0b12e9..2d1873b40b 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -131,7 +131,7 @@ describe("", () => { label: "Strawberry Plant 1", seed: 0, size: 50, - spread: 0, + spread: 30, x: 101, y: 201, }], @@ -288,7 +288,7 @@ describe("convertPlants()", () => { label: "Spinach", seed: 0, size: 50, - spread: 0, + spread: 20, x: 110, y: 201, }, diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index ee188a874a..bc3b6f013d 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -158,7 +158,6 @@ const LayerToggles = (props: LayerTogglesProps) => { submenuTitle={t("extras")} popover={} /> ; } @@ -57,19 +59,15 @@ export const ThreeDPlant = (props: ThreeDPlantProps) => { getPlantZ(plant.size), )}> {labelOnly - ? - {plant.label} - - : + : { ; }; +interface LabelPartProps { + visible: boolean; + plant: ThreeDGardenPlant; +} + +const LabelPart = (props: LabelPartProps) => + + {props.plant.label} + ; + +interface PlantPartProps extends CustomImageProps { + spreadVisible: boolean; +} + +const PlantPart = (props: PlantPartProps) => { + return + + {props.spreadVisible && + + + } + ; +}; + type MeshProps = ThreeElements["mesh"]; interface CustomImageProps extends MeshProps { url: string; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 2f73b930a9..9249f99eeb 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -104,6 +104,7 @@ export const GardenModel = (props: GardenModelProps) => { const showPoints = config.showSoilPoints || !!addPlantProps?.getConfigValue(BooleanSetting.show_points); const showWeeds = !!addPlantProps?.getConfigValue(BooleanSetting.show_weeds); + const showSpread = !!addPlantProps?.getConfigValue(BooleanSetting.show_spread); const soilPoints = filterSoilPoints({ points: props.mapPoints, config }); const soilSurface = React.useMemo(() => @@ -218,6 +219,7 @@ export const GardenModel = (props: GardenModelProps) => { Date: Wed, 10 Dec 2025 14:34:01 -0800 Subject: [PATCH 03/17] upgrade deps --- Gemfile.lock | 26 +++++++++++++------------- package.json | 34 +++++++++++++++++----------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d383ede921..9f32289b0b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,7 +39,7 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - active_model_serializers (0.10.15) + active_model_serializers (0.10.16) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) @@ -65,8 +65,8 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) amq-protocol (2.3.4) base64 (0.3.0) bcrypt (3.1.20) @@ -91,7 +91,7 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0) database_cleaner-core (2.0.1) - date (3.5.0) + date (3.5.1) declarative (0.0.20) delayed_job (4.1.13) activesupport (>= 3.0, < 9.0) @@ -118,7 +118,7 @@ GEM factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faker (3.5.2) + faker (3.5.3) i18n (>= 1.8.11, < 2) faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) @@ -140,7 +140,7 @@ GEM retriable (~> 3.1) google-apis-iamcredentials_v1 (0.26.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.57.0) + google-apis-storage_v1 (0.58.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -171,7 +171,7 @@ GEM hashie (5.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.16.0) + json (2.17.1) jsonapi-renderer (0.2.2) jwt (3.1.2) base64 @@ -206,7 +206,7 @@ GEM method_source (1.1.0) mini_mime (1.1.5) minitest (5.26.2) - multi_json (1.17.0) + multi_json (1.18.0) mutations (0.9.1) activesupport mutex_m (0.3.0) @@ -240,7 +240,7 @@ GEM method_source (~> 1.0) pry-rails (0.3.11) pry (>= 0.13.0) - public_suffix (6.0.2) + public_suffix (7.0.0) rabbitmq_http_api_client (3.2.2) addressable (~> 2.7) faraday (~> 2.9) @@ -362,7 +362,7 @@ GEM thor (1.4.0) thwait (0.2.0) e2mmap - timeout (0.4.4) + timeout (0.5.0) trailblazer-option (0.1.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -379,7 +379,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.9.1) + webrick (1.9.2) websocket-driver (0.8.0) base64 websocket-extensions (>= 0.1.0) @@ -439,7 +439,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.7p58 + ruby 3.4.7p58 BUNDLED WITH - 2.7.2 + 4.0.1 diff --git a/package.json b/package.json index d0e02ef9d2..ca624a9159 100644 --- a/package.json +++ b/package.json @@ -37,42 +37,42 @@ "@parcel/watcher": "2.1.0" }, "dependencies": { - "@blueprintjs/core": "6.3.4", - "@blueprintjs/select": "6.0.8", + "@blueprintjs/core": "6.4.1", + "@blueprintjs/select": "6.0.10", "@monaco-editor/react": "4.7.0", - "@parcel/transformer-sass": "2.16.1", - "@parcel/transformer-typescript-tsc": "2.16.1", + "@parcel/transformer-sass": "2.16.3", + "@parcel/transformer-typescript-tsc": "2.16.3", "@react-spring/three": "10.0.3", "@react-three/drei": "9.122.0", "@react-three/fiber": "8.18.0", "@rollbar/react": "1.0.0", - "@types/lodash": "4.17.20", + "@types/lodash": "4.17.21", "@types/markdown-it": "14.1.2", - "@types/node": "24.10.1", + "@types/node": "25.0.0", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.6", + "@types/react": "19.2.7", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/three": "0.181.0", "@types/ws": "8.18.1", "@xterm/xterm": "5.5.0", "axios": "1.13.2", - "bowser": "2.12.1", + "bowser": "2.13.1", "browser-speech": "1.1.1", "delaunator": "5.0.1", "events": "3.3.0", "farmbot": "15.9.3", "fengari": "0.1.4", "fengari-web": "0.1.4", - "i18next": "25.6.3", + "i18next": "25.7.2", "lodash": "4.17.21", "markdown-it": "14.1.0", "markdown-it-emoji": "3.0.0", "moment": "2.30.1", "monaco-editor": "0.55.1", "mqtt": "5.14.1", - "npm": "11.6.3", - "parcel": "2.16.1", + "npm": "11.7.0", + "parcel": "2.16.3", "process": "0.11.10", "promise-timeout": "1.3.0", "punycode": "1.4.1", @@ -81,14 +81,14 @@ "react-color": "2.19.3", "react-dom": "18.3.1", "react-redux": "9.2.0", - "react-router": "7.9.6", + "react-router": "7.10.1", "redux": "5.0.1", "redux-immutable-state-invariant": "2.1.0", "redux-thunk": "3.1.0", "rollbar": "2.26.5", "suncalc": "1.9.0", "takeme": "0.12.0", - "three": "0.181.2", + "three": "0.182.0", "typescript": "5.9.3", "url": "0.11.4" }, @@ -101,7 +101,7 @@ "@types/delaunator": "5.0.3", "@types/enzyme": "3.10.12", "@types/jest": "30.0.0", - "@types/readable-stream": "4.0.22", + "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", "@typescript-eslint/eslint-plugin": "7.15.0", "@typescript-eslint/parser": "7.15.0", @@ -110,7 +110,7 @@ "eslint": "8.57.0", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-jest": "29.2.0", + "eslint-plugin-jest": "29.2.1", "eslint-plugin-no-null": "1.0.2", "eslint-plugin-promise": "7.2.1", "eslint-plugin-react": "7.37.5", @@ -127,9 +127,9 @@ "raf": "3.4.1", "react-addons-test-utils": "15.6.2", "react-test-renderer": "18.3.1", - "sass": "1.94.2", + "sass": "1.95.1", "sass-lint": "1.13.1", - "ts-jest": "29.4.5", + "ts-jest": "29.4.6", "tslint": "5.20.1" } } From cee00c4dba1ff335ba3c645c377ad254e445af44 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 10 Dec 2025 14:59:29 -0800 Subject: [PATCH 04/17] fix ci warnings and errors --- .circleci/config.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a2dfec6c19..663b1c3e8c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,12 +27,13 @@ commands: command: | mv .circleci/circle_envs .env echo -e '\ndocker_volumes/db/pg_wal/*' >> .dockerignore + sudo docker compose build web sudo docker compose run web gem install bundler sudo docker compose run web bundle install sudo docker compose run web npm install sudo docker compose run web bundle exec rails db:create sudo docker compose run web bundle exec rails db:migrate - sudo docker compose run web rake keys:generate + sudo docker compose run web bundle exec rake keys:generate - run: name: After cache update command: | @@ -52,11 +53,11 @@ commands: - run: name: Run Ruby Tests command: | - sudo docker compose run web rspec spec --format progress --format RspecJunitFormatter --out /tmp/test-results/rspec/rspec.xml + sudo docker compose run web bundle exec rspec spec --format progress --format RspecJunitFormatter --out /tmp/test-results/rspec/rspec.xml - run: name: Check app coverage status command: | - sudo docker compose run web rake check_file_coverage:api || [ $CIRCLE_BRANCH == "staging" ] + sudo docker compose run web bundle exec rake check_file_coverage:api || [ $CIRCLE_BRANCH == "staging" ] when: always - run: name: Upload app coverage to Codecov @@ -88,13 +89,13 @@ commands: - run: name: Check frontend coverage status command: | - sudo docker compose run -e CIRCLE_SHA1="$CIRCLE_SHA1" -e CIRCLE_BRANCH="$CIRCLE_BRANCH" -e CIRCLE_PULL_REQUEST="$CIRCLE_PULL_REQUEST" web rake coverage:run || [ $CIRCLE_BRANCH == "staging" ] + sudo docker compose run -e CIRCLE_SHA1="$CIRCLE_SHA1" -e CIRCLE_BRANCH="$CIRCLE_BRANCH" -e CIRCLE_PULL_REQUEST="$CIRCLE_PULL_REQUEST" web bundle exec rake coverage:run || [ $CIRCLE_BRANCH == "staging" ] when: always - run: name: Check frontend file coverage status command: | changed=$(git diff --name-only staging...HEAD | tr '\n' ',' | sed 's/,$//') || true - sudo docker compose run -e CHANGED_FILES="$changed" web rake check_file_coverage:fe || true + sudo docker compose run -e CHANGED_FILES="$changed" web bundle exec rake check_file_coverage:fe || true when: always - run: name: Report frontend coverage to Coveralls From 86557389b503a280259646a9b61bf185ee1da48f Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 10 Dec 2025 15:32:27 -0800 Subject: [PATCH 05/17] update local setup instructions --- local_setup_instructions.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/local_setup_instructions.sh b/local_setup_instructions.sh index f523dc8a26..40e361181a 100644 --- a/local_setup_instructions.sh +++ b/local_setup_instructions.sh @@ -55,6 +55,7 @@ nano .env # Install project dependencies # ============================ +sudo docker compose build web # Install the correct version of bundler for the project sudo docker compose run web gem install bundler # Install application specific Ruby dependencies @@ -65,7 +66,7 @@ sudo docker compose run web npm install sudo docker compose run web bundle exec rails db:create db:migrate # Generate a set of *.pem files for data encryption # ⚠ SKIP THIS STEP IF UPGRADING! -sudo docker compose run web rake keys:generate +sudo docker compose run web bundle exec rake keys:generate # Run the server! 🌱 # ================== @@ -91,7 +92,7 @@ sudo docker compose up # Create the database for the app to use sudo docker compose run -e RAILS_ENV=test web bundle exec rails db:setup # Run the tests in the "test" RAILS_ENV -sudo docker compose run -e RAILS_ENV=test web rspec spec +sudo docker compose run -e RAILS_ENV=test web bundle exec rspec spec # Run user-interface unit tests (requires a large amount of RAM) sudo docker compose run web npm run test @@ -138,6 +139,7 @@ sudo docker compose run web npm run test sudo rm -rf node_modules/ # Download the latest version of the web app git pull https://github.com/FarmBot/Farmbot-Web-App.git main + sudo docker compose build web # Install Ruby gems sudo docker compose run web gem install bundler sudo docker compose run web bundle install @@ -157,9 +159,9 @@ sudo docker compose run web npm run test exit # --- end db container shell commands --- # Migrate the database - sudo docker compose run web rails db:migrate + sudo docker compose run web bundle exec rails db:migrate # Verify that parcel builds successfully - sudo docker compose run web rake assets:precompile + sudo docker compose run web bundle exec rake assets:precompile # Run the server sudo docker compose up # === END OPTIONAL UPGRADES === From e0ce0a6d699bd9d851e27fb0eff6fc355dc003de Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Wed, 10 Dec 2025 15:41:19 -0800 Subject: [PATCH 06/17] fix tests --- .../settings/fbos_settings/__tests__/timezone_row_test.tsx | 2 +- frontend/settings/firmware/__tests__/board_type_test.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx b/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx index 8611e421ed..1bf30a723f 100644 --- a/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx +++ b/frontend/settings/fbos_settings/__tests__/timezone_row_test.tsx @@ -27,7 +27,7 @@ describe("", () => { it("select timezone", () => { const p = fakeProps(); render(); - const selector = screen.getByRole("combobox"); + const selector = screen.getByRole("button", { name: "UTC" }); fireEvent.click(selector); const item = screen.getByText("America/Los_Angeles"); fireEvent.click(item); diff --git a/frontend/settings/firmware/__tests__/board_type_test.tsx b/frontend/settings/firmware/__tests__/board_type_test.tsx index d6a399ba55..87165f385b 100644 --- a/frontend/settings/firmware/__tests__/board_type_test.tsx +++ b/frontend/settings/firmware/__tests__/board_type_test.tsx @@ -61,7 +61,8 @@ describe("", () => { const p = fakeProps(); p.firmwareHardware = "arduino"; render(); - const selection = screen.getByRole("combobox"); + const selection = + screen.getByRole("button", { name: "Arduino/RAMPS (Genesis v1.2)" }); fireEvent.click(selection); const item = screen.getByText("Farmduino (Genesis v1.3)"); fireEvent.click(item); @@ -74,7 +75,7 @@ describe("", () => { it("displays boards", () => { mockFeatureBoolean = false; render(); - const selection = screen.getByRole("combobox"); + const selection = screen.getByRole("button", { name: "None" }); fireEvent.click(selection); [ { label: "Farmduino (Genesis v1.7)", value: "farmduino_k17" }, @@ -95,7 +96,7 @@ describe("", () => { it("displays more boards", () => { mockFeatureBoolean = true; render(); - const selection = screen.getByRole("combobox"); + const selection = screen.getByRole("button", { name: "None" }); fireEvent.click(selection); expect(screen.getByText("Farmduino (Express v1.2)")).toBeInTheDocument(); expect(screen.getByText("Farmduino (Genesis v1.8)")).toBeInTheDocument(); From cbee7a84cdc78e1e2e28f0b0d3591267213530a5 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 12 Dec 2025 10:26:13 -0800 Subject: [PATCH 07/17] color out-of-bounds plant spread sphere red --- frontend/__test_support__/three_d_mocks.tsx | 11 +++ .../__tests__/components_test.tsx | 12 ++++ frontend/three_d_garden/garden/plants.tsx | 68 +++++++++++++++++-- frontend/three_d_garden/garden/weed.tsx | 3 +- 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/frontend/__test_support__/three_d_mocks.tsx b/frontend/__test_support__/three_d_mocks.tsx index 522ce8797b..134a955d85 100644 --- a/frontend/__test_support__/three_d_mocks.tsx +++ b/frontend/__test_support__/three_d_mocks.tsx @@ -56,6 +56,17 @@ jest.mock("../three_d_garden/components", () => ({ // @ts-expect-error Property does not exist on type JSX.IntrinsicElements return
; }, + MeshPhongMaterial: (props: THREE.MeshPhongMaterial) => { + props.onBeforeCompile?.( + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + { uniforms: {}, vertexShader: "", fragmentShader: "" } as any, + // eslint-disable-next-line max-len + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + {} as any); + // @ts-expect-error Property does not exist on type JSX.IntrinsicElements + return
; + }, })); jest.mock("three/examples/jsm/Addons.js", () => ({ diff --git a/frontend/three_d_garden/__tests__/components_test.tsx b/frontend/three_d_garden/__tests__/components_test.tsx index 598d135c2d..1982d730a5 100644 --- a/frontend/three_d_garden/__tests__/components_test.tsx +++ b/frontend/three_d_garden/__tests__/components_test.tsx @@ -11,6 +11,7 @@ import { Group, Mesh, MeshBasicMaterial, + MeshPhongMaterial, PointLight, SpotLight, } from "../components"; @@ -93,6 +94,17 @@ describe("", () => { }); }); +describe("", () => { + const fakeProps = (): ThreeElements["meshPhongMaterial"] => ({ + name: "material", + }); + + it("adds props", () => { + const wrapper = mount(); + expect(wrapper.props().name).toEqual("material"); + }); +}); + describe("", () => { const fakeProps = (): ThreeElements["spotLight"] => ({ visible: true, diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 19fdb09232..cce9b63d9e 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -1,9 +1,13 @@ -import React from "react"; +import React, { useMemo } from "react"; import { Config } from "../config"; import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; import { Billboard, Plane, Sphere, useTexture } from "@react-three/drei"; -import { Vector3, Mesh, Group as GroupType } from "three"; -import { threeSpace, zZero as zZeroFunc } from "../helpers"; +import { Vector3, Mesh, Group as GroupType, Color } from "three"; +import { + threeSpace, + zZero, + zZero as zZeroFunc, +} from "../helpers"; import { Text } from "../elements"; import { isUndefined } from "lodash"; import { Path } from "../../internal_urls"; @@ -13,7 +17,7 @@ import { getMode } from "../../farm_designer/map/util"; import { ThreeElements, useFrame } from "@react-three/fiber"; import { getSizeAtTime } from "../../promo/plants"; import { FixedNormalMaterial } from "./fixed_normal_material"; -import { Group, MeshBasicMaterial } from "../components"; +import { Group, MeshPhongMaterial } from "../components"; export interface ThreeDGardenPlant { id?: number | undefined; @@ -62,7 +66,9 @@ export const ThreeDPlant = (props: ThreeDPlantProps) => { ? - : interface PlantPartProps extends CustomImageProps { spreadVisible: boolean; + config: Config; } const PlantPart = (props: PlantPartProps) => { + const { config } = props; + const boundsCenter = useMemo(() => + new Vector3( + config.bedXOffset - 140, + config.bedYOffset - 80, + -10000 + zZero(config), + // eslint-disable-next-line react-hooks/exhaustive-deps + ), []); + const halfSize = useMemo(() => new Vector3( + config.botSizeX / 2, + config.botSizeY / 2, + 10000, + // eslint-disable-next-line react-hooks/exhaustive-deps + ), []); return {props.spreadVisible && - { + shader.uniforms.uBoundsCenter = { value: boundsCenter }; + shader.uniforms.uHalfSize = { value: halfSize }; + shader.uniforms.uInside = { value: new Color("green") }; + shader.uniforms.uOutside = { value: new Color("red") }; + shader.vertexShader = shader.vertexShader.replace( + "#include ", + `#include + varying vec3 vWorldPosition;`, + ).replace( + "#include ", + `#include + vWorldPosition = worldPosition.xyz;`); + shader.fragmentShader = shader.fragmentShader.replace( + "#include ", + `#include + varying vec3 vWorldPosition; + uniform vec3 uBoundsCenter; + uniform vec3 uHalfSize; + uniform vec3 uInside; + uniform vec3 uOutside;`, + ).replace( + "#include ", + `vec3 p = vWorldPosition - uBoundsCenter; + bool inside = + abs(p.x) <= uHalfSize.x && + abs(p.y) <= uHalfSize.y && + abs(p.z) <= uHalfSize.z; + gl_FragColor = vec4(mix(uOutside, uInside, float(inside)), 0.35); + #include `, + ); + }} depthWrite={false} /> } ; diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index ac60315c65..a14f5b209e 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -90,9 +90,10 @@ export const WeedBase = (props: WeedBaseProps) => { scale={weedSize} renderOrder={RenderOrder.weedSpheres} args={[1, 32, 32]} - position={[0, 0, 0]}> + position={[0, 0, iconSize / 2]}> From 76caf80875786d5f0e4454ca0f0fb6be9f120aa5 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 16 Dec 2025 12:26:48 -0800 Subject: [PATCH 08/17] adjust plant spread bed boundary --- frontend/three_d_garden/garden/plants.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index cce9b63d9e..5acc19b11e 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -111,14 +111,14 @@ const PlantPart = (props: PlantPartProps) => { const { config } = props; const boundsCenter = useMemo(() => new Vector3( - config.bedXOffset - 140, - config.bedYOffset - 80, + 0, + 0, -10000 + zZero(config), // eslint-disable-next-line react-hooks/exhaustive-deps ), []); const halfSize = useMemo(() => new Vector3( - config.botSizeX / 2, - config.botSizeY / 2, + config.bedLengthOuter / 2 - 300, + config.bedWidthOuter / 2 - config.bedWallThickness, 10000, // eslint-disable-next-line react-hooks/exhaustive-deps ), []); @@ -129,7 +129,7 @@ const PlantPart = (props: PlantPartProps) => { { shader.uniforms.uBoundsCenter = { value: boundsCenter }; shader.uniforms.uHalfSize = { value: halfSize }; @@ -152,14 +152,15 @@ const PlantPart = (props: PlantPartProps) => { uniform vec3 uInside; uniform vec3 uOutside;`, ).replace( - "#include ", - `vec3 p = vWorldPosition - uBoundsCenter; - bool inside = - abs(p.x) <= uHalfSize.x && + "#include ", + `#include + vec3 p = vWorldPosition - uBoundsCenter; + bool inside = + p.x > -uHalfSize.x && abs(p.y) <= uHalfSize.y && abs(p.z) <= uHalfSize.z; - gl_FragColor = vec4(mix(uOutside, uInside, float(inside)), 0.35); - #include `, + diffuseColor.rgb = mix(uOutside, uInside, float(inside)); + `, ); }} depthWrite={false} /> From 10981c028c36399701896171210107d4ff2e8bce Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Thu, 18 Dec 2025 11:14:38 -0800 Subject: [PATCH 09/17] add spread overlap colors to 3D garden --- .../__tests__/spread_overlap_helper_test.tsx | 4 +- .../layers/spread/spread_overlap_helper.tsx | 72 +++++++++++++------ .../three_d_garden/bed/__tests__/bed_test.tsx | 2 + frontend/three_d_garden/bed/bed.tsx | 4 ++ .../__tests__/pointer_objects_test.tsx | 3 + .../bed/objects/pointer_objects.tsx | 37 ++++++---- .../garden/__tests__/plants_test.tsx | 23 ++++-- frontend/three_d_garden/garden/plants.tsx | 37 +++++++++- frontend/three_d_garden/garden_model.tsx | 5 ++ 9 files changed, 141 insertions(+), 46 deletions(-) diff --git a/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx b/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx index 633c624a95..9969375748 100644 --- a/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx +++ b/frontend/farm_designer/map/layers/spread/__tests__/spread_overlap_helper_test.tsx @@ -142,8 +142,8 @@ describe("SpreadOverlapHelper functions", () => { }); it("getContinuousColor()", () => { - expect(getContinuousColor(10, 100)).toEqual("rgba(51, 151, 0, 0.05)"); - expect(getContinuousColor(20, 100)).toEqual("rgba(102, 202, 0, 0.1)"); + expect(getContinuousColor(10, 100).string).toEqual("rgba(51, 151, 0, 0.05)"); + expect(getContinuousColor(20, 100).string).toEqual("rgba(102, 202, 0, 0.1)"); }); it("getRadius()", () => { diff --git a/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx b/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx index aa0afaa66e..e87bd8e899 100644 --- a/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx +++ b/frontend/farm_designer/map/layers/spread/spread_overlap_helper.tsx @@ -48,13 +48,13 @@ export function getContinuousColor(overlap: number, spreadRadius: number) { const r = Math.min(normalized, 255); const g = Math.min(100 + normalized, 255); // dark instead of bright green const a = Math.min(0.3, Math.round(0.5 * normalized / 510 * 100) / 100); - return `rgba(${r}, ${g}, 0, ${a})`; + return { string: `rgba(${r}, ${g}, 0, ${a})`, rgb: [r / 255, g / 255, 0] }; } else { // yellow to red const g = Math.min(255 * 2 - normalized, 255); - return `rgba(255, ${g}, 0, 0.3)`; + return { string: `rgba(255, ${g}, 0, 0.3)`, rgb: [1, g / 255, 0] }; } } else { - return "none"; + return { string: "none", rgb: [0, 1, 0] }; } } @@ -122,28 +122,56 @@ export function overlapText( } } +export interface GetSpreadRadiiProps { + activeDragSpread: number | undefined; + inactiveSpread: number; + radius: number; +} + +/** Convert spread diameter in cm to radius in mm. */ +export const getSpreadRadii = (props: GetSpreadRadiiProps) => { + return { + active: (props.activeDragSpread || 0) / 2 * 10, + inactive: (props.inactiveSpread || defaultSpreadCmDia(props.radius)) / 2 * 10, + }; +}; + +export interface GetSpreadOverlapColorProps { + spreadRadii: { active: number, inactive: number }; + activeDragXY: BotPosition | undefined; + plantXY: BotPosition; +} + +/** + * Overlap is evaluated against the inactive plant since evaluating + * against the active plant would require keeping a list of all plants + * overlapping the active plant. Therefore, the spread overlap helper + * should be thought of as a tool checking the inactive plants, not + * the plant being edited. Dragging a plant with a small spread into + * the area of a plant with large spread will illustrate this point. + */ +export const getSpreadOverlap = (props: GetSpreadOverlapColorProps) => { + const value = getOverlap(props.activeDragXY, props.plantXY, props.spreadRadii); + const color = getContinuousColor( + value, getRadius(SpreadOption.InactivePlant, props.spreadRadii)); + return { color, value }; +}; + export const SpreadOverlapHelper = (props: SpreadOverlapHelperProps) => { const { dragging, plant, activeDragXY, activeDragSpread, inactiveSpread, mapTransformProps } = props; const { radius, x, y } = plant.body; const { qx, qy } = transformXY(round(x), round(y), mapTransformProps); - const gardenCoord: BotPosition = { x: round(x), y: round(y), z: 0 }; - // Convert spread diameter in cm to radius in mm. - const spreadRadii = { - active: (activeDragSpread || 0) / 2 * 10, - inactive: (inactiveSpread || defaultSpreadCmDia(radius)) / 2 * 10, - }; - - const overlapValue = getOverlap(activeDragXY, gardenCoord, spreadRadii); - // Overlap is evaluated against the inactive plant since evaluating - // against the active plant would require keeping a list of all plants - // overlapping the active plant. Therefore, the spread overlap helper - // should be thought of as a tool checking the inactive plants, not - // the plant being edited. Dragging a plant with a small spread into - // the area of a plant with large spread will illustrate this point. - const color = getContinuousColor( - overlapValue, getRadius(SpreadOption.InactivePlant, spreadRadii)); - + const spreadRadii = getSpreadRadii({ + activeDragSpread, + inactiveSpread, + radius, + }); + const overlap = getSpreadOverlap({ + spreadRadii, + activeDragXY, + plantXY: { x: round(x), y: round(y), z: 0 }, + }); return {!dragging && // Non-active plants { cx={qx} cy={qy} r={spreadRadii.inactive} - fill={color} />} + fill={overlap.color.string} />} {props.showOverlapValues && !dragging && - overlapText(qx, qy, overlapValue, spreadRadii)} + overlapText(qx, qy, overlap.value, spreadRadii)} ; }; diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 832689c9e9..17f321c56c 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -90,6 +90,7 @@ import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import { fakePoint } from "../../../__test_support__/fake_state/resources"; import { SpecialStatus } from "farmbot"; import { BufferGeometry } from "three"; +import { ActivePositionRef } from "../objects/pointer_objects"; describe("", () => { beforeEach(() => { @@ -114,6 +115,7 @@ describe("", () => { sensors: [], sensorReadings: [], showMoistureReadings: true, + activePositionRef: { current: { x: 0, y: 0 } } as ActivePositionRef, }); it("renders bed", () => { diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 55ef082b4f..550ac5c9ec 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -31,6 +31,7 @@ import { GetWebAppConfigValue } from "../../config_storage/actions"; import { DesignerState } from "../../farm_designer/interfaces"; import { useNavigate } from "react-router"; import { + ActivePositionRef, BillboardRef, ImageRef, PointerObjects, PointerPlantRef, RadiusRef, soilClick, soilPointerMove, @@ -113,6 +114,7 @@ export interface BedProps { showMoistureReadings: boolean; sensors: TaggedSensor[]; sensorReadings: TaggedSensorReading[]; + activePositionRef: ActivePositionRef; } export const Bed = (props: BedProps) => { @@ -217,6 +219,7 @@ export const Bed = (props: BedProps) => { imageRef, xCrosshairRef, yCrosshairRef, + activePositionRef: props.activePositionRef, getZ: props.getZ, })} castShadow={true} @@ -372,6 +375,7 @@ export const Bed = (props: BedProps) => { imageRef={imageRef} xCrosshairRef={xCrosshairRef} yCrosshairRef={yCrosshairRef} + activePositionRef={props.activePositionRef} config={props.config} addPlantProps={props.addPlantProps} mapPoints={props.mapPoints} />} diff --git a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx index 82a669e595..4ff122028c 100644 --- a/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx +++ b/frontend/three_d_garden/bed/objects/__tests__/pointer_objects_test.tsx @@ -9,6 +9,7 @@ jest.mock("../../../../screen_size", () => ({ import React from "react"; import { + ActivePositionRef, BillboardRef, ImageRef, PointerObjects, PointerObjectsProps, @@ -43,6 +44,7 @@ describe("", () => { imageRef: { current: { scale: new Vector3(0, 0, 0) } } as ImageRef, xCrosshairRef: { current: { position: new Vector3(0, 0, 0) } } as XCrosshairRef, yCrosshairRef: { current: { position: new Vector3(0, 0, 0) } } as YCrosshairRef, + activePositionRef: { current: { x: 0, y: 0 } } as ActivePositionRef, }); it("renders", () => { @@ -90,6 +92,7 @@ describe("soilPointerMove()", () => { imageRef: { current: { scale: { set: jest.fn() } } } as unknown as ImageRef, xCrosshairRef: { current: { position: { set: jest.fn() } } } as unknown as XCrosshairRef, yCrosshairRef: { current: { position: { set: jest.fn() } } } as unknown as YCrosshairRef, + activePositionRef: { current: { x: 0, y: 0 } } as ActivePositionRef, }); it("updates plant position", () => { diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx index e9df500ad0..d331dd3617 100644 --- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx +++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { Group } from "../../components"; -import { Billboard, Line, Image } from "@react-three/drei"; -import { findIcon } from "../../../crops/find"; +import { Group, MeshPhongMaterial } from "../../components"; +import { Billboard, Line, Image, Sphere } from "@react-three/drei"; +import { findCrop, findIcon } from "../../../crops/find"; import { Mode } from "../../../farm_designer/map/interfaces"; import { getMode, round, xyDistance } from "../../../farm_designer/map/util"; import { isMobile } from "../../../screen_size"; @@ -38,6 +38,7 @@ export type BillboardRef = React.RefObject; export type ImageRef = React.RefObject; export type XCrosshairRef = React.RefObject; export type YCrosshairRef = React.RefObject; +export type ActivePositionRef = React.RefObject<{ x: number, y: number } | null>; interface AllRefs { pointerPlantRef: PointerPlantRef; @@ -53,6 +54,7 @@ export interface PointerObjectsProps extends AllRefs { config: Config; mapPoints: TaggedGenericPointer[]; addPlantProps: AddPlantProps; + activePositionRef: ActivePositionRef; } export const PointerObjects = (props: PointerObjectsProps) => { @@ -115,14 +117,23 @@ export const PointerObjects = (props: PointerObjectsProps) => { designer={addPlantProps.designer} usePosition={settingRadius} />} {getMode() == Mode.clickToAdd && - - - } + + + + + + + + } ; @@ -192,6 +203,7 @@ export interface SoilPointerMoveProps extends AllRefs { config: Config; addPlantProps: AddPlantProps; getZ(x: number, y: number): number; + activePositionRef: ActivePositionRef; } export const soilPointerMove = (props: SoilPointerMoveProps) => @@ -200,7 +212,7 @@ export const soilPointerMove = (props: SoilPointerMoveProps) => config, addPlantProps, pointerPlantRef, radiusRef, torusRef, billboardRef, imageRef, - xCrosshairRef, yCrosshairRef, + xCrosshairRef, yCrosshairRef, activePositionRef, } = props; const getGardenPosition = getGardenPositionFunc(config); const get3DPosition = get3DPositionFunc(config); @@ -213,6 +225,7 @@ export const soilPointerMove = (props: SoilPointerMoveProps) => const z = zZero(config) + props.getZ(gardenPosition.x, gardenPosition.y); xCrosshairRef.current?.position.set(0, y, z); yCrosshairRef.current?.position.set(x, 0, z); + activePositionRef.current = { x, y }; if (getMode() == Mode.clickToAdd) { pointerPlantRef.current.position.set(x, y, z); } diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 4227f54771..0ca9e208d7 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -4,15 +4,15 @@ interface MockRef { position: { z: number; }; } | undefined; } -const mockRef: MockRef = { +const mockRef = (): MockRef => ({ current: { scale: { set: jest.fn() }, position: { z: 0 }, } -}; +}); jest.mock("react", () => ({ ...jest.requireActual("react"), - useRef: () => mockRef, + useRef: mockRef, })); import React from "react"; @@ -27,6 +27,10 @@ import { mockDispatch } from "../../../__test_support__/fake_dispatch"; import { convertPlants } from "../../../farm_designer/three_d_garden_map"; describe("", () => { + beforeEach(() => { + location.pathname = Path.mock(Path.designer()); + }); + const fakeProps = (): ThreeDPlantProps => { const config = clone(INITIAL); const plant = fakePlant(); @@ -38,6 +42,7 @@ describe("", () => { hoveredPlant: undefined, visible: true, getZ: () => 0, + activePositionRef: { current: { x: 0, y: 0 } }, }; }; @@ -66,11 +71,18 @@ describe("", () => { p.config.labelsOnHover = false; p.labelOnly = false; p.config.light = false; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); + it("renders spread", () => { + location.pathname = Path.mock(Path.cropSearch("mint")); + const p = fakeProps(); + p.spreadVisible = true; + const { container } = render(); + expect(container).toContainHTML("sphere"); + }); + it("renders plant: not size animated", () => { const p = fakeProps(); p.config.labels = false; @@ -79,7 +91,6 @@ describe("", () => { p.config.light = false; p.config.animateSeasons = true; p.startTimeRef = undefined; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); @@ -92,7 +103,6 @@ describe("", () => { p.config.light = false; p.config.animateSeasons = true; p.startTimeRef = { current: 0 }; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); @@ -103,7 +113,6 @@ describe("", () => { p.config.labelsOnHover = false; p.labelOnly = false; p.config.light = true; - render(); const { container } = render(); expect(container).toContainHTML("avif"); }); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 5acc19b11e..a33be99096 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -4,6 +4,7 @@ import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; import { Billboard, Plane, Sphere, useTexture } from "@react-three/drei"; import { Vector3, Mesh, Group as GroupType, Color } from "three"; import { + getGardenPositionFunc, threeSpace, zZero, zZero as zZeroFunc, @@ -13,11 +14,17 @@ import { isUndefined } from "lodash"; import { Path } from "../../internal_urls"; import { useNavigate } from "react-router"; import { setPanelOpen } from "../../farm_designer/panel_header"; -import { getMode } from "../../farm_designer/map/util"; +import { getMode, round } from "../../farm_designer/map/util"; import { ThreeElements, useFrame } from "@react-three/fiber"; import { getSizeAtTime } from "../../promo/plants"; import { FixedNormalMaterial } from "./fixed_normal_material"; import { Group, MeshPhongMaterial } from "../components"; +import { + getSpreadOverlap, getSpreadRadii, +} from "../../farm_designer/map/layers/spread/spread_overlap_helper"; +import { ActivePositionRef } from "../bed/objects/pointer_objects"; +import { Mode } from "../../farm_designer/map/interfaces"; +import { findCrop } from "../../crops/find"; export interface ThreeDGardenPlant { id?: number | undefined; @@ -42,6 +49,7 @@ export interface ThreeDPlantProps { spreadVisible?: boolean; getZ(x: number, y: number): number; startTimeRef?: React.RefObject; + activePositionRef: ActivePositionRef; } export const ThreeDPlant = (props: ThreeDPlantProps) => { @@ -71,6 +79,7 @@ export const ThreeDPlant = (props: ThreeDPlantProps) => { config={config} plant={plant} billboardRef={billboardRef} + activePositionRef={props.activePositionRef} getPlantZ={getPlantZ} url={plant.icon} spreadVisible={props.spreadVisible || false} @@ -105,6 +114,7 @@ const LabelPart = (props: LabelPartProps) => interface PlantPartProps extends CustomImageProps { spreadVisible: boolean; config: Config; + activePositionRef: ActivePositionRef; } const PlantPart = (props: PlantPartProps) => { @@ -122,10 +132,31 @@ const PlantPart = (props: PlantPartProps) => { 10000, // eslint-disable-next-line react-hooks/exhaustive-deps ), []); + const spreadRadii = getSpreadRadii({ + activeDragSpread: findCrop(Path.getCropSlug()).spread, + inactiveSpread: props.plant.spread, + radius: props.plant.size / 2, + }); + const worldPos = props.activePositionRef.current || { x: -10000, y: -10000 }; + const active = getGardenPositionFunc(config)(worldPos); + const overlap = getSpreadOverlap({ + spreadRadii, + activeDragXY: { + x: round(active.x + config.bedXOffset), + y: round(active.y + config.bedYOffset), + z: 0, + }, + plantXY: { x: round(props.plant.x), y: round(props.plant.y), z: 0 }, + }); + const rgb = useMemo(() => ({ value: [0, 1, 0] }), []); + const mode = getMode(); + React.useEffect(() => { + rgb.value = mode == Mode.clickToAdd ? overlap.color.rgb : [0, 1, 0]; + }, [rgb, overlap.color.rgb, mode]); return {props.spreadVisible && - + { onBeforeCompile={(shader) => { shader.uniforms.uBoundsCenter = { value: boundsCenter }; shader.uniforms.uHalfSize = { value: halfSize }; - shader.uniforms.uInside = { value: new Color("green") }; + shader.uniforms.uInside = rgb; shader.uniforms.uOutside = { value: new Color("red") }; shader.vertexShader = shader.vertexShader.replace( "#include ", diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 9249f99eeb..56d38132ca 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -122,6 +122,8 @@ export const GardenModel = (props: GardenModelProps) => { // eslint-disable-next-line no-null/no-null const skyRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const activePositionRef = React.useRef<{ x: number, y: number }>(null); // eslint-disable-next-line no-null/no-null return { showMoistureReadings={showMoistureReadings} sensors={props.sensors || []} sensorReadings={props.sensorReadings || []} + activePositionRef={activePositionRef} addPlantProps={addPlantProps} /> {showMoistureMap && props.config.moistureDebug && { labelOnly={true} config={config} getZ={getZ} + activePositionRef={activePositionRef} hoveredPlant={hoveredPlant} />)} { spreadVisible={showSpread} config={config} hoveredPlant={hoveredPlant} + activePositionRef={activePositionRef} getZ={getZ} startTimeRef={props.startTimeRef} dispatch={dispatch} />)} From 4882e6f8171fd978a3c4ff7786aef0f64d4c4b2f Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Fri, 19 Dec 2025 10:21:26 -0800 Subject: [PATCH 10/17] add out-of-bounds visual to 3D add plant pointer --- .../bed/objects/pointer_objects.tsx | 21 ++- frontend/three_d_garden/garden/plants.tsx | 131 ++++++++++-------- 2 files changed, 92 insertions(+), 60 deletions(-) diff --git a/frontend/three_d_garden/bed/objects/pointer_objects.tsx b/frontend/three_d_garden/bed/objects/pointer_objects.tsx index d331dd3617..ec8b6b608a 100644 --- a/frontend/three_d_garden/bed/objects/pointer_objects.tsx +++ b/frontend/three_d_garden/bed/objects/pointer_objects.tsx @@ -7,7 +7,12 @@ import { getMode, round, xyDistance } from "../../../farm_designer/map/util"; import { isMobile } from "../../../screen_size"; import { HOVER_OBJECT_MODES, DRAW_POINT_MODES, RenderOrder } from "../../constants"; import { - DrawnPoint, POINT_CYLINDER_SCALE_FACTOR, WEED_IMG_SIZE_FRACTION, + DrawnPoint, + getBoundsCenter, + getHalfSize, + outOfBoundsShaderModification, + POINT_CYLINDER_SCALE_FACTOR, + WEED_IMG_SIZE_FRACTION, } from "../../garden"; import { zero as zeroFunc, @@ -21,7 +26,7 @@ import { SpecialStatus, TaggedGenericPointer } from "farmbot"; import { AddPlantProps } from "../bed"; import { DEFAULT_PLANT_RADIUS } from "../../../farm_designer/plant"; import { isUndefined, round as mathRound } from "lodash"; -import { Mesh as MeshType, Group as GroupType } from "three"; +import { Mesh as MeshType, Group as GroupType, Color } from "three"; import { Path } from "../../../internal_urls"; import { ThreeEvent } from "@react-three/fiber"; import { dropPlant } from "../../../farm_designer/map/layers/plants/plant_actions"; @@ -73,7 +78,10 @@ export const PointerObjects = (props: PointerObjectsProps) => { const gridPreview = mapPoints .filter(p => p.specialStatus == SpecialStatus.DIRTY && p.body.meta.gridId) .length > 0; - + // eslint-disable-next-line react-hooks/exhaustive-deps + const boundsCenter = React.useMemo(getBoundsCenter(config), []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const halfSize = React.useMemo(getHalfSize(config), []); return HOVER_OBJECT_MODES.includes(getMode()) && !isMobile() && @@ -131,6 +139,13 @@ export const PointerObjects = (props: PointerObjectsProps) => { color={"white"} transparent={true} opacity={0.4} + onBeforeCompile={(shader) => { + shader.uniforms.uBoundsCenter = { value: boundsCenter }; + shader.uniforms.uHalfSize = { value: halfSize }; + shader.uniforms.uInside = { value: new Color("white") }; + shader.uniforms.uOutside = { value: new Color("red") }; + outOfBoundsShaderModification(shader); + }} depthWrite={false} /> } diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index a33be99096..9c86588df2 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -1,8 +1,14 @@ -import React, { useMemo } from "react"; +import React from "react"; import { Config } from "../config"; import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; import { Billboard, Plane, Sphere, useTexture } from "@react-three/drei"; -import { Vector3, Mesh, Group as GroupType, Color } from "three"; +import { + Vector3, + Mesh, + Group as GroupType, + Color, + WebGLProgramParametersWithUniforms, +} from "three"; import { getGardenPositionFunc, threeSpace, @@ -119,40 +125,33 @@ interface PlantPartProps extends CustomImageProps { const PlantPart = (props: PlantPartProps) => { const { config } = props; - const boundsCenter = useMemo(() => - new Vector3( - 0, - 0, - -10000 + zZero(config), - // eslint-disable-next-line react-hooks/exhaustive-deps - ), []); - const halfSize = useMemo(() => new Vector3( - config.bedLengthOuter / 2 - 300, - config.bedWidthOuter / 2 - config.bedWallThickness, - 10000, - // eslint-disable-next-line react-hooks/exhaustive-deps - ), []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const boundsCenter = React.useMemo(getBoundsCenter(config), []); + // eslint-disable-next-line react-hooks/exhaustive-deps + const halfSize = React.useMemo(getHalfSize(config), []); const spreadRadii = getSpreadRadii({ activeDragSpread: findCrop(Path.getCropSlug()).spread, inactiveSpread: props.plant.spread, radius: props.plant.size / 2, }); - const worldPos = props.activePositionRef.current || { x: -10000, y: -10000 }; - const active = getGardenPositionFunc(config)(worldPos); - const overlap = getSpreadOverlap({ - spreadRadii, - activeDragXY: { - x: round(active.x + config.bedXOffset), - y: round(active.y + config.bedYOffset), - z: 0, - }, - plantXY: { x: round(props.plant.x), y: round(props.plant.y), z: 0 }, + + const rgb = React.useMemo(() => ({ value: [0, 1, 0] }), []); + useFrame(() => { + const worldPos = props.activePositionRef.current || { x: -10000, y: -10000 }; + const active = getGardenPositionFunc(config)(worldPos); + const overlap = getSpreadOverlap({ + spreadRadii, + activeDragXY: { + x: round(active.x + config.bedXOffset), + y: round(active.y + config.bedYOffset), + z: 0, + }, + plantXY: { x: round(props.plant.x), y: round(props.plant.y), z: 0 }, + }); + const color = props.plant.id ? overlap.color.rgb : [1, 1, 1]; + rgb.value = getMode() == Mode.clickToAdd ? color : [0, 1, 0]; }); - const rgb = useMemo(() => ({ value: [0, 1, 0] }), []); - const mode = getMode(); - React.useEffect(() => { - rgb.value = mode == Mode.clickToAdd ? overlap.color.rgb : [0, 1, 0]; - }, [rgb, overlap.color.rgb, mode]); + return {props.spreadVisible && @@ -166,39 +165,57 @@ const PlantPart = (props: PlantPartProps) => { shader.uniforms.uHalfSize = { value: halfSize }; shader.uniforms.uInside = rgb; shader.uniforms.uOutside = { value: new Color("red") }; - shader.vertexShader = shader.vertexShader.replace( - "#include ", - `#include - varying vec3 vWorldPosition;`, - ).replace( - "#include ", - `#include - vWorldPosition = worldPosition.xyz;`); - shader.fragmentShader = shader.fragmentShader.replace( - "#include ", - `#include - varying vec3 vWorldPosition; - uniform vec3 uBoundsCenter; - uniform vec3 uHalfSize; - uniform vec3 uInside; - uniform vec3 uOutside;`, - ).replace( - "#include ", - `#include - vec3 p = vWorldPosition - uBoundsCenter; - bool inside = - p.x > -uHalfSize.x && - abs(p.y) <= uHalfSize.y && - abs(p.z) <= uHalfSize.z; - diffuseColor.rgb = mix(uOutside, uInside, float(inside)); - `, - ); + outOfBoundsShaderModification(shader); }} depthWrite={false} /> } ; }; +export const getBoundsCenter = (config: Config) => () => + new Vector3( + 0, + 0, + -10000 + zZero(config), + ); + +export const getHalfSize = (config: Config) => () => new Vector3( + config.bedLengthOuter / 2 - 300, + config.bedWidthOuter / 2 - config.bedWallThickness, + 10000, +); + +export const outOfBoundsShaderModification = + (shader: WebGLProgramParametersWithUniforms) => { + shader.vertexShader = shader.vertexShader.replace( + "#include ", + `#include + varying vec3 vWorldPosition;`, + ).replace( + "#include ", + `#include + vWorldPosition = worldPosition.xyz;`); + shader.fragmentShader = shader.fragmentShader.replace( + "#include ", + `#include + varying vec3 vWorldPosition; + uniform vec3 uBoundsCenter; + uniform vec3 uHalfSize; + uniform vec3 uInside; + uniform vec3 uOutside;`, + ).replace( + "#include ", + `#include + vec3 p = vWorldPosition - uBoundsCenter; + bool inside = + p.x > -uHalfSize.x && + abs(p.y) <= uHalfSize.y && + abs(p.z) <= uHalfSize.z; + diffuseColor.rgb = mix(uOutside, uInside, float(inside)); + `, + ); + }; + type MeshProps = ThreeElements["mesh"]; interface CustomImageProps extends MeshProps { url: string; From 13318122a48ab17adf1990e2bd367ee9d0a7f3f7 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 23 Dec 2025 10:17:29 -0800 Subject: [PATCH 11/17] always show new plant 3D spread --- frontend/three_d_garden/garden/plants.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 9c86588df2..9abd9d9010 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -154,7 +154,7 @@ const PlantPart = (props: PlantPartProps) => { return - {props.spreadVisible && + {(props.spreadVisible || !props.plant.id) && Date: Tue, 23 Dec 2025 10:17:56 -0800 Subject: [PATCH 12/17] keep grid popup open --- frontend/plants/__tests__/crop_info_test.tsx | 2 +- frontend/plants/crop_info.tsx | 217 ++++++++++--------- frontend/plants/grid/plant_grid.tsx | 8 +- 3 files changed, 119 insertions(+), 108 deletions(-) diff --git a/frontend/plants/__tests__/crop_info_test.tsx b/frontend/plants/__tests__/crop_info_test.tsx index e1921a2ef2..4781abb9ff 100644 --- a/frontend/plants/__tests__/crop_info_test.tsx +++ b/frontend/plants/__tests__/crop_info_test.tsx @@ -130,7 +130,7 @@ describe("", () => { it("updates curves", () => { location.pathname = Path.mock(Path.cropSearch("mint")); const p = fakeProps(); - mount(); + mount(); expect(p.dispatch).toHaveBeenCalledWith({ type: Actions.SET_CROP_WATER_CURVE_ID, payload: undefined, }); diff --git a/frontend/plants/crop_info.tsx b/frontend/plants/crop_info.tsx index 94b58a0752..2c03e0fdfa 100644 --- a/frontend/plants/crop_info.tsx +++ b/frontend/plants/crop_info.tsx @@ -267,122 +267,127 @@ export function mapStateToProps(props: Everything): CropInfoProps { }; } -export class RawCropInfo extends React.Component { - componentDidMount() { - this.selectMostUsedCurves(Path.getCropSlug()); - } +export const RawCropInfo = (props: CropInfoProps) => { + const [gridOpen, setGridOpen] = React.useState(false); + const toggleOpen = () => setGridOpen(!gridOpen); + React.useEffect(() => { + selectMostUsedCurves(Path.getCropSlug()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - selectMostUsedCurves = (slug: string) => { + const selectMostUsedCurves = (slug: string) => { const findCurve = findMostUsedCurveForCrop({ - plants: this.props.plants, - curves: this.props.curves, + plants: props.plants, + curves: props.curves, slug: slug, }); [CurveType.water, CurveType.spread, CurveType.height].map(curveType => { const id = findCurve(curveType)?.body.id; - changeCurve(this.props.dispatch)(id, curveType); + changeCurve(props.dispatch)(id, curveType); }); }; - render() { - const { dispatch, designer } = this.props; - const slug = Path.getCropSlug(); - const crop = findCrop(slug); - const image = findImage(slug); - const panelName = "crop-info"; - return - !designer.cropSearchQuery && dispatch({ - type: Actions.SEARCH_QUERY_CHANGE, - payload: startCase(slug).toLowerCase(), - })} - style={{ - background: `linear-gradient( + const { dispatch, designer } = props; + const slug = Path.getCropSlug(); + const crop = findCrop(slug); + const image = findImage(slug); + const panelName = "crop-info"; + return + !designer.cropSearchQuery && dispatch({ + type: Actions.SEARCH_QUERY_CHANGE, + payload: startCase(slug).toLowerCase(), + })} + style={{ + background: `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url(${image})` - }} - description={crop.description}> - - - + {t("grid")} - } - content={
- -
} /> -
- - - -
- - dispatch({ - type: Actions.SET_CROP_STAGE, - payload: ddi.value, - })} /> -
-
- - dispatch({ - type: Actions.SET_CROP_PLANTED_AT, - payload: e.currentTarget.value, - }) - } /> -
-
- - dispatch({ - type: Actions.SET_CROP_RADIUS, - payload: parseInt(e.currentTarget.value), - }) - } /> -
- - -
-
; - } -} + }} + description={crop.description}> + + + + {t("grid")} + } + content={
+ +
} /> +
+ + + +
+ + dispatch({ + type: Actions.SET_CROP_STAGE, + payload: ddi.value, + })} /> +
+
+ + dispatch({ + type: Actions.SET_CROP_PLANTED_AT, + payload: e.currentTarget.value, + }) + } /> +
+
+ + dispatch({ + type: Actions.SET_CROP_RADIUS, + payload: parseInt(e.currentTarget.value), + }) + } /> +
+ + +
+
; +}; export const CropInfo = connect(mapStateToProps)(RawCropInfo); // eslint-disable-next-line import/no-default-export diff --git a/frontend/plants/grid/plant_grid.tsx b/frontend/plants/grid/plant_grid.tsx index c7e3fa44d0..57f9fc26dc 100644 --- a/frontend/plants/grid/plant_grid.tsx +++ b/frontend/plants/grid/plant_grid.tsx @@ -117,7 +117,8 @@ export class PlantGrid extends React.Component { revertPreview = ({ setStatus }: { setStatus: boolean }) => () => this.props.dispatch(stashGrid(this.state.gridId)) - .then(() => setStatus && this.setState({ status: "clean" })); + .then(() => setStatus && + this.setState({ status: "clean" }, this.props.close)); saveGrid = () => this.props.dispatch(saveGrid(this.state.gridId)) @@ -137,6 +138,11 @@ export class PlantGrid extends React.Component { switch (this.state.status) { case "clean": return
+ + {t("Close")} + From 9b8e5354c403c8ccbb725cbe2926b12c799d6cbf Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 23 Dec 2025 11:22:45 -0800 Subject: [PATCH 13/17] fix grid popup bugs and add logging --- .../plants/grid/__tests__/grid_input_test.tsx | 4 --- .../plants/grid/__tests__/plant_grid_test.tsx | 20 +++++++---- frontend/plants/grid/grid_input.tsx | 8 ++--- frontend/plants/grid/interfaces.ts | 1 - frontend/plants/grid/plant_grid.tsx | 33 +++++++++++-------- 5 files changed, 35 insertions(+), 31 deletions(-) diff --git a/frontend/plants/grid/__tests__/grid_input_test.tsx b/frontend/plants/grid/__tests__/grid_input_test.tsx index 870dabb68d..16f977a79d 100644 --- a/frontend/plants/grid/__tests__/grid_input_test.tsx +++ b/frontend/plants/grid/__tests__/grid_input_test.tsx @@ -19,7 +19,6 @@ describe("", () => { grid: testGridInputs(), xy_swap: true, onChange: jest.fn(() => jest.fn()), - preview: jest.fn(), botPosition: { x: undefined, y: undefined, z: undefined }, onUseCurrentPosition: jest.fn(), }); @@ -59,7 +58,6 @@ describe("", () => { gridKey: "numPlantsH", xy_swap: false, onChange: jest.fn(), - preview: jest.fn(), grid: testGridInputs(), }); @@ -88,7 +86,6 @@ describe("", () => { const wrapper = shallow(); wrapper.find("input").first().simulate("blur"); expect(p.onChange).toHaveBeenCalledWith(p.gridKey, 2); - expect(p.preview).toHaveBeenCalled(); expect(wrapper.find("input").props().value).toEqual("2"); }); @@ -100,7 +97,6 @@ describe("", () => { }); expect(wrapper.find("input").props().value).toEqual(""); wrapper.find("input").first().simulate("blur"); - expect(p.preview).not.toHaveBeenCalled(); expect(wrapper.find("input").props().value).toEqual("2"); }); }); diff --git a/frontend/plants/grid/__tests__/plant_grid_test.tsx b/frontend/plants/grid/__tests__/plant_grid_test.tsx index 65756e8544..9db95616d3 100644 --- a/frontend/plants/grid/__tests__/plant_grid_test.tsx +++ b/frontend/plants/grid/__tests__/plant_grid_test.tsx @@ -9,7 +9,7 @@ jest.mock("../../../api/crud", () => ({ import React from "react"; import { mount } from "enzyme"; -import { PlantGrid } from "../plant_grid"; +import { MAX_N, PlantGrid } from "../plant_grid"; import { saveGrid, stashGrid } from "../thunks"; import { error, success } from "../../../toast/toast"; import { PlantGridProps } from "../interfaces"; @@ -18,6 +18,10 @@ import { Actions } from "../../../constants"; import { fakeDesignerState } from "../../../__test_support__/fake_designer_state"; describe("", () => { + beforeEach(() => { + console.debug = jest.fn(); + }); + const fakeProps = (): PlantGridProps => ({ xy_swap: true, openfarm_slug: "beets", @@ -90,35 +94,35 @@ describe("", () => { expect(stashGrid).toHaveBeenCalledWith(wrapper.state().gridId); }); - it("prevents creation of grids with > 100 plants", () => { + it(`prevents creation of grids with > ${MAX_N} plants`, () => { const props = fakeProps(); const wrapper = mount(); wrapper.setState({ grid: { ...wrapper.state().grid, - numPlantsH: 10, + numPlantsH: MAX_N / 10, numPlantsV: 11 } }); wrapper.instance().performPreview()(); expect(error).toHaveBeenCalledWith( - "Please make a grid with less than 100 plants"); + `Please make a grid with less than ${MAX_N} plants`); }); - it("prevents creation of grids with > 100 points", () => { + it(`prevents creation of grids with > ${MAX_N} points`, () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); wrapper.setState({ grid: { ...wrapper.state().grid, - numPlantsH: 10, + numPlantsH: MAX_N / 10, numPlantsV: 11 } }); wrapper.instance().performPreview()(); expect(error).toHaveBeenCalledWith( - "Please make a grid with less than 100 points"); + `Please make a grid with less than ${MAX_N} points`); }); it("doesn't perform preview", () => { @@ -175,6 +179,8 @@ describe("", () => { const wrapper = mount(); wrapper.instance().onChange("numPlantsH", 6); expect(wrapper.state().grid.numPlantsH).toEqual(6); + wrapper.instance().onChange("numPlantsH", 6); + expect(wrapper.state().grid.numPlantsH).toEqual(6); }); it("handles data changes: starting coordinates", () => { diff --git a/frontend/plants/grid/grid_input.tsx b/frontend/plants/grid/grid_input.tsx index 5bc550599c..afeb82ea5d 100644 --- a/frontend/plants/grid/grid_input.tsx +++ b/frontend/plants/grid/grid_input.tsx @@ -24,7 +24,7 @@ export const getLabel = ( }; export function InputCell(props: InputCellProps) { - const { gridKey, onChange, grid, preview } = props; + const { gridKey, onChange, grid } = props; const [value, setValue] = React.useState("" + grid[gridKey]); return
{ const number = parseInt(value, 10); !isNaN(number) && onChange(gridKey, number); - isNaN(number) - ? setValue("" + grid[gridKey]) - : preview(); + isNaN(number) && setValue("" + grid[gridKey]); }} onChange={e => setValue(e.currentTarget.value)} />
; @@ -76,14 +74,12 @@ export function GridInput(props: GridInputProps) { xy_swap={props.xy_swap} gridKey={left} onChange={props.onChange} - preview={props.preview} grid={props.grid} /> )}
; diff --git a/frontend/plants/grid/interfaces.ts b/frontend/plants/grid/interfaces.ts index 73cd892ba6..21b2ffeb0b 100644 --- a/frontend/plants/grid/interfaces.ts +++ b/frontend/plants/grid/interfaces.ts @@ -52,7 +52,6 @@ interface GridInputPropsBase { grid: PlantGridData; xy_swap: boolean; onChange(key: PlantGridKey, value: number): void; - preview(): void; } export interface GridInputProps extends GridInputPropsBase { diff --git a/frontend/plants/grid/plant_grid.tsx b/frontend/plants/grid/plant_grid.tsx index 57f9fc26dc..5f6b269a2b 100644 --- a/frontend/plants/grid/plant_grid.tsx +++ b/frontend/plants/grid/plant_grid.tsx @@ -17,6 +17,8 @@ import { Actions } from "../../constants"; import { round } from "lodash"; import { Collapse } from "@blueprintjs/core"; +export const MAX_N = 200; + export class PlantGrid extends React.Component { state: PlantGridState = { grid: this.initGridState, @@ -48,6 +50,7 @@ export class PlantGrid extends React.Component { } onChange = (key: PlantGridKey, val: number) => { + if (this.state.grid[key] == val) { return; } const grid = { ...this.state.grid, [key]: val }; ["startX", "startY"].includes(key) && this.props.dispatch({ @@ -79,27 +82,29 @@ export class PlantGrid extends React.Component { get outdated() { return this.getKey() != this.state.previous; } get dirty() { return this.state.status === "dirty"; } - componentDidUpdate = () => { - if (this.dirty && this.outdated) { - this.performPreview()(); - } - }; - componentWillUnmount() { this.dirty && this.props.dispatch(stashGrid(this.state.gridId)); this.props.dispatch(showCameraViewPoints(undefined)); } + consoleLog = (action: string, start: number) => + console.debug(`${action} plant grid in ${performance.now() - start} ms`); + performPreview = (force = false) => () => { if (!this.state.autoPreview && !force) { return; } + console.debug("performPreview"); + const startRevertPreview = performance.now(); this.revertPreview({ setStatus: false })(); - if (this.plantCount > 100) { - error(t("Please make a grid with less than 100 {{ itemType }}", - { itemType: this.props.openfarm_slug ? t("plants") : t("points") })); + this.consoleLog("Reverted", startRevertPreview); + if (this.plantCount > MAX_N) { + error(t("Please make a grid with less than {{ n }} {{ itemType }}", { + n: MAX_N, + itemType: this.props.openfarm_slug ? t("plants") : t("points"), + })); return; } - + const startInitPlantGrid = performance.now(); const plants = initPlantGrid({ grid: this.state.grid, openfarm_slug: this.props.openfarm_slug, @@ -111,7 +116,10 @@ export class PlantGrid extends React.Component { meta: this.props.meta, designer: this.props.designer, }); + this.consoleLog("Generated", startInitPlantGrid); + const startDispatch = performance.now(); plants.map(p => this.props.dispatch(init("Point", p))); + this.consoleLog("Dispatched", startDispatch); this.setState({ status: "dirty", previous: this.getKey() }); }; @@ -186,8 +194,7 @@ export class PlantGrid extends React.Component { grid={this.state.grid} botPosition={this.props.botPosition} onChange={this.onChange} - onUseCurrentPosition={this.onUseCurrentPosition} - preview={this.performPreview()} /> + onUseCurrentPosition={this.onUseCurrentPosition} /> this.setState({ offsetPacking: !this.state.offsetPacking, @@ -212,7 +219,7 @@ export class PlantGrid extends React.Component { toggleValue={this.state.autoPreview} toggleAction={() => { const enabled = this.state.autoPreview; - if (!enabled) { this.performPreview(true); } + if (!enabled) { this.performPreview(true)(); } this.setState({ autoPreview: !enabled }); }} title={t("automatically update preview")} /> From 06941b9b5c06a14eef782b6201a4dfda93e3e0c7 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 23 Dec 2025 13:52:35 -0800 Subject: [PATCH 14/17] speed up grid actions --- frontend/api/__tests__/crud_test.ts | 15 +++++++- frontend/api/crud.ts | 13 +++++++ frontend/constants.ts | 1 + .../plants/grid/__tests__/plant_grid_test.tsx | 35 ++++++++++--------- frontend/plants/grid/__tests__/thunks_test.ts | 12 ++++--- frontend/plants/grid/plant_grid.tsx | 4 +-- frontend/plants/grid/thunks.ts | 10 ++++-- frontend/resources/__tests__/reducer_test.ts | 11 ++++++ frontend/resources/reducer.ts | 7 ++++ 9 files changed, 81 insertions(+), 27 deletions(-) diff --git a/frontend/api/__tests__/crud_test.ts b/frontend/api/__tests__/crud_test.ts index bd301e7046..a9d846b600 100644 --- a/frontend/api/__tests__/crud_test.ts +++ b/frontend/api/__tests__/crud_test.ts @@ -1,6 +1,8 @@ -import { urlFor } from "../crud"; +import { batchInitDirty, urlFor } from "../crud"; import { API } from "../api"; import { ResourceName } from "farmbot"; +import { fakePlant } from "../../__test_support__/fake_state/resources"; +import { Actions } from "../../constants"; describe("urlFor()", () => { API.setBaseUrl(""); @@ -10,3 +12,14 @@ describe("urlFor()", () => { .toThrow(/NewResourceWithoutURLHandler/); }); }); + +describe("batchInitDirty()", () => { + it("inits", () => { + const { body } = fakePlant(); + expect(batchInitDirty("Point", [body])) + .toEqual({ + type: Actions.BATCH_INIT, + payload: [expect.objectContaining({ body })], + }); + }); +}); diff --git a/frontend/api/crud.ts b/frontend/api/crud.ts index 532919d25e..15a6d3e803 100644 --- a/frontend/api/crud.ts +++ b/frontend/api/crud.ts @@ -88,6 +88,19 @@ export function init(kind: T["kind"], return { type: Actions.INIT_RESOURCE, payload: resource }; } +export const batchInitDirty = + (kind: T["kind"], bodies: T["body"][]) => { + const resources = bodies.map(body => { + const resource = arrayUnwrap(newTaggedResource(kind, body)); + resource.specialStatus = SpecialStatus.DIRTY; + return resource; + }); + return { + type: Actions.BATCH_INIT, + payload: resources, + }; + }; + /** Initialize and save a new resource, returning the `id`. * If you don't need the `id` returned, use `initSave` instead. */ diff --git a/frontend/constants.ts b/frontend/constants.ts index 93f0b3899e..a05e4caea5 100644 --- a/frontend/constants.ts +++ b/frontend/constants.ts @@ -2446,6 +2446,7 @@ export enum Actions { // Resources DESTROY_RESOURCE_START = "DESTROY_RESOURCE_START", DESTROY_RESOURCE_OK = "DESTROY_RESOURCE_OK", + BATCH_DESTROY_RESOURCE_OK = "BATCH_DESTROY_RESOURCE_OK", INIT_RESOURCE = "INIT_RESOURCE", BATCH_INIT = "BATCH_INIT", SAVE_RESOURCE_OK = "SAVE_RESOURCE_OK", diff --git a/frontend/plants/grid/__tests__/plant_grid_test.tsx b/frontend/plants/grid/__tests__/plant_grid_test.tsx index 9db95616d3..f562b1d8c7 100644 --- a/frontend/plants/grid/__tests__/plant_grid_test.tsx +++ b/frontend/plants/grid/__tests__/plant_grid_test.tsx @@ -4,7 +4,7 @@ jest.mock("../thunks", () => ({ })); jest.mock("../../../api/crud", () => ({ - init: jest.fn(), + batchInitDirty: jest.fn(), })); import React from "react"; @@ -13,7 +13,7 @@ import { MAX_N, PlantGrid } from "../plant_grid"; import { saveGrid, stashGrid } from "../thunks"; import { error, success } from "../../../toast/toast"; import { PlantGridProps } from "../interfaces"; -import { init } from "../../../api/crud"; +import { batchInitDirty } from "../../../api/crud"; import { Actions } from "../../../constants"; import { fakeDesignerState } from "../../../__test_support__/fake_designer_state"; @@ -39,7 +39,7 @@ describe("", () => { const previewButton = el.find("a.preview-button"); expect(previewButton.text()).toContain("Preview"); previewButton.simulate("click"); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); // After clicking PREVIEW, there should be two buttons. const cancel = el.find("a.cancel-button"); @@ -56,7 +56,7 @@ describe("", () => { const previewButton = wrapper.find("a.preview-button"); expect(previewButton.text()).toContain("Preview"); previewButton.simulate("click"); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); wrapper.setState({ offsetPacking: true }); const cancel = wrapper.find("a.cancel-button"); const update = wrapper.find("a.update-button"); @@ -139,7 +139,7 @@ describe("", () => { }); wrapper.instance().performPreview()(); expect(error).not.toHaveBeenCalled(); - expect(init).not.toHaveBeenCalled(); + expect(batchInitDirty).not.toHaveBeenCalled(); }); it("performs preview", () => { @@ -156,14 +156,15 @@ describe("", () => { const wrapper = mount(); wrapper.instance().performPreview()(); expect(error).not.toHaveBeenCalled(); - expect(init).toHaveBeenCalledTimes(6); - expect(init).toHaveBeenCalledWith("Point", expect.objectContaining({ - plant_stage: "planted", - planted_at: "2020-01-20T20:00:00.000Z", - water_curve_id: 1, - spread_curve_id: 2, - height_curve_id: 3, - })); + expect(batchInitDirty).toHaveBeenCalledTimes(1); + expect(batchInitDirty).toHaveBeenCalledWith("Point", + expect.arrayContaining([expect.objectContaining({ + plant_stage: "planted", + planted_at: "2020-01-20T20:00:00.000Z", + water_curve_id: 1, + spread_curve_id: 2, + height_curve_id: 3, + })])); }); it("discards unsaved changes", () => { @@ -211,7 +212,7 @@ describe("", () => { .first().simulate("click"); expect(wrapper.state().offsetPacking).toBeTruthy(); expect(wrapper.state().grid.spacingH).toEqual(217); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("toggles packing method off", () => { @@ -223,7 +224,7 @@ describe("", () => { .first().simulate("click"); expect(wrapper.state().offsetPacking).toBeFalsy(); expect(wrapper.state().grid.spacingH).toEqual(250); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("toggles camera view on", () => { @@ -238,7 +239,7 @@ describe("", () => { payload: wrapper.state().gridId, }); expect(wrapper.state().cameraView).toBeTruthy(); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("toggles camera view off", () => { @@ -253,7 +254,7 @@ describe("", () => { payload: undefined, }); expect(wrapper.state().cameraView).toBeFalsy(); - expect(init).toHaveBeenCalledTimes(6); + expect(batchInitDirty).toHaveBeenCalledTimes(1); }); it("toggles auto-preview off", () => { diff --git a/frontend/plants/grid/__tests__/thunks_test.ts b/frontend/plants/grid/__tests__/thunks_test.ts index 8af4ec0f30..ab2d918d3e 100644 --- a/frontend/plants/grid/__tests__/thunks_test.ts +++ b/frontend/plants/grid/__tests__/thunks_test.ts @@ -1,7 +1,6 @@ const mockSaveAllReturnValue = { mock: "yep" }; jest.mock("../../../api/crud", () => ({ saveAll: jest.fn(() => mockSaveAllReturnValue), - destroy: jest.fn() })); import { saveGrid, stashGrid } from "../thunks"; @@ -10,7 +9,8 @@ import { } from "../../../__test_support__/resource_index_builder"; import { fakePlant } from "../../../__test_support__/fake_state/resources"; import { fakeState } from "../../../__test_support__/fake_state"; -import { saveAll, destroy } from "../../../api/crud"; +import { saveAll } from "../../../api/crud"; +import { Actions } from "../../../constants"; const GRID_ID = "1234567"; const PLANT = fakePlant(); @@ -33,7 +33,11 @@ describe("stashGrid", () => { const thunk = stashGrid(GRID_ID); const state = fakeState(); state.resources = buildResourceIndex([PLANT]); - thunk(jest.fn, jest.fn(() => state)); - expect(destroy).toHaveBeenLastCalledWith(PLANT.uuid, true); + const dispatch = jest.fn(); + thunk(dispatch, jest.fn(() => state)); + expect(dispatch).toHaveBeenLastCalledWith({ + type: Actions.BATCH_DESTROY_RESOURCE_OK, + payload: [PLANT], + }); }); }); diff --git a/frontend/plants/grid/plant_grid.tsx b/frontend/plants/grid/plant_grid.tsx index 5f6b269a2b..84e36d8402 100644 --- a/frontend/plants/grid/plant_grid.tsx +++ b/frontend/plants/grid/plant_grid.tsx @@ -5,7 +5,7 @@ import { PlantGridState, } from "./interfaces"; import { initPlantGrid } from "./generate_grid"; -import { init } from "../../api/crud"; +import { batchInitDirty } from "../../api/crud"; import { uuid } from "farmbot"; import { saveGrid, stashGrid } from "./thunks"; import { error, success } from "../../toast/toast"; @@ -118,7 +118,7 @@ export class PlantGrid extends React.Component { }); this.consoleLog("Generated", startInitPlantGrid); const startDispatch = performance.now(); - plants.map(p => this.props.dispatch(init("Point", p))); + this.props.dispatch(batchInitDirty("Point", plants)); this.consoleLog("Dispatched", startDispatch); this.setState({ status: "dirty", previous: this.getKey() }); }; diff --git a/frontend/plants/grid/thunks.ts b/frontend/plants/grid/thunks.ts index bbb62749b0..5d726a431d 100644 --- a/frontend/plants/grid/thunks.ts +++ b/frontend/plants/grid/thunks.ts @@ -2,7 +2,8 @@ import { GetState } from "../../redux/interfaces"; import { ResourceIndex } from "../../resources/interfaces"; import { selectAllActivePoints } from "../../resources/selectors"; import { TaggedPoint } from "farmbot"; -import { destroy, saveAll } from "../../api/crud"; +import { saveAll } from "../../api/crud"; +import { Actions } from "../../constants"; const filterByGridId = (gridId: string) => (p: TaggedPoint) => p.body.meta["gridId"] === gridId; @@ -24,7 +25,10 @@ export function saveGrid(gridId: string) { export function stashGrid(gridId: string) { return function (dispatch: Function, getState: GetState) { const plants = findPlantByGridId(getState().resources.index, gridId); - const all = plants.map((x): Promise<{}> => dispatch(destroy(x.uuid, true))); - return Promise.all(all); + dispatch({ + type: Actions.BATCH_DESTROY_RESOURCE_OK, + payload: plants, + }); + return Promise.resolve({}); }; } diff --git a/frontend/resources/__tests__/reducer_test.ts b/frontend/resources/__tests__/reducer_test.ts index cf29edb802..5eba8346d9 100644 --- a/frontend/resources/__tests__/reducer_test.ts +++ b/frontend/resources/__tests__/reducer_test.ts @@ -196,6 +196,17 @@ describe("resource reducer", () => { .map((kind: ResourceName) => testResourceDestroy(kind)); }); + it("destroys resources", () => { + const state = fakeState().resources; + const plant = fakePlant(); + const action = { + type: Actions.BATCH_DESTROY_RESOURCE_OK, + payload: [plant], + }; + const newState = resourceReducer(state, action); + expect(newState.index.references[plant.uuid]).toEqual(undefined); + }); + it("toggles folder open state", () => { const folder = fakeFolder(); folder.body.id = 1; diff --git a/frontend/resources/reducer.ts b/frontend/resources/reducer.ts index cd7fd615fe..724600e5e2 100644 --- a/frontend/resources/reducer.ts +++ b/frontend/resources/reducer.ts @@ -159,6 +159,13 @@ export const resourceReducer = folderIndexer(payload, s.index); return s; }) + .add(Actions.BATCH_DESTROY_RESOURCE_OK, (s, { payload }) => { + payload.map(p => { + indexRemove(s.index, p); + folderIndexer(p, s.index); + }); + return s; + }) .add(Actions._RESOURCE_NO, (s, { payload }) => { merge(findByUuid(s.index, payload.uuid), payload); mutateSpecialStatus(payload.uuid, s.index, payload.statusBeforeError); From 88e3bd8f7ab4d8f43be7028258359c39a9b50019 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 23 Dec 2025 14:35:27 -0800 Subject: [PATCH 15/17] remove grid auto-preview toggle --- frontend/plants/__tests__/add_plant_test.tsx | 4 +- .../plants/grid/__tests__/plant_grid_test.tsx | 45 ++++++------------- frontend/plants/grid/plant_grid.tsx | 33 ++++++-------- frontend/plants/grid/thunks.ts | 2 +- 4 files changed, 31 insertions(+), 53 deletions(-) diff --git a/frontend/plants/__tests__/add_plant_test.tsx b/frontend/plants/__tests__/add_plant_test.tsx index ca5d757f34..112d4bbab5 100644 --- a/frontend/plants/__tests__/add_plant_test.tsx +++ b/frontend/plants/__tests__/add_plant_test.tsx @@ -12,6 +12,7 @@ import { } from "../../__test_support__/fake_state/resources"; import { Path } from "../../internal_urls"; import { fakeDesignerState } from "../../__test_support__/fake_designer_state"; +import { mockDispatch } from "../../__test_support__/fake_dispatch"; describe("", () => { const fakeProps = (): AddPlantProps => { @@ -27,10 +28,9 @@ describe("", () => { it("renders", () => { location.pathname = Path.mock(Path.cropSearch("mint/add")); const p = fakeProps(); - p.dispatch = jest.fn(x => x(jest.fn())); + p.dispatch = mockDispatch(jest.fn(), fakeState); const wrapper = mount(); expect(wrapper.text()).toContain("Mint"); - expect(wrapper.text()).toContain("Preview"); const img = wrapper.find("img"); expect(img).toBeDefined(); expect(img.props().src).toEqual("/crops/icons/mint.avif"); diff --git a/frontend/plants/grid/__tests__/plant_grid_test.tsx b/frontend/plants/grid/__tests__/plant_grid_test.tsx index f562b1d8c7..53cc18eed9 100644 --- a/frontend/plants/grid/__tests__/plant_grid_test.tsx +++ b/frontend/plants/grid/__tests__/plant_grid_test.tsx @@ -35,13 +35,7 @@ describe("", () => { it("renders", () => { const p = fakeProps(); const el = mount(); - // Upon load, there should be one button. - const previewButton = el.find("a.preview-button"); - expect(previewButton.text()).toContain("Preview"); - previewButton.simulate("click"); expect(batchInitDirty).toHaveBeenCalledTimes(1); - - // After clicking PREVIEW, there should be two buttons. const cancel = el.find("a.cancel-button"); const save = el.find("a.save-button"); expect(cancel.text()).toContain("Cancel"); @@ -52,10 +46,6 @@ describe("", () => { it("renders update button", () => { const p = fakeProps(); const wrapper = mount(); - wrapper.setState({ autoPreview: false }); - const previewButton = wrapper.find("a.preview-button"); - expect(previewButton.text()).toContain("Preview"); - previewButton.simulate("click"); expect(batchInitDirty).toHaveBeenCalledTimes(1); wrapper.setState({ offsetPacking: true }); const cancel = wrapper.find("a.cancel-button"); @@ -129,6 +119,7 @@ describe("", () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); + jest.clearAllMocks(); wrapper.setState({ autoPreview: false, grid: { @@ -154,6 +145,7 @@ describe("", () => { designer.cropHeightCurveId = 3; p.designer = designer; const wrapper = mount(); + jest.clearAllMocks(); wrapper.instance().performPreview()(); expect(error).not.toHaveBeenCalled(); expect(batchInitDirty).toHaveBeenCalledTimes(1); @@ -207,6 +199,7 @@ describe("", () => { it("toggles packing method on", () => { const p = fakeProps(); const wrapper = mount(); + jest.clearAllMocks(); expect(wrapper.state().offsetPacking).toBeFalsy(); wrapper.find('[title="toggle packing method"]') .first().simulate("click"); @@ -218,6 +211,7 @@ describe("", () => { it("toggles packing method off", () => { const p = fakeProps(); const wrapper = mount(); + jest.clearAllMocks(); wrapper.setState({ offsetPacking: true }); expect(wrapper.state().offsetPacking).toBeTruthy(); wrapper.find('[title="toggle packing method"]') @@ -231,6 +225,7 @@ describe("", () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); + jest.clearAllMocks(); expect(wrapper.state().cameraView).toBeFalsy(); wrapper.find('[title="show camera view area"]') .first().simulate("click"); @@ -246,6 +241,7 @@ describe("", () => { const p = fakeProps(); p.openfarm_slug = undefined; const wrapper = mount(); + jest.clearAllMocks(); wrapper.setState({ cameraView: true }); wrapper.find('[title="show camera view area"]') .first().simulate("click"); @@ -257,32 +253,19 @@ describe("", () => { expect(batchInitDirty).toHaveBeenCalledTimes(1); }); - it("toggles auto-preview off", () => { - const p = fakeProps(); - const wrapper = mount(); - wrapper.setState({ autoPreview: true }); - wrapper.find('[title="automatically update preview"]') - .first().simulate("click"); - expect(wrapper.state().autoPreview).toBeFalsy(); - }); - - it("toggles auto-preview on", () => { - const p = fakeProps(); - const wrapper = mount(); - wrapper.setState({ autoPreview: false }); - wrapper.find('[title="automatically update preview"]') - .first().simulate("click"); - expect(wrapper.state().autoPreview).toBeTruthy(); - }); - it("collapses", () => { const p = fakeProps(); p.collapsible = true; const wrapper = mount(); + jest.clearAllMocks(); + expect(wrapper.state().isOpen).toBeFalsy(); + const chevronDown = wrapper.find("i").first(); + expect(chevronDown.hasClass("fa-chevron-down")).toBeTruthy(); + chevronDown.simulate("click"); expect(wrapper.state().isOpen).toBeTruthy(); - const chevron = wrapper.find("i").first(); - expect(chevron.hasClass("fa-chevron-up")).toBeTruthy(); - chevron.simulate("click"); + const chevronUp = wrapper.find("i").first(); + expect(chevronUp.hasClass("fa-chevron-up")).toBeTruthy(); + chevronUp.simulate("click"); expect(wrapper.state().isOpen).toBeFalsy(); }); }); diff --git a/frontend/plants/grid/plant_grid.tsx b/frontend/plants/grid/plant_grid.tsx index 84e36d8402..4b74cf1851 100644 --- a/frontend/plants/grid/plant_grid.tsx +++ b/frontend/plants/grid/plant_grid.tsx @@ -28,7 +28,7 @@ export class PlantGrid extends React.Component { cameraView: false, previous: "", autoPreview: true, - isOpen: true, + isOpen: false, }; get initGridState() { @@ -82,10 +82,18 @@ export class PlantGrid extends React.Component { get outdated() { return this.getKey() != this.state.previous; } get dirty() { return this.state.status === "dirty"; } - componentWillUnmount() { + componentDidMount() { + !this.props.collapsible && this.performPreview()(); + } + + unmount = () => { this.dirty && this.props.dispatch(stashGrid(this.state.gridId)); this.props.dispatch(showCameraViewPoints(undefined)); + }; + + componentWillUnmount() { + this.unmount(); } consoleLog = (action: string, start: number) => @@ -151,11 +159,6 @@ export class PlantGrid extends React.Component { onClick={this.props.close}> {t("Close")} - - {t("Preview")} -
; case "dirty": return
@@ -183,7 +186,10 @@ export class PlantGrid extends React.Component { return
{this.props.collapsible && this.setState({ isOpen: !this.state.isOpen })} />} + onClick={() => { + !this.state.isOpen ? this.performPreview()() : this.unmount(); + this.setState({ isOpen: !this.state.isOpen }); + }} />}

{t("Add Grid or Row")}

{ this.setState({ cameraView: !this.state.cameraView }, this.performPreview()); }} />} -
- - { - const enabled = this.state.autoPreview; - if (!enabled) { this.performPreview(true)(); } - this.setState({ autoPreview: !enabled }); - }} - title={t("automatically update preview")} /> -
; diff --git a/frontend/plants/grid/thunks.ts b/frontend/plants/grid/thunks.ts index 5d726a431d..ccd321d677 100644 --- a/frontend/plants/grid/thunks.ts +++ b/frontend/plants/grid/thunks.ts @@ -29,6 +29,6 @@ export function stashGrid(gridId: string) { type: Actions.BATCH_DESTROY_RESOURCE_OK, payload: plants, }); - return Promise.resolve({}); + return Promise.all([]); }; } From 8daee82e1d2eb0a018a66cbee84ccbeab7bae3bb Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 30 Dec 2025 15:07:50 -0800 Subject: [PATCH 16/17] add 3d spread helpers to plant edit --- .../garden/__tests__/plants_test.tsx | 20 ++++++++++++++ frontend/three_d_garden/garden/plants.tsx | 27 ++++++++++++++----- frontend/three_d_garden/garden_model.tsx | 2 ++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 0ca9e208d7..ee176d7dda 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -35,6 +35,9 @@ describe("", () => { const config = clone(INITIAL); const plant = fakePlant(); plant.body.name = "Beet"; + plant.body.id = 1; + const otherPlant = fakePlant(); + otherPlant.body.id = 2; return { plant: convertPlants(config, [plant])[0], i: 0, @@ -43,6 +46,7 @@ describe("", () => { visible: true, getZ: () => 0, activePositionRef: { current: { x: 0, y: 0 } }, + plants: convertPlants(config, [plant, otherPlant]), }; }; @@ -83,6 +87,22 @@ describe("", () => { expect(container).toContainHTML("sphere"); }); + it("renders spread: edit plant mode", () => { + location.pathname = Path.mock(Path.plants("1")); + const p = fakeProps(); + p.spreadVisible = false; + const { container } = render(); + expect(container).toContainHTML("sphere"); + }); + + it("renders spread: edit plant mode without plant", () => { + location.pathname = Path.mock(Path.plants("999999")); + const p = fakeProps(); + p.spreadVisible = false; + const { container } = render(); + expect(container).toContainHTML("sphere"); + }); + it("renders plant: not size animated", () => { const p = fakeProps(); p.config.labels = false; diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 9abd9d9010..5d8f183336 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -56,6 +56,7 @@ export interface ThreeDPlantProps { getZ(x: number, y: number): number; startTimeRef?: React.RefObject; activePositionRef: ActivePositionRef; + plants: ThreeDGardenPlant[]; } export const ThreeDPlant = (props: ThreeDPlantProps) => { @@ -83,6 +84,7 @@ export const ThreeDPlant = (props: ThreeDPlantProps) => { : { const { config } = props; // eslint-disable-next-line react-hooks/exhaustive-deps const boundsCenter = React.useMemo(getBoundsCenter(config), []); + const editPlantMode = + Path.getSlug(Path.designer()) == "plants" && Path.lastChunkIsNum(); + const plantId = parseInt(Path.getSlug(Path.plants())); + const currentPlant = + props.plants.filter(p => p.id == plantId)[0] as ThreeDGardenPlant | undefined; // eslint-disable-next-line react-hooks/exhaustive-deps const halfSize = React.useMemo(getHalfSize(config), []); const spreadRadii = getSpreadRadii({ - activeDragSpread: findCrop(Path.getCropSlug()).spread, + activeDragSpread: editPlantMode + ? currentPlant?.spread + : findCrop(Path.getCropSlug()).spread, inactiveSpread: props.plant.spread, radius: props.plant.size / 2, }); @@ -138,7 +148,10 @@ const PlantPart = (props: PlantPartProps) => { const rgb = React.useMemo(() => ({ value: [0, 1, 0] }), []); useFrame(() => { const worldPos = props.activePositionRef.current || { x: -10000, y: -10000 }; - const active = getGardenPositionFunc(config)(worldPos); + const activePointer = getGardenPositionFunc(config)(worldPos); + const active = editPlantMode + ? { x: currentPlant?.x || -10000, y: currentPlant?.y || -10000 } + : activePointer; const overlap = getSpreadOverlap({ spreadRadii, activeDragXY: { @@ -148,13 +161,15 @@ const PlantPart = (props: PlantPartProps) => { }, plantXY: { x: round(props.plant.x), y: round(props.plant.y), z: 0 }, }); - const color = props.plant.id ? overlap.color.rgb : [1, 1, 1]; - rgb.value = getMode() == Mode.clickToAdd ? color : [0, 1, 0]; + const color = (props.plant.id && (plantId != props.plant.id)) + ? overlap.color.rgb + : [1, 1, 1]; + const clickToAddMode = getMode() == Mode.clickToAdd; + rgb.value = (clickToAddMode || editPlantMode) ? color : [0, 1, 0]; }); - return - {(props.spreadVisible || !props.plant.id) && + {(props.spreadVisible || !props.plant.id || editPlantMode) && { {threeDPlants.map((plant, i) => { {threeDPlants.map((plant, i) => Date: Wed, 31 Dec 2025 10:19:51 -0800 Subject: [PATCH 17/17] fix 3d plant edit spread helper overlap position bug --- frontend/three_d_garden/garden/plants.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 5d8f183336..a39e1fab8b 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -150,13 +150,19 @@ const PlantPart = (props: PlantPartProps) => { const worldPos = props.activePositionRef.current || { x: -10000, y: -10000 }; const activePointer = getGardenPositionFunc(config)(worldPos); const active = editPlantMode - ? { x: currentPlant?.x || -10000, y: currentPlant?.y || -10000 } - : activePointer; + ? { + x: currentPlant?.x || -10000, + y: currentPlant?.y || -10000, + } + : { + x: activePointer.x + config.bedXOffset, + y: activePointer.y + config.bedYOffset, + }; const overlap = getSpreadOverlap({ spreadRadii, activeDragXY: { - x: round(active.x + config.bedXOffset), - y: round(active.y + config.bedYOffset), + x: round(active.x), + y: round(active.y), z: 0, }, plantXY: { x: round(props.plant.x), y: round(props.plant.y), z: 0 },