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 6effdf408..d383ede92 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 2a603cb7d..d0e02ef9d 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 1f1c0b12e..2d1873b40 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 ee188a874..bc3b6f013 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 2f73b930a..9249f99ee 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 d383ede92..9f32289b0 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 d0e02ef9d..ca624a915 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 a2dfec6c1..663b1c3e8 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 f523dc8a2..40e361181 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 8611e421e..1bf30a723 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 d6a399ba5..87165f385 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 522ce8797..134a955d8 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 598d135c2..1982d730a 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 19fdb0923..cce9b63d9 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 ac60315c6..a14f5b209 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 cce9b63d9..5acc19b11 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 633c624a9..996937574 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 aa0afaa66..e87bd8e89 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 832689c9e..17f321c56 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 55ef082b4..550ac5c9e 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 82a669e59..4ff122028 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 e9df500ad..d331dd361 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 4227f5477..0ca9e208d 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 5acc19b11..a33be9909 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 9249f99ee..56d38132c 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 d331dd361..ec8b6b608 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 a33be9909..9c86588df 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 9c86588df..9abd9d901 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 e1921a2ef..4781abb9f 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 94b58a075..2c03e0fdf 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 c7e3fa44d..57f9fc26d 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 870dabb68..16f977a79 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 65756e854..9db95616d 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 5bc550599..afeb82ea5 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 73cd892ba..21b2ffeb0 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 57f9fc26d..5f6b269a2 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 bd301e704..a9d846b60 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 532919d25..15a6d3e80 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 93f0b3899..a05e4caea 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 9db95616d..f562b1d8c 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 8af4ec0f3..ab2d918d3 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 5f6b269a2..84e36d840 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 bbb62749b..5d726a431 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 cf29edb80..5eba8346d 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 cd7fd615f..724600e5e 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 ca5d757f3..112d4bbab 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 f562b1d8c..53cc18eed 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 84e36d840..4b74cf185 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 5d726a431..ccd321d67 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 0ca9e208d..ee176d7dd 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 9abd9d901..5d8f18333 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 5d8f18333..a39e1fab8 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 },