From 1c1ac0c434348b64befe08adddf8a1fe27c4d283 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 2 Jun 2026 11:10:20 -0700 Subject: [PATCH 01/18] upgrade deps --- Gemfile.lock | 10 +++++----- bun.lock | 42 +++++++++++++++++++++--------------------- package.json | 12 ++++++------ 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7a023ed79..72b2295ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -111,7 +111,7 @@ GEM database_cleaner-active_record (2.2.2) activerecord (>= 5.a) database_cleaner-core (~> 2.0) - database_cleaner-core (2.0.1) + database_cleaner-core (2.1.0) date (3.5.1) declarative (0.0.20) delayed_job (4.1.13) @@ -148,7 +148,7 @@ GEM logger faraday-follow_redirects (0.5.0) faraday (>= 1, < 3) - faraday-net_http (3.4.3) + faraday-net_http (3.4.4) net-http (~> 0.5) globalid (1.3.0) activesupport (>= 6.1) @@ -162,7 +162,7 @@ GEM retriable (~> 3.1) google-apis-iamcredentials_v1 (0.27.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.62.0) + google-apis-storage_v1 (0.63.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) @@ -283,7 +283,7 @@ GEM reline (>= 0.6.0) pry-rails (0.3.11) pry (>= 0.13.0) - psych (5.3.1) + psych (5.4.0) date stringio public_suffix (7.0.5) @@ -383,7 +383,7 @@ GEM rspec-support (3.13.7) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.86.2) + rubocop (1.87.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) diff --git a/bun.lock b/bun.lock index 4e2c707aa..011ec55a1 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.15", + "@types/react": "19.2.16", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", @@ -44,9 +44,9 @@ "promise-timeout": "1.3.0", "punycode": "2.3.1", "querystring-es3": "0.2.1", - "react": "19.2.6", + "react": "19.2.7", "react-color": "2.19.3", - "react-dom": "19.2.6", + "react-dom": "19.2.7", "react-redux": "9.3.0", "react-router": "7.16.0", "redux": "5.0.1", @@ -71,8 +71,8 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", "eslint": "10.4.1", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", @@ -95,7 +95,7 @@ "postcss": "8.5.15", "postcss-scss": "4.0.9", "raf": "3.4.1", - "react-test-renderer": "19.2.6", + "react-test-renderer": "19.2.7", "sass": "1.100.0", "sass-lint": "1.13.1", "stylelint": "17.12.0", @@ -486,7 +486,7 @@ "@types/promise-timeout": ["@types/promise-timeout@1.3.3", "", {}, "sha512-gqmIw/4R1F1bqY5hWWZP0YE66iy6KkIu0tICpOLdXBuyHOAaSy9bNvwWHTJxyYHLozkieHM3Ej9GrYA6nuQPMA=="], - "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + "@types/react": ["@types/react@19.2.16", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w=="], "@types/react-color": ["@types/react-color@3.0.13", "", { "dependencies": { "@types/reactcss": "*" }, "peerDependencies": { "@types/react": "*" } }, "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg=="], @@ -526,25 +526,25 @@ "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/type-utils": "8.60.0", "@typescript-eslint/utils": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.0", "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.1", "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0" } }, "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1" } }, "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0", "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.60.0", "", {}, "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.60.1", "", {}, "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.0", "@typescript-eslint/tsconfig-utils": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.1", "@typescript-eslint/tsconfig-utils": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.0", "", { "dependencies": { "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], @@ -1790,15 +1790,15 @@ "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": "cli.js" }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-color": ["react-color@2.19.3", "", { "dependencies": { "@icons/material": "^0.2.4", "lodash": "^4.17.15", "lodash-es": "^4.17.15", "material-colors": "^1.2.1", "prop-types": "^15.5.10", "reactcss": "^1.2.0", "tinycolor2": "^1.4.1" }, "peerDependencies": { "react": "*" } }, "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], - "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], + "react-is": ["react-is@19.2.7", "", {}, "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A=="], "react-is-18": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], @@ -1810,7 +1810,7 @@ "react-router": ["react-router@7.16.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A=="], - "react-test-renderer": ["react-test-renderer@19.2.6", "", { "dependencies": { "react-is": "^19.2.6", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw=="], + "react-test-renderer": ["react-test-renderer@19.2.7", "", { "dependencies": { "react-is": "^19.2.7", "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-U4TyPDJ9MsC8rFimXuJum8w40aPc9kbOZYO8Pc2/4A884i8hwJsMNA/JNyuOc/f2/37wHvk7HjpVl1V4re7Dig=="], "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], diff --git a/package.json b/package.json index 79a9932a0..108f2137f 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@types/markdown-it": "14.1.2", "@types/markdown-it-emoji": "3.0.1", "@types/promise-timeout": "1.3.3", - "@types/react": "19.2.15", + "@types/react": "19.2.16", "@types/react-color": "3.0.13", "@types/react-dom": "19.2.3", "@types/react-test-renderer": "19.1.0", @@ -77,9 +77,9 @@ "promise-timeout": "1.3.0", "punycode": "2.3.1", "querystring-es3": "0.2.1", - "react": "19.2.6", + "react": "19.2.7", "react-color": "2.19.3", - "react-dom": "19.2.6", + "react-dom": "19.2.7", "react-redux": "9.3.0", "react-router": "7.16.0", "redux": "5.0.1", @@ -104,8 +104,8 @@ "@types/jest": "30.0.0", "@types/readable-stream": "4.0.23", "@types/suncalc": "1.9.2", - "@typescript-eslint/eslint-plugin": "8.60.0", - "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/eslint-plugin": "8.60.1", + "@typescript-eslint/parser": "8.60.1", "eslint": "10.4.1", "eslint-plugin-eslint-comments": "3.2.0", "eslint-plugin-import": "2.32.0", @@ -128,7 +128,7 @@ "postcss": "8.5.15", "postcss-scss": "4.0.9", "raf": "3.4.1", - "react-test-renderer": "19.2.6", + "react-test-renderer": "19.2.7", "sass": "1.100.0", "sass-lint": "1.13.1", "stylelint": "17.12.0", From 738c6d5d471e8b9f8ec1dccfdf84312724e95061 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 4 Jun 2026 21:08:43 -0700 Subject: [PATCH 02/18] add plant, point, weed, slot, location, utm, camera, and electronics box popups --- .../css/farm_designer/three_d_garden.scss | 140 ++- .../farm_designer/__tests__/index_test.tsx | 12 + .../__tests__/three_d_garden_map_test.tsx | 1 + frontend/farm_designer/index.tsx | 13 + frontend/farm_designer/interfaces.ts | 14 +- frontend/farm_designer/state_to_props.ts | 20 +- frontend/farm_designer/three_d_garden_map.tsx | 35 +- .../__tests__/garden_model_test.tsx | 312 +++++++ frontend/three_d_garden/bed/bed.tsx | 87 +- .../three_d_garden/bot/__tests__/bot_test.tsx | 54 +- frontend/three_d_garden/bot/bot.tsx | 87 +- .../__tests__/electronics_box_test.tsx | 40 +- .../bot/components/__tests__/tools_test.tsx | 99 ++- .../bot/components/cable_carriers.tsx | 2 +- .../bot/components/camera_view.tsx | 2 +- .../bot/components/electronics_box.tsx | 70 +- .../bot/components/gantry_beam.tsx | 2 +- .../bot/components/tool_slot_position.ts | 75 ++ .../three_d_garden/bot/components/tools.tsx | 191 ++-- .../bot/components/watering_animations.tsx | 2 +- frontend/three_d_garden/bot/positioning.ts | 35 + .../garden/__tests__/plant_instances_test.tsx | 65 ++ .../garden/__tests__/plants_test.tsx | 76 ++ .../garden/__tests__/point_test.tsx | 56 ++ .../garden/__tests__/weed_test.tsx | 87 +- .../three_d_garden/garden/plant_instances.tsx | 37 +- frontend/three_d_garden/garden/plants.tsx | 18 +- frontend/three_d_garden/garden/point.tsx | 42 +- frontend/three_d_garden/garden/weed.tsx | 64 +- frontend/three_d_garden/garden_model.tsx | 550 +++++++++++- frontend/three_d_garden/index.tsx | 35 + frontend/three_d_garden/selection.tsx | 6 + .../selection/__tests__/selection_test.tsx | 826 ++++++++++++++++++ frontend/three_d_garden/selection/layer.tsx | 141 +++ frontend/three_d_garden/selection/overlay.tsx | 75 ++ .../selection/popup_controls.tsx | 494 +++++++++++ frontend/three_d_garden/selection/popups.tsx | 104 +++ frontend/three_d_garden/selection/props.ts | 46 + frontend/three_d_garden/selection/resolve.ts | 351 ++++++++ frontend/three_d_garden/selection/routes.ts | 90 ++ frontend/three_d_garden/selection_types.ts | 22 + 41 files changed, 4262 insertions(+), 216 deletions(-) create mode 100644 frontend/three_d_garden/bot/components/tool_slot_position.ts create mode 100644 frontend/three_d_garden/bot/positioning.ts create mode 100644 frontend/three_d_garden/selection.tsx create mode 100644 frontend/three_d_garden/selection/__tests__/selection_test.tsx create mode 100644 frontend/three_d_garden/selection/layer.tsx create mode 100644 frontend/three_d_garden/selection/overlay.tsx create mode 100644 frontend/three_d_garden/selection/popup_controls.tsx create mode 100644 frontend/three_d_garden/selection/popups.tsx create mode 100644 frontend/three_d_garden/selection/props.ts create mode 100644 frontend/three_d_garden/selection/resolve.ts create mode 100644 frontend/three_d_garden/selection/routes.ts create mode 100644 frontend/three_d_garden/selection_types.ts diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index 1edb7937b..96dc44455 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -53,15 +53,147 @@ height: 100vh; cursor: grab; - &:active { - cursor: grabbing; - } - @media screen and (max-width: 768px) { width: 100vw; } } + .three-d-object-popup-wrapper { + pointer-events: none; + } + + .three-d-object-popup { + width: min(30rem, calc(100vw - 2rem)); + padding: 1rem; + color: var(--text-color); + cursor: default; + pointer-events: auto; + background: var(--main-bg); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + box-shadow: var(--box-shadow); + backdrop-filter: var(--blur); + opacity: 0; + transform: translateY(calc(-50% + 0.75rem)) scale(0.98); + transform-origin: bottom center; + transition: + opacity 180ms ease, + transform 180ms ease; + + &.visible { + opacity: 1; + transform: translateY(-50%) scale(1); + } + + .object-popup-header { + align-items: center; + + h3 { + margin: 0; + overflow: hidden; + font-family: $inknut; + font-size: 2rem; + font-weight: bold; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .object-popup-button-cluster { + align-items: center; + justify-self: end; + + .point-color-input { + display: grid; + padding-right: 0.5rem; + place-items: center; + + .saucer { + margin: 0; + } + } + } + + .object-popup-content { + label { + margin-top: 0; + font-size: 1rem; + } + + .go-button-axes-wrapper { + margin: 0; + justify-self: end; + } + + .object-popup-location-row { + align-items: end; + } + + .object-popup-coordinate-inputs { + align-items: end; + min-width: 0; + + > div { + min-width: 0; + } + + input { + width: 100%; + } + } + + .point-color-input { + align-self: end; + } + + .object-popup-mounted-tool-row { + align-items: center; + } + + .object-popup-trail-row, + .object-popup-camera-row { + align-items: center; + + button { + margin: 0; + float: none; + justify-self: end; + } + } + + .object-popup-tool-verification-row { + .tool-verification-status { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + + p { + margin: 0; + } + + button { + margin: 0; + float: none; + } + } + } + + .object-popup-electronics-controls { + .row { + align-items: center; + } + + button:not(.bp6-button) { + margin: 0; + float: none; + justify-self: end; + } + } + } + } + .three-d-load-progress { position: absolute; left: 50%; diff --git a/frontend/farm_designer/__tests__/index_test.tsx b/frontend/farm_designer/__tests__/index_test.tsx index bc15cfb2f..3a4934559 100644 --- a/frontend/farm_designer/__tests__/index_test.tsx +++ b/frontend/farm_designer/__tests__/index_test.tsx @@ -26,6 +26,7 @@ import { } from "../../__test_support__/fake_bot_data"; import { WebAppConfig } from "farmbot/dist/resources/configs/web_app"; import { Path } from "../../internal_urls"; +import { NavigationContext } from "../../routes_helpers"; import * as mapLegend from "../map/legend/garden_map_legend"; import * as gardenMap from "../map/garden_map"; import { GardenMapLegendProps } from "../map/interfaces"; @@ -95,6 +96,7 @@ describe("", () => { logs: [], deviceTarget: "", sourceFbosConfig: () => ({ value: 1, consistent: true }), + env: {}, farmwareEnvs: [], curves: [], }); @@ -215,6 +217,16 @@ describe("", () => { const { container } = render(); expect(container.innerHTML).toContain("three-d-garden"); }); + + it("navigates from context", () => { + const navigate = jest.fn(); + const ref = React.createRef(); + render( + + ); + ref.current?.navigate(Path.tools()); + expect(navigate).toHaveBeenCalledWith(Path.tools()); + }); }); describe("getDefaultAxisLength()", () => { 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 b97ae7d41..7945d8cb9 100644 --- a/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx +++ b/frontend/farm_designer/__tests__/three_d_garden_map_test.tsx @@ -94,6 +94,7 @@ describe("", () => { sensors: [], sensorReadings: [], cameraCalibrationData: fakeCameraCalibrationData(), + env: {}, farmwareEnvs: [], logs: [], }); diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index cea3dcfaf..9aad5188c 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -218,6 +218,8 @@ export class RawFarmDesigner ? (uuid: string | undefined): TaggedPlant => @@ -76,6 +79,7 @@ const selectPointGroups = memoizeLast(selectAllPointGroups); const selectPoints = memoizeLast(selectAllPoints); const selectTools = memoizeLast(selectAllTools); const selectToolSlots = memoizeLast(joinToolsAndSlot); +const selectSequences = memoizeLast(selectAllSequences); const selectPeripherals = memoizeLast(selectAllPeripherals); const selectImages = memoizeLast((index: RestResources["index"]) => chain(selectAllImages(index)) @@ -180,9 +184,11 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { const { hardware } = props.bot; const { mcu_params } = hardware; const firmwareSettings = fwConfig || mcu_params; - const fbosConfig = validFbosConfig(getFbosConfig(props.resources.index)); + const taggedFbosConfig = getFbosConfig(props.resources.index); + const fbosConfig = validFbosConfig(taggedFbosConfig); - const device = getDeviceAccountSettings(props.resources.index).body; + const deviceAccount = getDeviceAccountSettings(props.resources.index); + const device = deviceAccount.body; const mountedToolId = device.mounted_tool_id; const mountedToolName = maybeFindToolById(props.resources.index, mountedToolId)?.body.name; @@ -215,12 +221,16 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { crops: selectCrops(props.resources.index), dispatch: props.dispatch, device, + deviceAccount, + bot: props.bot, selectedPlant, designer: props.resources.consumers.farm_designer, genericPoints, weeds, allPoints, tools: selectTools(props.resources.index), + sequences: selectSequences(props.resources.index), + fbosConfig: taggedFbosConfig, toolSlots: selectToolSlots(props.resources.index), hoveredPlant, plants, @@ -233,6 +243,11 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { latestImages, cameraCalibrationData: selectCameraCalibrationData(env), timeSettings: selectTimeSettings(props.resources.index), + botOnline: isBotOnlineFromState(props.bot), + arduinoBusy: hardware.informational_settings.busy, + currentBotLocation: validBotLocationData(hardware.location_data).position, + movementState: props.app.movement, + defaultAxes: validGoButtonAxes(getConfigValue), getConfigValue, sensorReadings, sensors: selectSensors(props.resources.index), @@ -241,6 +256,7 @@ export function mapStateToProps(props: Everything): FarmDesignerProps { visualizedSequenceBody, logs: selectLogs(props.resources.index), sourceFbosConfig: sourceFbosConfigValue(fbosConfig, hardware.configuration), + env, farmwareEnvs: selectFarmwareEnvs(props.resources.index), curves: selectCurves(props.resources.index), }; diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index 03acb1be9..dc8d94ded 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -4,11 +4,14 @@ import { Config, INITIAL, INITIAL_POSITION } from "../three_d_garden/config"; import { BotSize, MapTransformProps, AxisNumberProperty, TaggedPlant, } from "./map/interfaces"; -import { BotPosition, SourceFbosConfig } from "../devices/interfaces"; +import { + BotPosition, BotState, SourceFbosConfig, UserEnv, +} from "../devices/interfaces"; import { TaggedCurve, TaggedFarmwareEnv, TaggedGenericPointer, TaggedImage, TaggedLog, TaggedPoint, - TaggedPointGroup, TaggedSensor, TaggedSensorReading, TaggedWeedPointer, + TaggedPointGroup, TaggedSensor, TaggedSensorReading, TaggedTool, + TaggedDevice, TaggedFbosConfig, TaggedSequence, TaggedWeedPointer, } from "farmbot"; import { CameraCalibrationData, DesignerState } from "./interfaces"; import { GetWebAppConfigValue } from "../config_storage/actions"; @@ -25,6 +28,7 @@ import { parseCalibrationData } from "./map/layers/images/map_image"; import { fetchInterpolationOptions } from "./map/layers/points/interpolation_map"; import { isTopDown } from "../three_d_garden/helpers"; import { perfMark, usePerfRenderCount } from "../performance/perf"; +import { MovementState, TimeSettings } from "../interfaces"; export interface ThreeDGardenMapProps { botSize: BotSize; @@ -40,6 +44,18 @@ export interface ThreeDGardenMapProps { curves: TaggedCurve[]; mapPoints: TaggedGenericPointer[]; weeds: TaggedWeedPointer[]; + tools?: TaggedTool[]; + sequences?: TaggedSequence[]; + fbosConfig?: TaggedFbosConfig; + timeSettings?: TimeSettings; + botOnline?: boolean; + arduinoBusy?: boolean; + currentBotLocation?: BotPosition; + movementState?: MovementState; + defaultAxes?: string; + noUTM?: boolean; + deviceAccount?: TaggedDevice; + bot?: BotState; botPosition: BotPosition; toolSlots?: SlotWithTool[]; mountedToolName: string | undefined; @@ -51,6 +67,7 @@ export interface ThreeDGardenMapProps { sensorReadings: TaggedSensorReading[]; sensors: TaggedSensor[]; cameraCalibrationData: CameraCalibrationData; + env: UserEnv; farmwareEnvs: TaggedFarmwareEnv[]; logs: TaggedLog[]; } @@ -373,15 +390,29 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { config={config} configPosition={position} threeDPlants={threeDPlants} + plants={props.plants} mapPoints={props.mapPoints} weeds={props.weeds} toolSlots={props.toolSlots} + tools={props.tools} + sequences={props.sequences} + fbosConfig={props.fbosConfig} + timeSettings={props.timeSettings} + botOnline={props.botOnline} + arduinoBusy={props.arduinoBusy} + currentBotLocation={props.currentBotLocation} + movementState={props.movementState} + defaultAxes={props.defaultAxes} + noUTM={props.noUTM} + deviceAccount={props.deviceAccount} + bot={props.bot} mountedToolName={props.mountedToolName} allPoints={props.allPoints} groups={props.groups} images={props.images} sensorReadings={props.sensorReadings} sensors={props.sensors} + env={props.env} addPlantProps={addPlantProps} />; }; diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 52f139066..4af1012dd 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -3,6 +3,8 @@ let mockIsMobile = false; import React from "react"; import { OrbitControls, useGLTF, useTexture } from "@react-three/drei"; +import * as threeFiber from "@react-three/fiber"; +import * as reactSpring from "@react-spring/three"; import { GardenModelProps, GardenModel, SMOOTH_XL_CAMERA_BED_SCALE, SMOOTH_XL_CAMERA_HEIGHT_SCALE, @@ -27,6 +29,8 @@ import { PLANT_ICON_ATLAS } from "../garden/plant_icon_atlas"; import { cameraInit } from "../camera"; import { getCamera } from "../zoom_beacons_constants"; import { BooleanSetting } from "../../session_keys"; +import { Mode } from "../../farm_designer/map/interfaces"; +import * as mapUtil from "../../farm_designer/map/util"; import { FallInGroup, GridRevealGroup, LoadStepReady, PopInGroup, } from "../progressive_load"; @@ -37,6 +41,8 @@ import { NorthArrow } from "../garden/north_arrow"; import { Solar } from "../garden/solar"; import { configureStore, store } from "../../redux/store"; import { resourceReady } from "../../sync/actions"; +import { get3DPositionFunc } from "../helpers"; +import { ThreeDObjectSelectionLayer } from "../selection/layer"; let isDesktopSpy: jest.SpyInstance; let isMobileSpy: jest.SpyInstance; @@ -644,6 +650,17 @@ describe("", () => { expect(useGltfMock).not.toHaveBeenCalled(); }); + it("unmounts FarmBot after hide animation exits", () => { + const p = fakeProps(); + const wrapper = createWrapper(p); + const botLoadIn = wrapper.root.findAllByType(FallInGroup) + .find(node => node.props.name == "bot-load-in"); + actRenderer(() => { + botLoadIn?.props.onExitRest(); + }); + expect(botLoadIn).toBeTruthy(); + }); + it("renders other options", async () => { mockIsDesktop = false; const p = fakeProps(); @@ -735,6 +752,22 @@ describe("", () => { expect(e.stopPropagation).toHaveBeenCalled(); }); + it("sets hover on plant pointer move", () => { + const p = fakeProps(); + p.config.labelsOnHover = true; + p.threeDPlants = convertPlants(p.config, [fakePlant()]); + const wrapper = createWrapper(p); + const e = { + stopPropagation: jest.fn(), + intersections: [{ object: { name: "0" } }], + }; + const plants = wrapper.root.findAll(node => node.props.name == "plants")[0]; + actRenderer(() => { + plants?.props.onPointerMove(e); + }); + expect(e.stopPropagation).toHaveBeenCalled(); + }); + it("sets hover with instance id and no plant index map", () => { const p = fakeProps(); p.config.labelsOnHover = true; @@ -818,6 +851,285 @@ describe("", () => { consoleLogSpy.mockRestore(); }); + it("handles scene leave and camera drag callbacks", () => { + const p = fakeProps(); + const wrapper = createWrapper(p); + const root = wrapper.root.findAll(node => !!node.props.onPointerLeave)[0]; + const orbitControls = wrapper.root.findByType(OrbitControls); + actRenderer(() => { + root.props.onPointerLeave(); + orbitControls.props.onStart(); + orbitControls.props.onEnd(); + }); + expect(root).toBeTruthy(); + }); + + it("handles grid hover and location selection", () => { + location.pathname = Path.mock(Path.designer()); + const p = fakeProps(); + const wrapper = createWrapper(p); + const hoverTarget = wrapper.root.findByProps({ name: "grid-hover-target" }); + const point = get3DPositionFunc(p.config)({ x: 100, y: 100 }); + const event = { + point, + stopPropagation: jest.fn(), + }; + actRenderer(() => { + hoverTarget.props.onPointerOver(event); + hoverTarget.props.onPointerMove(event); + hoverTarget.props.onPointerOut(); + hoverTarget.props.onClick(event); + hoverTarget.props.onClick({ ...event, delta: 3 }); + hoverTarget.props.onPointerMove({ point: { x: 100000, y: 100000 } }); + }); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it("clears grid hover when grid selection becomes blocked", () => { + const getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); + location.pathname = Path.mock(Path.designer()); + const p = fakeProps(); + const wrapper = createWrapper(p); + const hoverTarget = wrapper.root.findByProps({ name: "grid-hover-target" }); + getModeSpy.mockReturnValue(Mode.cameraSelection); + const point = get3DPositionFunc(p.config)({ x: 100, y: 100 }); + actRenderer(() => { + hoverTarget.props.onPointerMove({ point }); + }); + expect(hoverTarget).toBeTruthy(); + getModeSpy.mockRestore(); + }); + + it("updates selection callbacks from the model", () => { + const p = fakeProps(); + p.addPlantProps = fakeAddPlantProps(); + const dispatch = jest.fn(); + p.addPlantProps.dispatch = dispatch; + const wrapper = createWrapper(p); + const staticLayers = wrapper.root.findAll(node => + typeof node.props.onSelectObject == "function" + && typeof node.props.onPlantHoverChange == "function")[0]; + const selectionLayer = wrapper.root.findByType(ThreeDObjectSelectionLayer); + const location = { kind: "location" as const, x: 1, y: 2, z: 3 }; + actRenderer(() => { + staticLayers.props.onSelectObject({ kind: "plant", id: 1 }); + selectionLayer.props.onUpdateLocationSelection(location); + selectionLayer.props.onOpenPanel({ kind: "plant", id: 1 }); + selectionLayer.props.onOpenLocationPanel(location); + selectionLayer.props.onClosePopup(); + staticLayers.props.onHoverObject(true); + staticLayers.props.onHoverObject(false); + }); + expect(dispatch).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalled(); + }); + + it("closes selections on Escape", () => { + useStateSpy.mockRestore(); + const actualUseState = jest.requireActual("react") + .useState as typeof React.useState; + useStateSpy = jest.spyOn(React, "useState") + .mockImplementation(actualUseState); + const addEventSpy = jest.spyOn(window, "addEventListener"); + const wrapper = createWrapper(fakeProps()); + const selectionLayer = wrapper.root.findByType(ThreeDObjectSelectionLayer); + actRenderer(() => { + selectionLayer.props.onUpdateLocationSelection({ + kind: "location", + x: 1, + y: 2, + z: 3, + }); + }); + const keydownHandler = addEventSpy.mock.calls + .find(call => call[0] == "keydown")?.[1] as + ((event: KeyboardEvent) => void) | undefined; + expect(keydownHandler).toBeDefined(); + actRenderer(() => { + keydownHandler?.(new KeyboardEvent("keydown", { key: "Escape" })); + }); + addEventSpy.mockRestore(); + }); + + it("applies scene cursor styles", () => { + const canvas = document.createElement("canvas"); + const connected = document.createElement("div"); + const model = document.createElement("div"); + model.className = "garden-bed-3d-model"; + model.appendChild(canvas); + const state = { + gl: { + domElement: canvas, + info: { + render: { calls: 0, triangles: 0, points: 0, lines: 0 }, + memory: { geometries: 0, textures: 0 }, + }, + }, + events: { connected }, + scene: { traverse: jest.fn() }, + pointer: { x: 0, y: 0 }, + camera: {}, + raycaster: { + setFromCamera: jest.fn(), + intersectObjects: jest.fn(() => []), + }, + size: { width: 800, height: 600 }, + }; + jest.spyOn(threeFiber, "useThree") + .mockImplementation(() => state); + const wrapper = createWrapper(fakeProps()); + expect(canvas.style.cursor).toEqual("grab"); + unmountRenderer(wrapper); + mountedWrappers.pop(); + expect(canvas.style.cursor).toEqual(""); + }); + + const mockSceneCursorTarget = () => { + const canvas = document.createElement("canvas"); + const connected = document.createElement("div"); + const model = document.createElement("div"); + model.className = "garden-bed-3d-model"; + model.appendChild(canvas); + const state = { + gl: { + domElement: canvas, + info: { + render: { calls: 0, triangles: 0, points: 0, lines: 0 }, + memory: { geometries: 0, textures: 0 }, + }, + }, + events: { connected }, + scene: { traverse: jest.fn() }, + pointer: { x: 0, y: 0 }, + camera: {}, + raycaster: { + setFromCamera: jest.fn(), + intersectObjects: jest.fn(() => []), + }, + size: { width: 800, height: 600 }, + }; + const useThreeSpy = jest.spyOn(threeFiber, "useThree") + .mockImplementation(() => state); + return { canvas, useThreeSpy }; + }; + + it("applies pointer cursor while hovering selectable objects", () => { + useStateSpy.mockRestore(); + useStateSpy = jest.spyOn(React, "useState") + // eslint-disable-next-line comma-spacing + .mockImplementation((initialState?: S | (() => S)) => { + // eslint-disable-next-line no-null/no-null + if (initialState === null) { + return [{}, jest.fn()]; + } + const value = typeof initialState == "function" + ? (initialState as () => S)() + : initialState; + const setter = jest.fn((next: S | ((value: S) => S)) => { + if (typeof next == "function") { + (next as (value: S | undefined) => S)(value); + } + }); + if (initialState === 0) { + return [1, setter]; + } + return [value, setter]; + }); + const { canvas, useThreeSpy } = mockSceneCursorTarget(); + const wrapper = createWrapper(fakeProps()); + const staticLayers = wrapper.root.findAll(node => + typeof node.props.onHoverObject == "function" + && typeof node.props.onPlantHoverChange == "function")[0]; + const root = wrapper.root.findAll(node => !!node.props.onPointerMove)[0]; + actRenderer(() => { + staticLayers.props.onHoverObject(true); + root.props.onPointerMove({ + intersections: [{ + object: { userData: { plantIndexes: [0] }, name: "plant" }, + }], + }); + }); + expect(canvas.style.cursor).toEqual("pointer"); + useThreeSpy.mockRestore(); + }); + + it("applies grabbing cursor while dragging the camera", () => { + useStateSpy.mockRestore(); + useStateSpy = jest.spyOn(React, "useState") + // eslint-disable-next-line comma-spacing + .mockImplementation((initialState?: S | (() => S)) => { + // eslint-disable-next-line no-null/no-null + if (initialState === null) { + return [{}, jest.fn()]; + } + const value = typeof initialState == "function" + ? (initialState as () => S)() + : initialState; + if (initialState === false) { + return [true, jest.fn()]; + } + return [value, jest.fn()]; + }); + const { canvas, useThreeSpy } = mockSceneCursorTarget(); + createWrapper(fakeProps()); + expect(canvas.style.cursor).toEqual("grabbing"); + useThreeSpy.mockRestore(); + }); + + it("renders grid hover crosshairs with real state", () => { + useStateSpy.mockRestore(); + const actualUseState = jest.requireActual("react") + .useState as typeof React.useState; + useStateSpy = jest.spyOn(React, "useState") + .mockImplementation(actualUseState); + location.pathname = Path.mock(Path.designer()); + const p = fakeProps(); + const wrapper = createWrapper(p); + const hoverTarget = wrapper.root.findByProps({ name: "grid-hover-target" }); + const point = get3DPositionFunc(p.config)({ x: 100, y: 100 }); + actRenderer(() => { + hoverTarget.props.onPointerMove({ point }); + }); + expect(wrapper.root.findAllByProps({ name: "grid-hover-crosshairs" }).length) + .toBeGreaterThan(0); + }); + + it("smooths config changes", () => { + const springSpy = jest.spyOn(reactSpring, "useSpring") + .mockImplementation((props: Parameters[0]) => { + const resolved = typeof props == "function" ? props() : props; + const api = { + start: jest.fn((update: { + onChange?: (result: { value: Record }) => void; + onRest?: () => void; + }) => { + update.onChange?.({ value: { bedLengthOuter: 1400 } }); + update.onRest?.(); + return Promise.resolve(); + }), + }; + return [resolved, api] as unknown as ReturnType; + }); + const p = fakeProps(); + p.smoothConfigTransitions = true; + const wrapper = createWrapper(p); + actRenderer(() => wrapper.update()); + expect(springSpy).toHaveBeenCalled(); + }); + + it("preloads inactive environment scenes", async () => { + const p = fakeProps(); + p.preloadEnvironmentScenes = true; + p.config.bot = false; + p.config.zoomBeacons = false; + p.onLoadComplete = jest.fn(); + render(); + await waitFor(() => + expect(p.onLoadComplete).toHaveBeenCalled()); + }); + it.each<[string, string]>([ ["Greenhouse", "ground Greenhouse"], ["Lab", "ground Lab"], diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 18c363c53..650bdf9f4 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -542,6 +542,46 @@ const bedPropsEqual = (prev: Readonly, next: Readonly) => && bedConfigFieldsEqual(prev.config, next.config) && bedSettingFieldsEqual(prev, next); +type RenderSoilSurfaceGeometryConfig = Pick; + +export const getRenderSoilSurfaceGeometry = ( + config: RenderSoilSurfaceGeometryConfig, + soilSurfaceGeometry: BufferGeometry, +) => { + if (!config.mirrorX && !config.mirrorY) { + return soilSurfaceGeometry; + } + const geometry = soilSurfaceGeometry.clone(); + const position = geometry.getAttribute("position"); + const normal = geometry.getAttribute("normal"); + const xMid = config.bedLengthOuter / 2 - config.bedXOffset; + const yMid = config.bedWidthOuter / 2 - config.bedYOffset; + const positionArray = position.array; + const normalArray = normal?.array; + for (let i = 0; i < position.count; i++) { + const offset = i * 3; + if (config.mirrorX) { + positionArray[offset] = 2 * xMid - positionArray[offset]; + } + if (config.mirrorY) { + positionArray[offset + 1] = 2 * yMid - positionArray[offset + 1]; + } + if (normalArray && config.mirrorX) { + normalArray[offset] = -normalArray[offset]; + } + if (normalArray && config.mirrorY) { + normalArray[offset + 1] = -normalArray[offset + 1]; + } + } + position.needsUpdate = true; + if (normal) { normal.needsUpdate = true; } + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; +}; + const BedBase = (props: BedProps) => { const { bedWidthOuter, bedLengthOuter, botSizeZ, bedHeight, bedZOffset, @@ -612,45 +652,28 @@ const BedBase = (props: BedProps) => { const mirroredAxesCount = Number(mirrorX) + Number(mirrorY); const soilSurfaceSide = mirroredAxesCount % 2 == 1 ? FrontSide : BackSide; - const renderSoilSurfaceGeometry = React.useMemo(() => { - if (!mirrorX && !mirrorY) { - return props.soilSurfaceGeometry; - } - const geometry = props.soilSurfaceGeometry.clone(); - const position = geometry.getAttribute("position"); - const normal = geometry.getAttribute("normal"); - const xMid = bedLengthOuter / 2 - bedXOffset; - const yMid = bedWidthOuter / 2 - bedYOffset; - const positionArray = position.array; - const normalArray = normal?.array; - for (let i = 0; i < position.count; i++) { - const offset = i * 3; - if (mirrorX) { - positionArray[offset] = 2 * xMid - positionArray[offset]; - } - if (mirrorY) { - positionArray[offset + 1] = 2 * yMid - positionArray[offset + 1]; - } - if (normalArray && mirrorX) { - normalArray[offset] = -normalArray[offset]; - } - if (normalArray && mirrorY) { - normalArray[offset + 1] = -normalArray[offset + 1]; - } - } - position.needsUpdate = true; - if (normal) { normal.needsUpdate = true; } - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - return geometry; - }, [ + const soilSurfaceConfig = React.useMemo(() => ({ + bedLengthOuter, + bedWidthOuter, + bedXOffset, + bedYOffset, + mirrorX, + mirrorY, + }), [ bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, mirrorX, mirrorY, + ]); + const renderSoilSurfaceGeometry = React.useMemo(() => + getRenderSoilSurfaceGeometry( + soilSurfaceConfig, + props.soilSurfaceGeometry, + ), [ props.soilSurfaceGeometry, + soilSurfaceConfig, ]); const soilPosition: [number, number, number] = [ threeSpace(0, bedLengthOuter) + bedXOffset, diff --git a/frontend/three_d_garden/bot/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx index 7604e96e3..821dcc783 100644 --- a/frontend/three_d_garden/bot/__tests__/bot_test.tsx +++ b/frontend/three_d_garden/bot/__tests__/bot_test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { useGLTF } from "@react-three/drei"; import { Bot, clearBotShapeCache, FarmbotModelProps } from "../bot"; import { INITIAL, INITIAL_POSITION } from "../../config"; @@ -7,6 +7,9 @@ import { clone } from "lodash"; import { SVGLoader } from "three/examples/jsm/loaders/SVGLoader.js"; import { Texture, TextureLoader } from "three"; import { ASSETS } from "../../constants"; +import { Path } from "../../../internal_urls"; +import * as mapUtil from "../../../farm_designer/map/util"; +import { Mode } from "../../../farm_designer/map/interfaces"; import { actRenderer, createRenderer, @@ -59,7 +62,7 @@ describe("", () => { const slots = container.querySelectorAll("[name='slot']"); const lastSlot = slots[slots.length - 1]; expect(lastSlot?.getAttribute("position")?.replace(/\s+/g, "")) - .toContain("-1345,200,51"); + .toContain("-1350,200,51"); }); it("renders: Jr", () => { @@ -72,7 +75,7 @@ describe("", () => { const slots = container.querySelectorAll("[name='slot']"); const lastSlot = slots[slots.length - 1]; expect(lastSlot?.getAttribute("position")?.replace(/\s+/g, "")) - .toContain("-1345,100,51"); + .toContain("-1350,100,51"); }); it("renders: v1.7", () => { @@ -89,6 +92,51 @@ describe("", () => { expect(container.querySelectorAll("[name='button-group']").length).toEqual(3); }); + it("selects the UTM", () => { + location.pathname = Path.mock(Path.designer()); + const p = fakeProps(); + p.onSelectObject = jest.fn(); + p.onHoverObject = jest.fn(); + const { container } = render(); + const utm = container.querySelector("group[name='UTM'] mesh"); + utm && fireEvent.pointerOver(utm); + utm && fireEvent.pointerOut(utm); + utm && fireEvent.click(utm); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "utm", id: 0 }); + }); + + it("selects the camera", () => { + location.pathname = Path.mock(Path.designer()); + const p = fakeProps(); + p.onSelectObject = jest.fn(); + p.onHoverObject = jest.fn(); + const { container } = render(); + const camera = container.querySelector("group[name='camera']"); + camera && fireEvent.pointerOver(camera); + camera && fireEvent.pointerOut(camera); + camera && fireEvent.click(camera); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "camera", id: 0 }); + }); + + it("doesn't select the UTM in camera selection mode", () => { + const getModeSpy = jest.spyOn(mapUtil, "getMode") + .mockReturnValue(Mode.cameraSelection); + location.pathname = Path.mock(Path.designer()); + const p = fakeProps(); + p.onSelectObject = jest.fn(); + const { container } = render(); + const utm = container.querySelector("group[name='UTM'] mesh"); + const camera = container.querySelector("group[name='camera']"); + utm && fireEvent.click(utm); + camera && fireEvent.click(camera); + expect(p.onSelectObject).not.toHaveBeenCalled(); + getModeSpy.mockRestore(); + }); + it("hides FarmBot in Planter bed focus", () => { const p = fakeProps(); p.activeFocus = "Planter bed"; diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index 56da51e69..b713fd3f6 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -1,6 +1,7 @@ /* eslint-disable complexity */ import React, { useEffect, useState } from "react"; import * as THREE from "three"; +import { ThreeEvent } from "@react-three/fiber"; import { Cylinder, Extrude, Trail, Tube, useGLTF, } from "@react-three/drei"; @@ -14,7 +15,7 @@ import { } from "../helpers"; import { Config, PositionConfig } from "../config"; import type { GLTF } from "three-stdlib"; -import { ASSETS, LIB_DIR, PartName } from "../constants"; +import { ASSETS, HOVER_OBJECT_MODES, LIB_DIR, PartName } from "../constants"; import { SVGLoader } from "three/examples/jsm/loaders/SVGLoader.js"; import { range } from "lodash"; import { @@ -39,21 +40,18 @@ import { WateringAnimations } from "./components/watering_animations"; import { FocusVisibilityGroup } from "../focus_transition"; import { useTextureVariant } from "../texture_variants"; import { WaterFlowTextureProvider } from "./components/water_stream"; +import { + ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, +} from "../selection_types"; +import { clickWasDragged } from "../click_event"; +import { Mode } from "../../farm_designer/map/interfaces"; +import { getMode } from "../../farm_designer/map/util"; +import { + cameraMountOffset, distinguishableBlack, extrusionWidth, utmHeight, + utmRadius, +} from "./positioning"; -export const extrusionWidth = 20; -const utmRadius = 35; -export const utmHeight = 35; -export const cameraMountOffset = { - x: extrusionWidth + 3, - y: utmRadius, -}; -export const cameraMountToLensOffset = new THREE.Vector3( - 0, - extrusionWidth + 9, - 0, -); const xTrackPadding = 280; -export const distinguishableBlack = "#333"; type LeftBracket = GLTF & { nodes: { [PartName.leftBracket]: THREE.Mesh }; @@ -121,6 +119,8 @@ export interface FarmbotModelProps { toolSlots?: SlotWithTool[]; mountedToolName?: string | undefined; dispatch?: Function; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } interface RequestedShapes { @@ -535,14 +535,29 @@ const BotGantrySubassemblies = React.memo( sameBotGantrySubassembliesProps, ); -const BotElectronicsSubassemblyBase = (props: BotXYSubassemblyProps) => +interface BotElectronicsSubassemblyProps extends BotXYSubassemblyProps { + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; +} + +const botElectronicsSubassemblyPropsEqual = ( + prev: BotElectronicsSubassemblyProps, + next: BotElectronicsSubassemblyProps, +) => + sameBotXYSubassemblyProps(prev, next) && + prev.onSelectObject === next.onSelectObject && + prev.onHoverObject === next.onHoverObject; + +const BotElectronicsSubassemblyBase = (props: BotElectronicsSubassemblyProps) => ; + configPosition={props.configPosition} + onSelectObject={props.onSelectObject} + onHoverObject={props.onHoverObject} />; const BotElectronicsSubassembly = React.memo( BotElectronicsSubassemblyBase, - sameBotXYSubassemblyProps, + botElectronicsSubassemblyPropsEqual, ); interface BotVerticalToolheadSubassemblyProps @@ -550,6 +565,8 @@ interface BotVerticalToolheadSubassemblyProps zAxisShape: Shape | undefined; getZ(x: number, y: number): number; trailReady: boolean; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } const BOT_VERTICAL_TOOLHEAD_CONFIG_FIELDS: (keyof Config)[] = [ @@ -587,6 +604,8 @@ const sameBotVerticalToolheadSubassemblyProps = ( prev.configPosition.y === next.configPosition.y && prev.configPosition.z === next.configPosition.z && prev.getZ === next.getZ && + prev.onSelectObject === next.onSelectObject && + prev.onHoverObject === next.onHoverObject && prev.trailReady === next.trailReady && prev.zAxisShape === next.zAxisShape; @@ -652,6 +671,26 @@ const BotVerticalToolheadSubassemblyBase = ...gardenXY(x + cameraMountOffset.x, y + cameraMountOffset.y), zZero - zDir * z - 140 + zGantryOffset + 20, ); + const selectUtm = (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } + if ([...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + return; + } + if (props.onSelectObject) { + event.stopPropagation?.(); + props.onSelectObject({ kind: "utm", id: 0 }); + } + }; + const selectCamera = (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } + if ([...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + return; + } + if (props.onSelectObject) { + event.stopPropagation?.(); + props.onSelectObject({ kind: "camera", id: 0 }); + } + }; const utmComponent = props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} rotation={[0, 0, 2.094]} /> ; @@ -806,6 +848,9 @@ const BotVerticalToolheadSubassemblyBase = scale={1000} position={vacuumPumpCoverPosition(config.kitVersion)} /> props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} rotation={[Math.PI, 0, 0]} position={cameraMountPosition}> { config={config} configPosition={props.configPosition} getZ={props.getZ} + onSelectObject={props.onSelectObject} + onHoverObject={props.onHoverObject} trailReady={trailReady} zAxisShape={zAxisShape} /> { + configPosition={props.configPosition} + onSelectObject={props.onSelectObject} + onHoverObject={props.onHoverObject} /> {config.waterFlow && diff --git a/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx b/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx index 93bfe6d82..ef9af05ae 100644 --- a/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx @@ -1,11 +1,14 @@ import React from "react"; -import { render } from "@testing-library/react"; +import { fireEvent, render } from "@testing-library/react"; import { useGLTF } from "@react-three/drei"; import type { Vector3 } from "three"; import { INITIAL, INITIAL_POSITION } from "../../../config"; import { clone } from "lodash"; import { ElectronicsBox, ElectronicsBoxProps } from "../electronics_box"; +import { getElectronicsBoxPosition } from "../../positioning"; import { ASSETS } from "../../../constants"; +import * as mapUtil from "../../../../farm_designer/map/util"; +import { Mode } from "../../../../farm_designer/map/interfaces"; const useGltfMock = useGLTF as unknown as jest.Mock; @@ -45,6 +48,41 @@ describe("", () => { expect(container).toContainHTML("electronics-box"); }); + it("selects and hovers the electronics box", () => { + const p = fakeProps(); + p.onSelectObject = jest.fn(); + p.onHoverObject = jest.fn(); + const { container } = render(); + const box = container.querySelector("group[name='box']"); + box && fireEvent.pointerOver(box); + box && fireEvent.pointerOut(box); + box && fireEvent.click(box); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onSelectObject).toHaveBeenCalledWith({ + kind: "electronics", + id: 0, + }); + }); + + it("doesn't select the electronics box in camera selection mode", () => { + const getModeSpy = jest.spyOn(mapUtil, "getMode") + .mockReturnValue(Mode.cameraSelection); + const p = fakeProps(); + p.onSelectObject = jest.fn(); + const { container } = render(); + const box = container.querySelector("group[name='box']"); + box && fireEvent.click(box); + expect(p.onSelectObject).not.toHaveBeenCalled(); + getModeSpy.mockRestore(); + }); + + it("calculates the electronics box position", () => { + const p = fakeProps(); + const position = getElectronicsBoxPosition(p.config, p.configPosition); + expect(position.z).toEqual(p.config.columnLength - 190); + }); + it("reuses static model internals while x position changes", () => { const p = fakeProps(); p.config.kitVersion = "v1.7"; diff --git a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx index f2df2111b..c7de19827 100644 --- a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx @@ -39,6 +39,7 @@ import { INITIAL, INITIAL_POSITION } from "../../../config"; import { ASSETS } from "../../../constants"; import { clone } from "lodash"; import { Tools, ToolsProps, toolsPropsEqual } from "../tools"; +import { getToolSlotRenderPosition } from "../tool_slot_position"; import { fakeTool, fakeToolSlot, } from "../../../../__test_support__/fake_state/resources"; @@ -349,7 +350,7 @@ describe("", () => { toolSlot.body.tool_id = tool.body.id; p.toolSlots = [{ toolSlot, tool }]; const { container } = render(); - expect(container).toContainHTML("position=\"1265,460,391\""); + expect(container).toContainHTML("position=\"1260,460,391\""); }); it("flips rendered pullout direction for mirrored axis", () => { @@ -380,7 +381,28 @@ describe("", () => { toolSlot.body.gantry_mounted = true; p.toolSlots = [{ toolSlot, tool }]; const { container } = render(); - expect(container).toContainHTML("position=\"1065,-680,391\""); + expect(container).toContainHTML("position=\"1060,-680,391\""); + }); + + it("calculates static and gantry tool slot render positions", () => { + const config = clone(INITIAL); + const configPosition = clone(INITIAL_POSITION); + config.mirrorX = true; + config.botSizeX = 1000; + configPosition.x = 200; + const toolSlot = fakeToolSlot(); + toolSlot.body.x = 100; + toolSlot.body.y = 200; + toolSlot.body.z = 30; + expect(getToolSlotRenderPosition(config, configPosition, { + toolSlot, + tool: undefined, + }).z).toEqual(361); + toolSlot.body.gantry_mounted = true; + expect(getToolSlotRenderPosition(config, configPosition, { + toolSlot, + tool: undefined, + }).x).toEqual(560); }); it("doesn't mirror gantry-mounted tool y when mirrorY is active", () => { @@ -396,7 +418,7 @@ describe("", () => { toolSlot.body.gantry_mounted = true; p.toolSlots = [{ toolSlot, tool }]; const { container } = render(); - expect(container).toContainHTML("position=\"-1055,-680,391\""); + expect(container).toContainHTML("position=\"-1060,-680,391\""); }); it("renders vacuum animation when not in toolbay and vacuum", () => { @@ -477,6 +499,77 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.toolSlots("1")); }); + it("selects tool slot object instead of navigating when handler is present", () => { + const p = fakeProps(); + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + const tool = fakeTool(); + tool.body.name = "soil sensor"; + tool.body.id = 2; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 1; + toolSlot.body.tool_id = tool.body.id; + p.toolSlots = [{ toolSlot, tool }]; + let view: TestRenderer.ReactTestRenderer | undefined; + TestRenderer.act(() => { + view = TestRenderer.create(); + }); + const draggedSlot = view?.root.findAllByProps({ name: "slot" })[0]; + draggedSlot?.props.onClick({ delta: 2, stopPropagation: jest.fn() }); + expect(p.onSelectObject).not.toHaveBeenCalled(); + TestRenderer.act(() => view?.unmount()); + + const { container } = render(); + const slot = container.querySelector("[name='slot']"); + slot && fireEvent.click(slot); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "slot", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("selects UTM object instead of navigating when handler is present", () => { + const p = fakeProps(); + p.toolSlots = []; + p.onSelectObject = jest.fn(); + const { container } = render(); + const utm = container.querySelector("[name='utm-tool']"); + utm && fireEvent.click(utm); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "utm", id: 0 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("hovers selectable tools", () => { + const p = fakeProps(); + p.onHoverObject = jest.fn(); + p.toolSlots = configuredUserTools(); + const { container } = render(); + const slot = container.querySelector("[name='slot']"); + slot && fireEvent.pointerOver(slot); + slot && fireEvent.pointerOut(slot); + const utm = container.querySelector("[name='utm-tool']"); + utm && fireEvent.pointerOver(utm); + utm && fireEvent.pointerOut(utm); + container.querySelectorAll("group:not([name])").forEach(group => { + fireEvent.pointerOver(group); + fireEvent.pointerOut(group); + }); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + }); + + it("navigates to tools from the mounted UTM tool", () => { + const p = fakeProps(); + const dispatch = jest.fn(); + p.dispatch = mockDispatch(dispatch); + p.toolSlots = []; + const { container } = render(); + const utm = container.querySelector("[name='utm-tool']"); + utm && fireEvent.click(utm); + expect(dispatch).toHaveBeenCalledWith({ + type: Actions.SET_PANEL_OPEN, payload: true, + }); + expect(mockNavigate).toHaveBeenCalledWith(Path.tools()); + }); + it("doesn't navigate to tool info", () => { const p = fakeProps(); p.dispatch = undefined; diff --git a/frontend/three_d_garden/bot/components/cable_carriers.tsx b/frontend/three_d_garden/bot/components/cable_carriers.tsx index 32f90b900..f3c6e8ebb 100644 --- a/frontend/three_d_garden/bot/components/cable_carriers.tsx +++ b/frontend/three_d_garden/bot/components/cable_carriers.tsx @@ -14,7 +14,7 @@ import { range } from "lodash"; import { Group, Mesh, MeshPhongMaterial, InstancedMesh, } from "../../components"; -import { distinguishableBlack, extrusionWidth } from "../bot"; +import { distinguishableBlack, extrusionWidth } from "../positioning"; import { EMISSIVE_PROPS } from "./gantry_beam"; type CCSupportHorizontal = GLTF & { diff --git a/frontend/three_d_garden/bot/components/camera_view.tsx b/frontend/three_d_garden/bot/components/camera_view.tsx index b90edb609..f585885ad 100644 --- a/frontend/three_d_garden/bot/components/camera_view.tsx +++ b/frontend/three_d_garden/bot/components/camera_view.tsx @@ -4,7 +4,7 @@ import { Config, PositionConfig } from "../../config"; import { Mesh, MeshStandardMaterial } from "../../components"; import { Edges } from "@react-three/drei"; import { ConvexGeometry } from "three-stdlib"; -import { cameraMountOffset, cameraMountToLensOffset } from "../bot"; +import { cameraMountOffset, cameraMountToLensOffset } from "../positioning"; import { extraRotation } from "../../garden/images"; import { useSpring, animated } from "@react-spring/three"; diff --git a/frontend/three_d_garden/bot/components/electronics_box.tsx b/frontend/three_d_garden/bot/components/electronics_box.tsx index 94e682b23..e8fa7fa09 100644 --- a/frontend/three_d_garden/bot/components/electronics_box.tsx +++ b/frontend/three_d_garden/bot/components/electronics_box.tsx @@ -1,11 +1,20 @@ import React from "react"; import * as THREE from "three"; +import { ThreeEvent } from "@react-three/fiber"; import { Cylinder, useGLTF } from "@react-three/drei"; -import { get3DPositionNoMirrorFunc } from "../../helpers"; import { Config, PositionConfig } from "../../config"; import type { GLTF } from "three-stdlib"; -import { ASSETS, ElectronicsBoxMaterial, LIB_DIR, PartName } from "../../constants"; +import { + ASSETS, ElectronicsBoxMaterial, HOVER_OBJECT_MODES, LIB_DIR, PartName, +} from "../../constants"; import { Group, Mesh } from "../../components"; +import { + ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, +} from "../../selection_types"; +import { clickWasDragged } from "../../click_event"; +import { Mode } from "../../../farm_designer/map/interfaces"; +import { getMode } from "../../../farm_designer/map/util"; +import { getElectronicsBoxPosition } from "../positioning"; type Box = GLTF & { nodes: { @@ -71,6 +80,7 @@ const buttons = (kitVersion: string) => { ]; } }; + const ledsPresent = (kitVersion: string) => { switch (kitVersion) { case "v1.7": @@ -113,6 +123,8 @@ const LedIndicators = () => { export interface ElectronicsBoxProps { config: Config; configPosition: PositionConfig; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } const electronicsBoxPropsEqual = ( @@ -125,24 +137,42 @@ const electronicsBoxPropsEqual = ( prevProps.config.bedLengthOuter == nextProps.config.bedLengthOuter && prevProps.config.bedWidthOuter == nextProps.config.bedWidthOuter && prevProps.config.columnLength == nextProps.config.columnLength && - prevProps.config.kitVersion == nextProps.config.kitVersion; + prevProps.config.kitVersion == nextProps.config.kitVersion && + prevProps.onSelectObject == nextProps.onSelectObject && + prevProps.onHoverObject == nextProps.onHoverObject; const ElectronicsBoxBase = (props: ElectronicsBoxProps) => { - const { bedYOffset, columnLength } = props.config; - const { x } = props.configPosition; - const get3DPosition = get3DPositionNoMirrorFunc(props.config); - const position = get3DPosition({ - x: x - 62, - y: -20 - bedYOffset, - }); - + const { + config, configPosition, onHoverObject, onSelectObject, + } = props; + const selectElectronics = React.useCallback((event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } + if ([...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + return; + } + if (onSelectObject) { + event.stopPropagation?.(); + onSelectObject({ kind: "electronics", id: 0 }); + } + }, [onSelectObject]); + const hoverElectronics = React.useCallback(( + hovered: boolean, + event: ThreeEvent, + ) => { + event.stopPropagation?.(); + onHoverObject?.(hovered); + }, [onHoverObject]); + const onPointerOver = React.useCallback((event: ThreeEvent) => + hoverElectronics(true, event), [hoverElectronics]); + const onPointerOut = React.useCallback((event: ThreeEvent) => + hoverElectronics(false, event), [hoverElectronics]); return - + position={getElectronicsBoxPosition(config, configPosition)}> + ; }; @@ -151,6 +181,9 @@ export const ElectronicsBox = React.memo( interface ElectronicsBoxModelProps { kitVersion: string; + onClick(event: ThreeEvent): void; + onPointerOver(event: ThreeEvent): void; + onPointerOut(event: ThreeEvent): void; } const ElectronicsBoxModelBase = (props: ElectronicsBoxModelProps) => { @@ -161,6 +194,9 @@ const ElectronicsBoxModelBase = (props: ElectronicsBoxModelProps) => { useGLTF(ASSETS.models.farmduino, LIB_DIR) as unknown as Farmduino; return <> ; + get3DPositionNoMirror: ReturnType; + zZero: number; + zDir: number; +} + +export const getToolPositionHelpers = ( + config: Config, +): ToolPositionHelpers => ({ + get3DPosition: get3DPositionFunc(config), + get3DPositionNoMirror: get3DPositionNoMirrorFunc(config), + zZero: zZeroFunc(config), + zDir: zDirFunc(config), +}); + +export const getToolRenderPosition = ( + config: Config, + tool: ThreeDToolPositionInput, + inToolbay: boolean, + helpers = getToolPositionHelpers(config), +): Record => { + const mirroredPosition = helpers.get3DPosition({ x: tool.x, y: tool.y }); + const noMirrorPosition = + helpers.get3DPositionNoMirror({ x: tool.x, y: tool.y }); + return { + x: inToolbay ? mirroredPosition.x : noMirrorPosition.x, + y: inToolbay && !tool.gantryMounted + ? mirroredPosition.y + : noMirrorPosition.y, + z: helpers.zZero + - helpers.zDir * tool.z + + (inToolbay ? 0 : (utmHeight / 2 - 15)), + }; +}; + +export const getToolSlotRenderPosition = ( + config: Config, + configPosition: PositionConfig, + slot: SlotWithTool, +): Record => { + const slotBody = slot.toolSlot.body; + const mirroredBotX = config.mirrorX + ? config.botSizeX - configPosition.x + : configPosition.x; + const position = getToolRenderPosition(config, { + x: slotBody.gantry_mounted ? mirroredBotX : slotBody.x, + y: slotBody.gantry_mounted + ? slotBody.y - config.bedYOffset + : slotBody.y, + z: slotBody.z, + gantryMounted: slotBody.gantry_mounted, + }, true); + return { + x: position.x, + y: position.y, + z: position.z - 9, + }; +}; diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 53bf19f96..885583f7f 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -1,13 +1,7 @@ import React from "react"; import * as THREE from "three"; import { useGLTF } from "@react-three/drei"; -import { - get3DPositionFunc, - get3DPositionNoMirrorFunc, - threeSpace, - zDir as zDirFunc, - zZero as zZeroFunc, -} from "../../helpers"; +import { threeSpace } from "../../helpers"; import { Config, PositionConfig } from "../../config"; import type { GLTF } from "three-stdlib"; import { @@ -18,8 +12,10 @@ import { SeedTroughAssemblyFull, SeedTroughAssemblyModel, SeedTroughHolderFull, SeedTroughHolderModel, } from "../parts"; -import { Group, Mesh, MeshPhongMaterial } from "../../components"; -import { distinguishableBlack, utmHeight } from "../bot"; +import { + BoxGeometry, Group, Mesh, MeshBasicMaterial, MeshPhongMaterial, +} from "../../components"; +import { distinguishableBlack } from "../positioning"; import { SlotWithTool } from "../../../resources/interfaces"; import { isUndefined, sortBy } from "lodash"; import { @@ -31,10 +27,18 @@ import { useNavigate } from "react-router"; import { Path } from "../../../internal_urls"; import { setPanelOpen3D } from "../../panel_actions"; import { getMode } from "../../../farm_designer/map/util"; +import { Mode } from "../../../farm_designer/map/interfaces"; import { PROMO_TOOLS } from "../../../promo/tools"; -import { useFrame } from "@react-three/fiber"; +import { ThreeEvent, useFrame } from "@react-three/fiber"; import { Model, ModelMesh } from "../../model_mesh"; import { SuctionAnimations } from "./suction_animation"; +import { + ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, +} from "../../selection_types"; +import { + getToolPositionHelpers, getToolRenderPosition, ToolPositionHelpers, +} from "./tool_slot_position"; +import { clickWasDragged } from "../../click_event"; type Toolbay3 = GLTF & { nodes: { @@ -82,6 +86,8 @@ export interface ToolsProps { mountedToolName?: string | undefined; dispatch?: Function; getZ(x: number, y: number): number; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } export interface ThreeDTool { @@ -118,6 +124,8 @@ export const toolsPropsEqual = (prev: ToolsProps, next: ToolsProps) => prev.mountedToolName === next.mountedToolName && prev.dispatch === next.dispatch && prev.getZ === next.getZ && + prev.onSelectObject === next.onSelectObject && + prev.onHoverObject === next.onHoverObject && prev.configPosition.x === next.configPosition.x && prev.configPosition.y === next.configPosition.y && prev.configPosition.z === next.configPosition.z && @@ -191,17 +199,15 @@ const ToolsBase = (props: ToolsProps) => { const tools = isUndefined(configuredTools) ? PROMO_TOOLS(props.config, props.configPosition) : configuredTools; - const positionHelpers = React.useMemo(() => ({ - get3DPosition: get3DPositionFunc(props.config), - get3DPositionNoMirror: get3DPositionNoMirrorFunc(props.config), - zZero: zZeroFunc(props.config), - zDir: zDirFunc(props.config), - }), [props.config]); + const positionHelpers = + React.useMemo(() => getToolPositionHelpers(props.config), [props.config]); return { { + const navigate = useNavigate(); + return (event: ThreeEvent) => { + if (clickWasDragged(event)) { return; } + const utmSelection = !props.inToolbay; + if ((props.id || utmSelection) && (props.dispatch || props.onSelectObject) && + ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + event.stopPropagation?.(); + if (props.onSelectObject) { + props.onSelectObject(props.id + ? { kind: "slot", id: props.id } + : { kind: "utm", id: 0 }); + return; + } + if (props.id) { + props.dispatch?.(setPanelOpen3D(true)); + navigate(Path.toolSlots(props.id)); + } else { + props.dispatch?.(setPanelOpen3D(true)); + navigate(Path.tools()); + } + } + }; +}; + +const SLOT_HITBOX_SIZE: [number, number, number] = [120, 120, 30]; +const SLOT_HITBOX_TOP_ABOVE_POSITION = 10; +const TOOLBAY_SLOT_Z_OFFSET = -9; +const SEED_TROUGH_SLOT_Z_OFFSET = -40; + +interface SlotHitTargetProps { + groupZOffset: number; } +const SlotHitTarget = (props: SlotHitTargetProps) => + + + + ; + const ToolbaySlot = (props: ToolbaySlotProps) => { const { position, children, toolPulloutDirection, mounted } = props; + const selectable = !!props.id || !props.inToolbay; const rotationMultiplier = rotationFactor(displayedPulloutDirection( toolPulloutDirection, props.config.mirrorX, props.config.mirrorY)); - const navigate = useNavigate(); + const onClick = useToolSlotClick(props); return { - if (props.id && !isUndefined(props.dispatch) && - !HOVER_OBJECT_MODES.includes(getMode())) { - props.dispatch(setPanelOpen3D(true)); - navigate(Path.toolSlots(props.id)); - } - }}> + onClick={onClick} + onPointerOver={() => selectable && props.onHoverObject?.(true)} + onPointerOut={() => selectable && props.onHoverObject?.(false)}> + {selectable && props.inToolbay && + } {rotationMultiplier && @@ -356,12 +414,9 @@ interface ToolProps extends ThreeDTool { mountedToolName: string | undefined; config: Config; dispatch?: Function; - positionHelpers: { - get3DPosition: ReturnType; - get3DPositionNoMirror: ReturnType; - zZero: number; - zDir: number; - }; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; + positionHelpers: ToolPositionHelpers; } interface ToolModelProps { @@ -369,8 +424,6 @@ interface ToolModelProps { inToolbay: boolean; } -const TOOL_X = 5.5; - const RotaryToolModel = React.memo( React.forwardRef((_props, ref) => { const rotaryToolBase = @@ -379,7 +432,7 @@ const RotaryToolModel = React.memo( useGLTF(ASSETS.models.rotaryToolImplement, LIB_DIR) as unknown as Model; return { ASSETS.models.wateringNozzle, LIB_DIR) as unknown as WateringNozzle; return { const seedBin = useGLTF(ASSETS.models.seedBin, LIB_DIR) as unknown as SeedBin; return { const seedTray = useGLTF(ASSETS.models.seedTray, LIB_DIR) as unknown as SeedTray; return { model={soilSensor} name={"soilSensor"} position={[ - TOOL_X, + 0, 0, 10, ]} @@ -462,7 +515,7 @@ const SeederToolModel = React.memo((props: ToolModelProps) => { return <> { const weeder = useGLTF(ASSETS.models.weeder, LIB_DIR) as unknown as Weeder; return ? : ); +interface SeedTroughToolSlotProps extends ToolbaySlotProps { + firstTrough?: boolean; +} + +const SeedTroughToolSlot = (props: SeedTroughToolSlotProps) => { + const onClick = useToolSlotClick(props); + const selectable = !!props.id; + return selectable && props.onHoverObject?.(true)} + onPointerOut={() => selectable && props.onHoverObject?.(false)}> + {selectable && + } + + ; +}; + interface ActiveRotaryToolSlotProps extends ToolbaySlotProps { rotary: number; } @@ -562,23 +638,12 @@ const ToolBase = (props: ToolProps) => { toolPulloutDirection, inToolbay, id, mountedToolName, config, dispatch, } = props; const mounted = inToolbay && props.toolName == mountedToolName; - const { - get3DPosition, get3DPositionNoMirror, zZero, zDir, - } = props.positionHelpers; - const mirroredPosition = get3DPosition({ x: props.x, y: props.y }); - const noMirrorPosition = get3DPositionNoMirror({ - x: props.x, - y: props.y, - }); - const position = { - x: inToolbay ? mirroredPosition.x : noMirrorPosition.x, - y: inToolbay && !props.gantryMounted - ? mirroredPosition.y - : noMirrorPosition.y, - z: zZero - zDir * props.z + (inToolbay ? 0 : (utmHeight / 2 - 15)), - }; + const position = + getToolRenderPosition(config, props, inToolbay, props.positionHelpers); const common: ToolbaySlotProps = { mounted, position, toolPulloutDirection, id, inToolbay, config, dispatch, + onSelectObject: props.onSelectObject, + onHoverObject: props.onHoverObject, }; switch (props.toolName) { case ToolName.rotaryTool: @@ -614,15 +679,9 @@ const ToolBase = (props: ToolProps) => { ; case ToolName.seedTrough: - return - - ; + return ; default: return ; } diff --git a/frontend/three_d_garden/bot/components/watering_animations.tsx b/frontend/three_d_garden/bot/components/watering_animations.tsx index 807d7820c..1da0cbefc 100644 --- a/frontend/three_d_garden/bot/components/watering_animations.tsx +++ b/frontend/three_d_garden/bot/components/watering_animations.tsx @@ -10,7 +10,7 @@ import { easyCubicBezierCurve3, get3DPositionNoMirrorFunc, zDir, zZero, } from "../../helpers"; import { Config, PositionConfig } from "../../config"; -import { utmHeight } from "../bot"; +import { utmHeight } from "../positioning"; import { Texture } from "three"; export interface WateringAnimationsProps { diff --git a/frontend/three_d_garden/bot/positioning.ts b/frontend/three_d_garden/bot/positioning.ts new file mode 100644 index 000000000..f9c4dda90 --- /dev/null +++ b/frontend/three_d_garden/bot/positioning.ts @@ -0,0 +1,35 @@ +import * as THREE from "three"; +import { Config, PositionConfig } from "../config"; +import { get3DPositionNoMirrorFunc } from "../helpers"; + +export const extrusionWidth = 20; +export const utmRadius = 35; +export const utmHeight = 35; +export const cameraMountOffset = { + x: extrusionWidth + 3, + y: utmRadius, +}; +export const cameraMountToLensOffset = new THREE.Vector3( + 0, + extrusionWidth + 9, + 0, +); +export const distinguishableBlack = "#333"; + +export const getElectronicsBoxPosition = ( + config: Config, + configPosition: PositionConfig, +) => { + const { bedYOffset, columnLength } = config; + const { x } = configPosition; + const get3DPosition = get3DPositionNoMirrorFunc(config); + const position = get3DPosition({ + x: x - 62, + y: -20 - bedYOffset, + }); + return new THREE.Vector3( + position.x, + position.y, + columnLength - 190, + ); +}; diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 62e5a4344..2ca672a81 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -33,6 +33,8 @@ import { INITIAL } from "../../config"; import { PlantInstances, PlantInstancesProps, + plantIconConfigEquals, + plantInstancesPropsEqual, plantIconBrightness, } from "../plant_instances"; import { Path } from "../../../internal_urls"; @@ -132,6 +134,20 @@ describe("", () => { expect(mesh?.getAttribute("count")).toEqual("1"); }); + it("compares plant instance props", () => { + const p = fakeProps(); + expect(plantIconConfigEquals(p.config, { ...p.config })).toBeTruthy(); + expect(plantIconConfigEquals(p.config, { + ...p.config, + bedLengthOuter: p.config.bedLengthOuter + 1, + })).toBeFalsy(); + expect(plantInstancesPropsEqual(p, { ...p })).toBeTruthy(); + expect(plantInstancesPropsEqual(p, { + ...p, + config: { ...p.config, mirrorX: !p.config.mirrorX }, + })).toBeFalsy(); + }); + it("keeps reserved capacities for multiple active icon buckets", () => { const p = fakeProps(); p.iconCapacities = { @@ -320,6 +336,43 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); }); + it("selects a plant object instead of navigating when handler is present", () => { + setMockInstanceId(0); + const p = fakeProps(); + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + mesh && fireEvent.click(mesh, { instanceId: 0 }); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "plant", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("hovers plant icon instances", () => { + PLANT_ICON_ATLAS["/crops/icons/strawberry.avif"] = { + atlasUrl: "/crops/icons/atlas.avif", + textureWidth: 256, + textureHeight: 256, + x: 0, + y: 0, + width: 64, + height: 64, + }; + const p = fakeProps(); + p.onHoverObject = jest.fn(); + const wrapper = createRenderer(); + const meshes = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh"); + expect(meshes.length).toEqual(2); + meshes.forEach(mesh => { + mesh.props.onPointerOver(); + mesh.props.onPointerOut(); + }); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + unmountRenderer(wrapper); + }); + it("doesn't navigate after orbiting over a plant icon", () => { const p = fakeProps(); const dispatch = jest.fn(); @@ -615,6 +668,18 @@ describe("", () => { expect(useFrame).toHaveBeenCalledTimes(frameCalls + 1); }); + it("updates animated season icon matrices on frame", () => { + const p = fakeProps(); + p.config.animateSeasons = true; + p.startTimeRef = { current: 0 }; + p.plants = [p.plants[0]]; + render(); + const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; + const instancedRef = allRefs.find(ref => !!ref.current?.setMatrixAt); + frameFn({ camera: { quaternion: new Quaternion() } }); + expect(instancedRef?.current?.setMatrixAt).toHaveBeenCalled(); + }); + it("updates material brightness when changed", () => { const setScalar = jest.fn(); const instancedRef = { diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 262ae3b67..7cfafb75d 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -17,6 +17,7 @@ import { Actions } from "../../../constants"; import { convertPlants } from "../../../farm_designer/three_d_garden_map"; import { setMockInstanceId } from "../../../__test_support__/three_d_mocks"; import { useFrame } from "@react-three/fiber"; +import * as reactSpring from "@react-spring/three"; import { InstancedMesh as ThreeInstancedMesh, Quaternion, @@ -302,6 +303,35 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); }); + it("selects a spread plant instead of navigating when handler is present", () => { + setMockInstanceId(0); + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = true; + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + const { container } = render(); + const mesh = container.querySelector("instancedmesh"); + mesh && fireEvent.click(mesh, { instanceId: 0 }); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "plant", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("hovers spread plants", () => { + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = true; + p.onHoverObject = jest.fn(); + const wrapper = createRenderer(); + const mesh = wrapper.root.findAll(node => + (node.type as string) == "instancedMesh")[0]; + mesh.props.onPointerOver(); + mesh.props.onPointerOut(); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + unmountRenderer(wrapper); + }); + it("doesn't navigate after orbiting over a spread sphere", () => { queueMeshRef(); const p = fakeProps(); @@ -399,6 +429,52 @@ describe("", () => { expect(mesh?.geometry?.setAttribute).toHaveBeenCalled(); }); + it("updates spread state during spring changes", () => { + const springSpy = jest.spyOn(reactSpring, "useSpring") + .mockImplementation((props: Parameters[0]) => { + const resolved = typeof props == "function" ? props() : props; + const api = { + start: jest.fn((update: { + onChange?: (result: { value: { scale?: number } }) => void; + onRest?: () => void; + }) => { + update.onChange?.({ value: { scale: 0.5 } }); + update.onRest?.(); + return Promise.resolve(); + }), + }; + return [resolved, api] as unknown as ReturnType; + }); + queueMeshRef(); + const p = fakeProps(); + p.spreadVisible = true; + render(); + expect(springSpy).toHaveBeenCalled(); + springSpy.mockRestore(); + }); + + it("clears spread rendering when the spring hides it", () => { + const springSpy = jest.spyOn(reactSpring, "useSpring") + .mockImplementation((props: Parameters[0]) => { + const resolved = typeof props == "function" ? props() : props; + const api = { + start: jest.fn((update: { + onChange?: (result: { value: { scale?: number } }) => void; + onRest?: () => void; + }) => { + update.onChange?.({ value: { scale: 0 } }); + update.onRest?.(); + return Promise.resolve(); + }), + }; + return [resolved, api] as unknown as ReturnType; + }); + queueMeshRef(); + render(); + expect(springSpy).toHaveBeenCalled(); + springSpy.mockRestore(); + }); + it("skips frame updates when invisible", () => { queueMeshRef(); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 1a68dc61d..49466f35d 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -80,6 +80,30 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); }); + it("selects a point object instead of navigating when handler is present", () => { + const p = fakeProps(); + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + p.point.body.id = 1; + const { container } = render(); + const point = container.querySelector("[name='marker']"); + point && fireEvent.click(point); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "point", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("hovers a point", () => { + const p = fakeProps(); + p.onHoverObject = jest.fn(); + p.point.body.id = 1; + const { container } = render(); + const point = container.querySelector("[name='point-1']"); + point && fireEvent.pointerOver(point); + point && fireEvent.pointerOut(point); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + }); + it("doesn't navigate after orbiting over a point", () => { const p = fakeProps(); const dispatch = jest.fn(); @@ -262,6 +286,38 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.points("1")); }); + it("selects a point instance instead of navigating when handler is present", () => { + const p = fakeInstanceProps(); + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + p.points[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const marker = wrapper.root + .findAll(node => node.props.name == "marker")[0]; + marker.props.onClick({ instanceId: 0 }); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "point", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("hovers point instances", () => { + const p = fakeInstanceProps(); + p.onHoverObject = jest.fn(); + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const meshes = wrapper.root.findAll(node => { + const name: unknown = node.props.name; + return typeof name == "string" + && ["marker", "marker-radius"].includes(name); + }); + meshes.forEach(mesh => { + mesh.props.onPointerOver(); + mesh.props.onPointerOut(); + }); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + }); + it("doesn't navigate after orbiting over a point instance", () => { const p = fakeInstanceProps(); const dispatch = jest.fn(); diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index 610ae0ea0..edee5c54e 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -74,6 +74,30 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); }); + it("selects a weed object instead of navigating when handler is present", () => { + const p = fakeProps(); + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + p.weed.body.id = 1; + const { container } = render(); + const weed = container.querySelector("[name='weed-1']"); + weed && fireEvent.click(weed); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "weed", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("hovers a weed", () => { + const p = fakeProps(); + p.onHoverObject = jest.fn(); + p.weed.body.id = 1; + const { container } = render(); + const weed = container.querySelector("[name='weed-1']"); + weed && fireEvent.pointerOver(weed); + weed && fireEvent.pointerOut(weed); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + }); + it("doesn't navigate after orbiting over a weed", () => { const p = fakeProps(); const dispatch = jest.fn(); @@ -189,11 +213,40 @@ describe("", () => { mountedWrappers.push(wrapper); const weedIcons = wrapper.root .findAll(node => node.props.name == "weed-icons")[0]; - weedIcons.props.onClick({ instanceId: 0 }); + const event = { instanceId: 0, stopPropagation: jest.fn() }; + weedIcons.props.onClick(event); expect(dispatch).toHaveBeenCalledWith({ type: Actions.SET_PANEL_OPEN, payload: true, }); expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it("selects a weed icon instead of navigating when handler is present", () => { + const p = fakeInstanceProps(); + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weedIcons = wrapper.root + .findAll(node => node.props.name == "weed-icons")[0]; + weedIcons.props.onClick({ instanceId: 0 }); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "weed", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("doesn't navigate from a missing weed instance", () => { + const p = fakeInstanceProps(); + p.dispatch = mockDispatch(jest.fn()); + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weedIcons = wrapper.root + .findAll(node => node.props.name == "weed-icons")[0]; + const event = { instanceId: 99, stopPropagation: jest.fn() }; + weedIcons.props.onClick(event); + expect(event.stopPropagation).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); }); it("navigates from a weed radius instance", () => { @@ -212,6 +265,38 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); }); + it("selects a weed radius instead of navigating when handler is present", () => { + const p = fakeInstanceProps(); + p.dispatch = mockDispatch(jest.fn()); + p.onSelectObject = jest.fn(); + p.weeds[0].body.id = 1; + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const weedRadius = wrapper.root + .findAll(node => node.props.name == "weed-radius")[0]; + weedRadius.props.onClick({ instanceId: 0 }); + expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "weed", id: 1 }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("hovers weed instances", () => { + const p = fakeInstanceProps(); + p.onHoverObject = jest.fn(); + const wrapper = createRenderer(); + mountedWrappers.push(wrapper); + const meshes = wrapper.root.findAll(node => { + const name: unknown = node.props.name; + return typeof name == "string" + && ["weed-icons", "weed-radius"].includes(name); + }); + meshes.forEach(mesh => { + mesh.props.onPointerOver(); + mesh.props.onPointerOut(); + }); + expect(p.onHoverObject).toHaveBeenCalledWith(true); + expect(p.onHoverObject).toHaveBeenCalledWith(false); + }); + it("doesn't navigate after orbiting over weed instances", () => { const p = fakeInstanceProps(); const dispatch = jest.fn(); diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 2edb796e7..985a44049 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -34,6 +34,9 @@ import { getSeasonAnimationElapsed, } from "./sun"; import { clickWasDragged } from "../click_event"; +import { + ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, +} from "../selection_types"; export interface PlantInstancesProps { plants: ThreeDGardenPlant[]; @@ -44,6 +47,8 @@ export interface PlantInstancesProps { dispatch?: Function; iconCapacities?: Record; plantIconAtlas?: PlantIconAtlas; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } interface PlantIconInstancesProps extends PlantInstancesProps { @@ -105,14 +110,14 @@ const PLANT_ICON_CONFIG_KEYS: (keyof Config)[] = [ "plants", ]; -const plantIconConfigEquals = (prev: Config, next: Config) => { +export const plantIconConfigEquals = (prev: Config, next: Config) => { for (const key of PLANT_ICON_CONFIG_KEYS) { if (prev[key] !== next[key]) { return false; } } return true; }; -const plantInstancesPropsEqual = ( +export const plantInstancesPropsEqual = ( prev: PlantInstancesProps, next: PlantInstancesProps, ) => @@ -121,6 +126,8 @@ const plantInstancesPropsEqual = ( prev.visible === next.visible && prev.startTimeRef === next.startTimeRef && prev.dispatch === next.dispatch && + prev.onSelectObject === next.onSelectObject && + prev.onHoverObject === next.onHoverObject && prev.iconCapacities === next.iconCapacities && prev.plantIconAtlas === next.plantIconAtlas && plantIconConfigEquals(prev.config, next.config); @@ -266,6 +273,7 @@ const usePlantIconClick = ( plants: ThreeDGardenPlant[], dispatch: Function | undefined, visible: boolean | undefined, + onSelectObject: ThreeDObjectSelectionHandler | undefined, ) => { const navigate = useNavigate(); return (event: ThreeEvent) => { @@ -273,9 +281,14 @@ const usePlantIconClick = ( const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; - if (plant?.id && dispatch && visible && + if (plant?.id && (dispatch || onSelectObject) && visible && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - dispatch(setPanelOpen3D(true)); + event.stopPropagation?.(); + if (onSelectObject) { + onSelectObject({ kind: "plant", id: plant.id }); + return; + } + dispatch?.(setPanelOpen3D(true)); navigate(Path.plants(plant.id)); } }; @@ -346,7 +359,8 @@ const AtlasPlantIconInstances = (props: AtlasPlantIconInstancesProps) => { instancedRef, materialRef, }); - const onClick = usePlantIconClick(plants, dispatch, visible); + const onClick = + usePlantIconClick(plants, dispatch, visible, props.onSelectObject); return { visible={visible} raycast={plantIconRaycast} onClick={onClick} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} renderOrder={RenderOrder.plants}> { instancedRef, materialRef, }); - const onClick = usePlantIconClick(plants, dispatch, visible); + const onClick = + usePlantIconClick(plants, dispatch, visible, props.onSelectObject); return { visible={visible} raycast={plantIconRaycast} onClick={onClick} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} renderOrder={RenderOrder.plants}> { capacity: props.iconCapacities[icon], startTimeRef: props.startTimeRef, visible: props.visible, + onSelectObject: props.onSelectObject, + onHoverObject: props.onHoverObject, }; } } @@ -459,6 +480,8 @@ const VisiblePlantInstances = (props: PlantInstancesProps) => { capacity: 0, startTimeRef: props.startTimeRef, visible: props.visible, + onSelectObject: props.onSelectObject, + onHoverObject: props.onHoverObject, }; } }); @@ -509,6 +532,8 @@ const VisiblePlantInstances = (props: PlantInstancesProps) => { props.dispatch, props.getZ, props.iconCapacities, + props.onHoverObject, + props.onSelectObject, props.plants, props.startTimeRef, props.visible, diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index db9dc7d63..5a443b473 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -37,6 +37,9 @@ import { Mode } from "../../farm_designer/map/interfaces"; import { findCropMetadata } from "../../crops/metadata"; import { perfMeasure } from "../../performance/perf"; import { clickWasDragged } from "../click_event"; +import { + ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, +} from "../selection_types"; const spreadLayerSpringConfig = { tension: 240, @@ -161,6 +164,8 @@ export interface PlantSpreadInstancesProps { spreadVisible: boolean; instanceCapacity?: number; routeKey?: string; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } interface PlantSpreadUpdateState { @@ -461,9 +466,14 @@ const PlantSpreadInstancesBase = (props: PlantSpreadInstancesProps) => { const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const plant = plants[instanceId]; - if (plant?.id && dispatch && visible && + if (plant?.id && (dispatch || props.onSelectObject) && visible && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - dispatch(setPanelOpen3D(true)); + event.stopPropagation?.(); + if (props.onSelectObject) { + props.onSelectObject({ kind: "plant", id: plant.id }); + return; + } + dispatch?.(setPanelOpen3D(true)); navigate(Path.plants(plant.id)); } }; @@ -478,7 +488,9 @@ const PlantSpreadInstancesBase = (props: PlantSpreadInstancesProps) => { userData={{ plantIndexes }} visible={visible} raycast={plantSpreadRaycast} - onClick={onClick}> + onClick={onClick} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)}> { @@ -109,15 +114,22 @@ export const Point = (props: PointProps) => { }} onClick={(event) => { if (clickWasDragged(event)) { return; } - if (point.body.id && !isUndefined(props.dispatch) && props.visible && - !HOVER_OBJECT_MODES.includes(getMode())) { - props.dispatch(setPanelOpen3D(true)); + if (point.body.id && (props.dispatch || props.onSelectObject) && + props.visible && + ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + event.stopPropagation?.(); + if (props.onSelectObject) { + props.onSelectObject({ kind: "point", id: point.body.id }); + return; + } + props.dispatch?.(setPanelOpen3D(true)); navigate(Path.points(point.body.id)); } }} config={config} color={point.body.meta.color} radius={point.body.radius} + onHoverObject={props.onHoverObject} />; }; @@ -140,6 +152,8 @@ export interface PointInstancesProps { dispatch?: Function; visible: boolean; getZ(x: number, y: number): number; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } const pointAlpha = (point: TaggedGenericPointer) => @@ -245,9 +259,14 @@ const PointBucketInstances = (props: PointInstanceBucketProps) => { const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } const point = instances[instanceId]?.point; - if (point?.body.id && dispatch && visible && - !HOVER_OBJECT_MODES.includes(getMode())) { - dispatch(setPanelOpen3D(true)); + if (point?.body.id && (dispatch || props.onSelectObject) && visible && + ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + event.stopPropagation?.(); + if (props.onSelectObject) { + props.onSelectObject({ kind: "point", id: point.body.id }); + return; + } + dispatch?.(setPanelOpen3D(true)); navigate(Path.points(point.body.id)); } }; @@ -261,6 +280,8 @@ const PointBucketInstances = (props: PointInstanceBucketProps) => { dispose={null} visible={visible} onClick={onClick(group.points)} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} renderOrder={RenderOrder.points}> { dispose={null} visible={visible} onClick={onClick(group.ringPoints)} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} renderOrder={RenderOrder.points}> { @@ -411,6 +436,7 @@ interface PointBaseProps { pointName: string; position?: Record; onClick?: (event: ThreeEvent) => void; + onHoverObject?: ThreeDObjectHoverHandler; color: string | undefined; radius: number; alpha: number; @@ -431,7 +457,9 @@ const PointBase = (props: PointBaseProps) => { rotation={[Math.PI / 2, 0, 0]} position={position ? getWorldPosition(position) - : [0, 0, 0]}> + : [0, 0, 0]} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)}> { @@ -55,9 +61,15 @@ export const Weed = (props: WeedProps) => { alpha={1} onClick={(event) => { if (clickWasDragged(event)) { return; } - if (weed.body.id && !isUndefined(props.dispatch) && props.visible && - !HOVER_OBJECT_MODES.includes(getMode())) { - props.dispatch(setPanelOpen3D(true)); + if (weed.body.id && (props.dispatch || props.onSelectObject) && + props.visible && + ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + event.stopPropagation?.(); + if (props.onSelectObject) { + props.onSelectObject({ kind: "weed", id: weed.body.id }); + return; + } + props.dispatch?.(setPanelOpen3D(true)); navigate(Path.weeds(weed.body.id)); } }} @@ -68,7 +80,8 @@ export const Weed = (props: WeedProps) => { }} config={config} color={weed.body.meta.color} - radius={weed.body.radius} />; + radius={weed.body.radius} + onHoverObject={props.onHoverObject} />; }; interface WeedBaseProps { @@ -82,6 +95,7 @@ interface WeedBaseProps { radiusRef?: RadiusRef; billboardRef?: BillboardRef; imageRef?: ImageRef; + onHoverObject?: ThreeDObjectHoverHandler; } export const WeedBase = (props: WeedBaseProps) => { @@ -97,7 +111,9 @@ export const WeedBase = (props: WeedBaseProps) => { position={position ? getWorldPosition(position) : [0, 0, 0]} - onClick={onClick}> + onClick={onClick} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)}> ({ const useNavigateToWeed = ( dispatch: Function | undefined, visible: boolean, + onSelectObject: ThreeDObjectSelectionHandler | undefined, ) => { const navigate = useNavigate(); return (weed: TaggedWeedPointer | undefined) => { - if (weed?.body.id && dispatch && visible && - !HOVER_OBJECT_MODES.includes(getMode())) { - dispatch(setPanelOpen3D(true)); - navigate(Path.weeds(weed.body.id)); + if (!weed?.body.id || !(dispatch || onSelectObject) || !visible || + [...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { + return false; + } + if (onSelectObject) { + onSelectObject({ kind: "weed", id: weed.body.id }); + return true; } + const dispatchPanelOpen = dispatch as Function; + dispatchPanelOpen(setPanelOpen3D(true)); + navigate(Path.weeds(weed.body.id)); + return true; }; }; @@ -244,7 +270,8 @@ const WeedIconInstances = (props: WeedIconInstancesProps) => { () => getPlantIconTexture(baseTexture, GENERIC_WEED_ICON, plantIconAtlas), [baseTexture, plantIconAtlas], ); - const navigateToWeed = useNavigateToWeed(dispatch, visible); + const navigateToWeed = + useNavigateToWeed(dispatch, visible, props.onSelectObject); // eslint-disable-next-line no-null/no-null const instancedRef = React.useRef(null); const updateStateRef = @@ -291,7 +318,9 @@ const WeedIconInstances = (props: WeedIconInstancesProps) => { if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } - navigateToWeed(weedInstances[instanceId]?.weed); + if (navigateToWeed(weedInstances[instanceId]?.weed)) { + event.stopPropagation?.(); + } }; return { args={[undefined, undefined, weedInstances.length]} visible={visible} onClick={onClick} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} renderOrder={RenderOrder.weedImages}> { const { bucket, dispatch, visible } = props; - const navigateToWeed = useNavigateToWeed(dispatch, visible); + const navigateToWeed = + useNavigateToWeed(dispatch, visible, props.onSelectObject); // eslint-disable-next-line no-null/no-null const instancedRef = React.useRef(null); const radiusGeometry = getWeedRadiusGeometry(); @@ -349,7 +381,9 @@ const WeedRadiusInstances = (props: WeedRadiusInstancesProps) => { if (clickWasDragged(event)) { return; } const instanceId = event.instanceId; if (isUndefined(instanceId)) { return; } - navigateToWeed(bucket.weeds[instanceId]?.weed); + if (navigateToWeed(bucket.weeds[instanceId]?.weed)) { + event.stopPropagation?.(); + } }; return { dispose={null} visible={visible} onClick={onClick} + onPointerOver={() => props.onHoverObject?.(true)} + onPointerOut={() => props.onHoverObject?.(false)} renderOrder={RenderOrder.weedSpheres}> + !GRID_SELECTION_BLOCKED_MODES.includes(getMode()); const LazyBot = React.lazy(() => import("./bot").then(module => ({ default: module.Bot }))); const LazyVisualization = React.lazy(() => @@ -89,7 +126,7 @@ const ZoomBeaconsLoadIn = (props: ZoomBeaconsLoadInProps) => { opacity: reveal ? 1 : 0, }, immediate: !reveal, - onRest: () => reveal && onRest?.(), + onRest: reveal ? onRest : undefined, config: { tension: 220, friction: 26, @@ -107,6 +144,7 @@ const ZoomBeaconsLoadIn = (props: ZoomBeaconsLoadInProps) => { interface SceneBoundaryProps { markName?: string; loadProgress?: ThreeDLoadProgress; + markStep?: ThreeDLoadProgress["markStep"]; loadStep?: ThreeDLoadStepId; reveal?: boolean; markReadyOnMount?: boolean; @@ -116,15 +154,16 @@ interface SceneBoundaryProps { const SceneBoundary = (props: SceneBoundaryProps) => { const reveal = props.reveal !== false; const markReadyOnMount = props.markReadyOnMount !== false; + const markStep = props.markStep || props.loadProgress?.markStep; return {props.children} - {reveal && markReadyOnMount && props.loadStep && props.loadProgress && + {reveal && markReadyOnMount && props.loadStep && markStep && } + markStep={markStep} />} {reveal && props.markName && } ; }; @@ -135,10 +174,23 @@ export interface GardenModelProps { activeFocus: string; setActiveFocus(focus: string): void; threeDPlants: ThreeDGardenPlant[]; + plants?: TaggedPlant[]; addPlantProps?: AddPlantProps; mapPoints?: TaggedGenericPointer[]; weeds?: TaggedWeedPointer[]; toolSlots?: SlotWithTool[]; + tools?: TaggedTool[]; + sequences?: TaggedSequence[]; + fbosConfig?: TaggedFbosConfig; + timeSettings?: TimeSettings; + botOnline?: boolean; + arduinoBusy?: boolean; + currentBotLocation?: BotPosition; + movementState?: MovementState; + defaultAxes?: string; + noUTM?: boolean; + deviceAccount?: TaggedDevice; + bot?: BotState; mountedToolName?: string | undefined; startTimeRef?: React.RefObject; allPoints?: TaggedPoint[]; @@ -146,6 +198,7 @@ export interface GardenModelProps { images?: TaggedImage[]; sensorReadings?: TaggedSensorReading[]; sensors?: TaggedSensor[]; + env?: UserEnv; smoothFocusTransitions?: boolean; smoothConfigTransitions?: boolean; plantIconCapacities?: Record; @@ -162,9 +215,20 @@ const EMPTY_GENERIC_POINTERS: TaggedGenericPointer[] = []; const EMPTY_WEEDS: TaggedWeedPointer[] = []; const EMPTY_POINTS: TaggedPoint[] = []; const EMPTY_POINT_GROUPS: TaggedPointGroup[] = []; +const EMPTY_PLANTS: TaggedPlant[] = []; +const EMPTY_TOOLS: TaggedTool[] = []; +const EMPTY_TOOL_SLOTS: SlotWithTool[] = []; +const EMPTY_BOT_POSITION: BotPosition = + { x: undefined, y: undefined, z: undefined }; +const EMPTY_MOVEMENT_STATE: MovementState = { + start: EMPTY_BOT_POSITION, + distance: { x: 0, y: 0, z: 0 }, +}; const EMPTY_IMAGES: TaggedImage[] = []; const EMPTY_SENSORS: TaggedSensor[] = []; const EMPTY_SENSOR_READINGS: TaggedSensorReading[] = []; +const EMPTY_SEQUENCES: TaggedSequence[] = []; +const EMPTY_ENV: UserEnv = {}; const smoothConfigSpringConfig = { tension: 160, @@ -292,7 +356,7 @@ function getGardenLayerVisibility( interface StaticGardenLayersProps { config: Config; - loadProgress: ThreeDLoadProgress; + markStep: ThreeDLoadProgress["markStep"]; environmentReveal: boolean; bedReveal: boolean; gridReveal: boolean; @@ -327,12 +391,15 @@ interface StaticGardenLayersProps { showWeeds: boolean; weeds: TaggedWeedPointer[]; showPoints: boolean; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; + onPlantHoverChange(hovered: boolean): void; } // eslint-disable-next-line complexity const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { const { - config, loadProgress, environmentReveal, bedReveal, gridReveal, + config, markStep, environmentReveal, bedReveal, gridReveal, plantsReveal, weedsReveal, pointsReveal, skyRef, activePositionRef, soilSurfaceGeometry, getZ, images, activeFocus, mapPoints, showMoistureMap, showMoistureReadings, sensors, sensorReadings, @@ -340,7 +407,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { plantIconAtlas, setHover, threeDPlants, plantIconCapacities, startTimeRef, dispatch, showSpread, plantInstanceCapacity, routeKey, seasonResetKey, showWeeds, weeds, - showPoints, + showPoints, onSelectObject, onHoverObject, onPlantHoverChange, } = props; const seasonLayerKey = `${config.plants}-${seasonResetKey || 0}`; const gridVisible = config.grid && activeFocus != "Planter bed"; @@ -352,11 +419,23 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { const plantsLayerReveal = plantsReveal && plantsVisible; const weedsLayerReveal = weedsReveal && showWeeds; const pointsLayerReveal = pointsReveal && showPoints; + const handlePlantPointerEnter = React.useCallback((e: ThreeEvent) => { + setHover(true)?.(e); + onPlantHoverChange(true); + }, [onPlantHoverChange, setHover]); + const handlePlantPointerMove = React.useCallback((e: ThreeEvent) => { + setHover(true)?.(e); + onPlantHoverChange(true); + }, [onPlantHoverChange, setHover]); + const handlePlantPointerLeave = React.useCallback((e: ThreeEvent) => { + setHover(false)?.(e); + onPlantHoverChange(false); + }, [onPlantHoverChange, setHover]); return <> @@ -375,7 +454,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { @@ -383,7 +462,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { loadProgress.markStep("bed")} + onRest={() => markStep("bed")} distance={config.bedHeight + config.bedZOffset}> { @@ -410,7 +489,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { loadProgress.markStep("grid")}> + onRest={() => markStep("grid")}> { @@ -428,7 +507,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { key={seasonLayerKey} name={"plants-load-in"} reveal={plantsLayerReveal} - onRest={() => loadProgress.markStep("plants")} + onRest={() => markStep("plants")} distance={200} animateExit={true} hideAfterExit={true}> @@ -440,9 +519,9 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { + onPointerEnter={handlePlantPointerEnter} + onPointerMove={handlePlantPointerMove} + onPointerLeave={handlePlantPointerLeave}> { iconCapacities={plantIconCapacities} plantIconAtlas={plantIconAtlas} startTimeRef={startTimeRef} + onSelectObject={onSelectObject} + onHoverObject={onPlantHoverChange} dispatch={dispatch} /> { activePositionRef={activePositionRef} routeKey={routeKey} getZ={getZ} + onSelectObject={onSelectObject} + onHoverObject={onPlantHoverChange} dispatch={dispatch} /> } @@ -475,7 +558,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { loadProgress.markStep("weeds")} + onRest={() => markStep("weeds")} distance={200} animateExit={true} hideAfterExit={true}> @@ -487,13 +570,15 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { config={config} getZ={getZ} plantIconAtlas={plantIconAtlas} + onSelectObject={onSelectObject} + onHoverObject={onHoverObject} dispatch={dispatch} /> } @@ -501,7 +586,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { loadProgress.markStep("points")} + onRest={() => markStep("points")} distance={200} animateExit={true} hideAfterExit={true}> @@ -512,6 +597,8 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { visible={true} config={config} getZ={getZ} + onSelectObject={onSelectObject} + onHoverObject={onHoverObject} dispatch={dispatch} /> } @@ -519,7 +606,24 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { ; }; -const StaticGardenLayers = React.memo(StaticGardenLayersBase); +const isStaticGardenLayerIgnoredProp = ( + key: keyof StaticGardenLayersProps, +) => + key == "markStep" + || key == "onSelectObject" + || key == "onHoverObject" + || key == "onPlantHoverChange"; + +const staticGardenLayersPropsEqual = ( + prev: StaticGardenLayersProps, + next: StaticGardenLayersProps, +) => + (Object.keys(prev) as (keyof StaticGardenLayersProps)[]) + .every(key => + isStaticGardenLayerIgnoredProp(key) || prev[key] === next[key]); + +const StaticGardenLayers = React.memo( + StaticGardenLayersBase, staticGardenLayersPropsEqual); const ENVIRONMENT_SCENES = ["Outdoor", "Lab", "Greenhouse"] as const; type EnvironmentScene = typeof ENVIRONMENT_SCENES[number]; @@ -581,6 +685,7 @@ const EnvironmentScenePreloader = (props: EnvironmentScenePreloaderProps) => { }; const ignoredLoadStep = (_step: ThreeDLoadStepId) => undefined; +const allowLoadStep = () => true; const farmbotLayerLoadProgress: ThreeDLoadProgress = { readyStepTimes: {}, @@ -588,7 +693,7 @@ const farmbotLayerLoadProgress: ThreeDLoadProgress = { progress: 6 / THREE_D_LOAD_STEPS.length * 100, complete: false, markStep: ignoredLoadStep, - isStepAllowed: () => true, + isStepAllowed: allowLoadStep, }; interface FarmbotLoadInProps { @@ -604,6 +709,8 @@ interface FarmbotLoadInProps { onLoadInComplete(): void; reveal: boolean; toolSlots: SlotWithTool[] | undefined; + onSelectObject?: ThreeDObjectSelectionHandler; + onHoverObject?: ThreeDObjectHoverHandler; } const FarmbotLoadIn = (props: FarmbotLoadInProps) => @@ -626,6 +733,8 @@ const FarmbotLoadIn = (props: FarmbotLoadInProps) => trailReady={props.reveal && props.detailsReveal && props.loadInComplete} activeFocus={props.activeFocus} mountedToolName={props.mountedToolName} + onSelectObject={props.onSelectObject} + onHoverObject={props.onHoverObject} toolSlots={props.toolSlots} /> ; @@ -672,6 +781,8 @@ const FarmbotLayer = (props: FarmbotLayerProps) => { mountedToolName={props.mountedToolName} onExitRest={markFarmbotHidden} onLoadInComplete={markFarmbotLoaded} + onHoverObject={props.onHoverObject} + onSelectObject={props.onSelectObject} reveal={layerReveal} toolSlots={props.toolSlots} /> @@ -712,6 +823,198 @@ const OptionalFarmbotLayer = (props: OptionalFarmbotLayerProps) => { onExitRest={handleExitRest} />; }; +type SceneCursorValue = "grab" | "grabbing" | "pointer" | "crosshair"; + +interface SceneCursorProps { + cursor: SceneCursorValue; +} + +const SceneCursor = (props: SceneCursorProps) => { + const state = useThree(); + React.useEffect(() => { + const targets: HTMLElement[] = []; + const addTarget = (target: EventTarget | undefined) => { + if (target instanceof HTMLElement && !targets.includes(target)) { + targets.push(target); + } + }; + const canvas = state.gl.domElement as HTMLElement | undefined; + addTarget(state.events?.connected as EventTarget | undefined); + addTarget(canvas); + addTarget(canvas?.closest(".garden-bed-3d-model") || undefined); + const previousCursors = targets.map(target => ({ + target, + cursor: target.style.cursor, + })); + targets.forEach(target => { target.style.cursor = props.cursor; }); + return () => previousCursors.forEach(({ target, cursor }) => { + target.style.cursor = cursor; + }); + }, [state, props.cursor]); + return <>; +}; + +interface GridHoverPosition { + x: number; + y: number; +} + +const isPlantIntersectionObject = (object: Object3D | undefined) => { + const plantIndexes = object?.userData?.plantIndexes as unknown; + return Array.isArray(plantIndexes); +}; + +const hasPlantIntersection = (event: ThreeEvent) => + event.intersections.some(intersection => + isPlantIntersectionObject(intersection.object)); + +interface GridHoverTargetProps { + config: Config; + enabled: boolean; + getZ(x: number, y: number): number; + soilSurfaceGeometry: ReturnType["geometry"]; + onHoverPositionChange(position: GridHoverPosition | undefined): void; + onLocationSelect(selection: ThreeDLocationSelection): void; +} + +const inGardenGrid = (config: Config, position: GridHoverPosition) => + position.x >= 0 + && position.x <= config.botSizeX + && position.y >= 0 + && position.y <= config.botSizeY; + +const GridHoverTarget = (props: GridHoverTargetProps) => { + const { + config, enabled, getZ, onHoverPositionChange, onLocationSelect, + soilSurfaceGeometry, + } = props; + const getGardenPosition = React.useMemo(() => + getGardenPositionFunc(config, false), [config]); + const { + bedLengthOuter, bedWidthOuter, bedXOffset, bedYOffset, + columnLength, mirrorX, mirrorY, zGantryOffset, + } = config; + const hoverGeometryConfig = React.useMemo(() => ({ + bedLengthOuter, + bedWidthOuter, + bedXOffset, + bedYOffset, + mirrorX, + mirrorY, + }), [ + bedLengthOuter, + bedWidthOuter, + bedXOffset, + bedYOffset, + mirrorX, + mirrorY, + ]); + const hoverGeometry = React.useMemo(() => + getRenderSoilSurfaceGeometry(hoverGeometryConfig, soilSurfaceGeometry), [ + hoverGeometryConfig, + soilSurfaceGeometry, + ]); + const hoverPosition = React.useMemo((): [number, number, number] => [ + threeSpace(0, bedLengthOuter) + bedXOffset, + threeSpace(0, bedWidthOuter) + bedYOffset, + zZeroFunc({ columnLength, zGantryOffset }) + GRID_HOVER_TARGET_Z_OFFSET, + ], [ + bedLengthOuter, + bedWidthOuter, + bedXOffset, + bedYOffset, + columnLength, + zGantryOffset, + ]); + const getGridPosition = React.useCallback(( + point: { x: number, y: number }, + ): GridHoverPosition | undefined => { + const position = getGardenPosition({ + x: point.x, + y: point.y, + }); + return inGardenGrid(config, position) ? position : undefined; + }, [config, getGardenPosition]); + const updateHover = React.useCallback((event: ThreeEvent) => { + if (!enabled || !gridSelectionAllowed()) { + onHoverPositionChange(undefined); + return; + } + onHoverPositionChange(getGridPosition(event.point)); + }, [enabled, getGridPosition, onHoverPositionChange]); + const clearHover = React.useCallback(() => { + onHoverPositionChange(undefined); + }, [onHoverPositionChange]); + const selectLocation = React.useCallback((event: ThreeEvent) => { + if (!enabled || clickWasDragged(event)) { return; } + if (!gridSelectionAllowed()) { return; } + const position = getGridPosition(event.point); + if (!position) { return; } + event.stopPropagation?.(); + const x = round(position.x); + const y = round(position.y); + onLocationSelect({ + kind: "location", + x, + y, + z: round(getZ(x, y)), + }); + }, [enabled, getGridPosition, getZ, onLocationSelect]); + return + + ; +}; + +interface GridHoverCrosshairsProps { + config: Config; + getZ(x: number, y: number): number; + position: GridHoverPosition; +} + +const GridHoverCrosshairs = (props: GridHoverCrosshairsProps) => { + const { config, position } = props; + const get3DPosition = React.useMemo(() => + get3DPositionFunc(config), [config]); + const zero = get3DPosition({ x: 0, y: 0 }); + const extents = get3DPosition({ x: config.botSizeX, y: config.botSizeY }); + const hover = get3DPosition(position); + const minX = Math.min(zero.x, extents.x); + const maxX = Math.max(zero.x, extents.x); + const minY = Math.min(zero.y, extents.y); + const maxY = Math.max(zero.y, extents.y); + const z = zeroFunc(config).z + props.getZ(position.x, position.y) + 6; + return + + + ; +}; + // eslint-disable-next-line complexity export const GardenModel = (props: GardenModelProps) => { usePerfRenderCount("GardenModel"); @@ -727,10 +1030,16 @@ export const GardenModel = (props: GardenModelProps) => { ? baseConfig : config; const dispatch = addPlantProps?.dispatch; + const routeLocation = useLocation(); + const navigate = useNavigate(); const mapPoints = props.mapPoints || EMPTY_GENERIC_POINTERS; const weeds = props.weeds || EMPTY_WEEDS; const allPoints = props.allPoints || EMPTY_POINTS; const groups = props.groups || EMPTY_POINT_GROUPS; + const plants = props.plants || EMPTY_PLANTS; + const toolSlots = props.toolSlots || EMPTY_TOOL_SLOTS; + const tools = props.tools || EMPTY_TOOLS; + const sequences = props.sequences || EMPTY_SEQUENCES; const images = props.images || EMPTY_IMAGES; const sensors = props.sensors || EMPTY_SENSORS; const sensorReadings = props.sensorReadings || EMPTY_SENSOR_READINGS; @@ -738,6 +1047,37 @@ export const GardenModel = (props: GardenModelProps) => { const [hoveredPlant, setHoveredPlant] = React.useState(undefined); + const [selectableObjectHoverCount, setSelectableObjectHoverCount] = + React.useState(0); + const [plantIntersected, setPlantIntersected] = React.useState(false); + const selectableObjectHovered = selectableObjectHoverCount > 0 || plantIntersected; + const [cameraDragging, setCameraDragging] = React.useState(false); + const [gridHoverPosition, setGridHoverPosition] = + React.useState(undefined); + const setSelectableObjectHover = React.useCallback( + (hovered: boolean) => setSelectableObjectHoverCount(count => + hovered ? count + 1 : Math.max(0, count - 1)), + []); + const handleCameraDragStart = React.useCallback(() => { + setSelectableObjectHoverCount(0); + setCameraDragging(true); + }, []); + const handleCameraDragEnd = React.useCallback(() => { + setCameraDragging(false); + }, []); + const handleScenePointerLeave = React.useCallback(() => { + setSelectableObjectHoverCount(0); + setPlantIntersected(false); + setGridHoverPosition(undefined); + }, []); + const handleScenePointerMove = React.useCallback((event: ThreeEvent) => { + if (config.eventDebug) { + console.log(event.intersections.map(x => x.object.name)); + } + const nextPlantIntersected = hasPlantIntersection(event); + setPlantIntersected(current => + current == nextPlantIntersected ? current : nextPlantIntersected); + }, [config.eventDebug]); const getI = React.useCallback((e: ThreeEvent) => { if (e.buttons) { return -1; } @@ -756,7 +1096,7 @@ export const GardenModel = (props: GardenModelProps) => { const setHover = React.useCallback((active: boolean) => { return config.labelsOnHover ? (e: ThreeEvent) => { - e.stopPropagation(); + e.stopPropagation?.(); const nextHover = active ? getI(e) : undefined; setHoveredPlant(nextHover); } @@ -840,17 +1180,66 @@ export const GardenModel = (props: GardenModelProps) => { const pointsReveal = loadProgress.isStepAllowed("points"); const farmbotReveal = loadProgress.isStepAllowed("farmbot"); const detailsReveal = loadProgress.isStepAllowed("details"); + const gridLoaded = loadProgress.readyStepTimes.grid !== undefined; const detailsRevealNotified = React.useRef(false); const loadCompleteNotified = React.useRef(false); const markLoadStep = loadProgress.markStep; const markDetailsLoaded = React.useCallback(() => { markLoadStep("details"); }, [markLoadStep]); + const [popupSelection, setPopupSelection] = + React.useState(undefined); + const [locationSelection, setLocationSelection] = + React.useState(undefined); + const onSelectObject = React.useCallback(( + selection: ThreeDObjectSelection, + ) => { + setLocationSelection(undefined); + setPopupSelection(selection); + }, []); + const onSelectLocation = React.useCallback(( + selection: ThreeDLocationSelection, + ) => { + setPopupSelection(undefined); + setLocationSelection(selection); + }, []); + const updateLocationSelection = React.useCallback(( + selection: ThreeDLocationSelection, + ) => { + setLocationSelection(selection); + }, []); + const closePopup = React.useCallback(() => { + setPopupSelection(undefined); + setLocationSelection(undefined); + }, []); + const openSelectedObjectPanel = React.useCallback(( + selection: ThreeDObjectSelection, + ) => { + dispatch?.(setPanelOpen3D(true)); + navigate(pathForThreeDSelection(selection)); + closePopup(); + }, [closePopup, dispatch, navigate]); + const openSelectedLocationPanel = React.useCallback(( + selection: ThreeDLocationSelection, + ) => { + dispatch?.(setPanelOpen3D(true)); + navigate(Path.location(selection)); + closePopup(); + }, [closePopup, dispatch, navigate]); React.useEffect(() => { perfMark("garden_model_mounted"); }, []); + React.useEffect(() => { + if (!popupSelection && !locationSelection) { return; } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key == "Escape") { closePopup(); } + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [closePopup, locationSelection, popupSelection]); + React.useEffect(() => { if (!detailsReveal || detailsRevealNotified.current) { return; } detailsRevealNotified.current = true; @@ -879,7 +1268,50 @@ export const GardenModel = (props: GardenModelProps) => { showSpread, showMoistureMap, showMoistureReadings, topDownAtStart, } = layerVisibility; - const routeKey = `${location.pathname}?${location.search}`; + const routeKey = `${routeLocation.pathname}?${routeLocation.search}`; + const routeSelection = React.useMemo( + () => routeSelectionFromPath(routeLocation.pathname), + [routeLocation.pathname]); + const selectedLocation = React.useMemo( + () => routeLocationSelectionFromPath( + routeLocation.pathname, + routeLocation.search, + ), [routeLocation.pathname, routeLocation.search]); + const hoverSelection = React.useMemo(() => + hoverSelectionFromDesigner( + addPlantProps?.designer, + plants, + mapPoints, + weeds, + toolSlots, + ), [ + addPlantProps?.designer, + plants, + mapPoints, + weeds, + toolSlots, + ]); + const visualSelection = popupSelection || hoverSelection || routeSelection; + const gridHoverEnabled = + config.grid && props.activeFocus != "Planter bed" && gridSelectionAllowed(); + const activeGridHoverPosition = gridHoverEnabled + ? gridHoverPosition + : undefined; + const showGridHoverCrosshairs = + gridLoaded + && !!activeGridHoverPosition + && !cameraDragging + && !selectableObjectHovered; + let sceneCursor: SceneCursorValue = "grab"; + if (activeGridHoverPosition) { + sceneCursor = "crosshair"; + } + if (selectableObjectHovered) { + sceneCursor = "pointer"; + } + if (cameraDragging) { + sceneCursor = "grabbing"; + } const soilPointConfig = React.useMemo(() => ({ bedHeight: config.bedHeight, @@ -1013,11 +1445,11 @@ export const GardenModel = (props: GardenModelProps) => { return {/* eslint-disable-next-line no-null/no-null */} console.log(e.intersections.map(x => x.object.name)) - : undefined}> + onPointerMove={handleScenePointerMove} + onPointerLeave={handleScenePointerLeave}> + { enablePan={config.pan} dampingFactor={0.2} {...orbitControlProps} + onStart={handleCameraDragStart} + onEnd={handleCameraDragEnd} minZoom={config.lightsDebug ? 0 : 0.05} maxZoom={10} minDistance={config.lightsDebug ? 50 : 500} @@ -1048,7 +1482,7 @@ export const GardenModel = (props: GardenModelProps) => { complete={detailsReveal} /> { seasonResetKey={props.seasonResetKey} showWeeds={showWeeds} weeds={weeds} + onSelectObject={onSelectObject} + onHoverObject={setSelectableObjectHover} + onPlantHoverChange={setPlantIntersected} showPoints={showPoints} /> + {gridHoverEnabled && + } + {showGridHoverCrosshairs && activeGridHoverPosition && + } { reveal={farmbotReveal} showLoadProgress={props.showFarmbotLayerLoadProgress !== false} toolSlots={props.toolSlots} + onSelectObject={onSelectObject} + onHoverObject={setSelectableObjectHover} visible={farmbotVisible} /> + { @@ -60,17 +81,31 @@ export const ThreeDGarden = React.memo((props: ThreeDGardenProps) => { config={props.config} configPosition={props.configPosition} threeDPlants={props.threeDPlants} + plants={props.plants} activeFocus={""} setActiveFocus={noop} mapPoints={props.mapPoints} weeds={props.weeds} toolSlots={props.toolSlots} + tools={props.tools} + sequences={props.sequences} + fbosConfig={props.fbosConfig} + timeSettings={props.timeSettings} + botOnline={props.botOnline} + arduinoBusy={props.arduinoBusy} + currentBotLocation={props.currentBotLocation} + movementState={props.movementState} + defaultAxes={props.defaultAxes} + noUTM={props.noUTM} + deviceAccount={props.deviceAccount} + bot={props.bot} mountedToolName={props.mountedToolName} allPoints={props.allPoints} groups={props.groups} images={props.images} sensorReadings={props.sensorReadings} sensors={props.sensors} + env={props.env} addPlantProps={props.addPlantProps} /> diff --git a/frontend/three_d_garden/selection.tsx b/frontend/three_d_garden/selection.tsx new file mode 100644 index 000000000..19bcf5a73 --- /dev/null +++ b/frontend/three_d_garden/selection.tsx @@ -0,0 +1,6 @@ +export { ThreeDObjectSelectionLayer } from "./selection/layer"; +export { + hoverSelectionFromDesigner, pathForThreeDSelection, + routeLocationSelectionFromPath, routeSelectionFromPath +} from "./selection/routes"; +export type { ThreeDObjectSelectionLayerProps } from "./selection/props"; diff --git a/frontend/three_d_garden/selection/__tests__/selection_test.tsx b/frontend/three_d_garden/selection/__tests__/selection_test.tsx new file mode 100644 index 000000000..088e8eda2 --- /dev/null +++ b/frontend/three_d_garden/selection/__tests__/selection_test.tsx @@ -0,0 +1,826 @@ +import React from "react"; +import { fireEvent, render, waitFor } from "@testing-library/react"; +import { clone } from "lodash"; +import { + fakeFbosConfig, fakePlant, fakePoint, fakeSequence, fakeTool, + fakeToolSlot, fakeWeed, +} from "../../../__test_support__/fake_state/resources"; +import { fakeDevice } from "../../../__test_support__/resource_index_builder"; +import { fakeMovementState } from "../../../__test_support__/fake_bot_data"; +import { fakeTimeSettings } from "../../../__test_support__/fake_time_settings"; +import { fakeDesignerState } from + "../../../__test_support__/fake_designer_state"; +import { INITIAL, INITIAL_POSITION } from "../../config"; +import { + ThreeDLocationSelection, ThreeDObjectSelection, +} from "../../selection_types"; +import { + hoverSelectionFromDesigner, pathForThreeDSelection, + routeLocationSelectionFromPath, routeSelectionFromPath, +} from "../routes"; +import { + ObjectPopupControls, ObjectPopupDeleteButton, ObjectPopupHeaderColor, + PopupObjectLocationRow, PopupSelectedLocationRow, +} from "../popup_controls"; +import { LocationPopup, ObjectPopup } from "../popups"; +import { SelectedObjectOverlay } from "../overlay"; +import { + ThreeDObjectSelectionLayer, + clearPendingSelectionLayerAnimation, +} from "../layer"; +import { + ResolvedLocationObject, ResolvedThreeDObject, + ResolveSelectedObjectProps, objectHasSelectionOverlay, + resolveLocationObject, resolveSelectedObject, +} from "../resolve"; +import { ThreeDObjectSelectionLayerProps } from "../props"; +import * as toolSlotEditComponents from "../../../tools/tool_slot_edit_components"; +import * as ui from "../../../ui"; +import * as deviceActions from "../../../devices/actions"; +import { SlotWithTool } from "../../../resources/interfaces"; +import { Path } from "../../../internal_urls"; +import { + createRenderer, + unmountRenderer, +} from "../../../__test_support__/test_renderer"; + +const layerProps = (): ThreeDObjectSelectionLayerProps => ({ + config: clone(INITIAL), + configPosition: clone(INITIAL_POSITION), + selection: undefined, + popupSelection: undefined, + locationSelection: undefined, + selectedLocation: undefined, + onClosePopup: jest.fn(), + onOpenPanel: jest.fn(), + onOpenLocationPanel: jest.fn(), + onUpdateLocationSelection: jest.fn(), + plants: [], + points: [], + weeds: [], + toolSlots: [], + tools: [], + sequences: [], + sensors: [], + fbosConfig: undefined, + timeSettings: fakeTimeSettings(), + botOnline: true, + arduinoBusy: false, + currentBotLocation: { x: 10, y: 20, z: 30 }, + movementState: fakeMovementState(), + defaultAxes: "XY", + noUTM: false, + deviceAccount: fakeDevice(), + bot: undefined, + env: {}, + dispatch: jest.fn(), + gridLoaded: true, + getZ: jest.fn(() => 5), +}); + +const resolveProps = (): ResolveSelectedObjectProps => { + const plant = fakePlant(); + plant.body.id = 1; + plant.body.name = undefined as never; + plant.body.radius = 25; + const point = fakePoint(); + point.body.id = 2; + point.body.radius = 10; + const weed = fakeWeed(); + weed.body.id = 3; + weed.body.radius = 0; + const tool = fakeTool(); + tool.body.id = 4; + tool.body.name = "Seeder"; + const staticSlot = fakeToolSlot(); + staticSlot.body.id = 5; + staticSlot.body.tool_id = tool.body.id; + staticSlot.body.x = 100; + staticSlot.body.y = 200; + staticSlot.body.z = 30; + const gantrySlot = fakeToolSlot(); + gantrySlot.body.id = 6; + gantrySlot.body.tool_id = tool.body.id; + gantrySlot.body.gantry_mounted = true; + gantrySlot.body.x = 0; + gantrySlot.body.y = 300; + gantrySlot.body.z = 40; + return { + config: clone(INITIAL), + configPosition: clone(INITIAL_POSITION), + plants: [plant], + points: [point], + weeds: [weed], + toolSlots: [ + { toolSlot: staticSlot, tool: undefined }, + { toolSlot: gantrySlot, tool }, + ], + currentBotLocation: { x: 123, y: undefined, z: undefined }, + deviceAccount: fakeDevice({ name: "FarmBot Prime" }), + getZ: jest.fn(() => 5), + }; +}; + +const objectBase = ( + selection: ThreeDObjectSelection, +) => ({ + selection, + name: selection.kind, + worldPosition: [1, 2, 3] as [number, number, number], + popupPosition: [4, 5, 6] as [number, number, number], + ringRadius: 35, + locationCoordinate: { x: 10, y: 20, z: 30 }, +}); + +const plantObject = (): ResolvedThreeDObject => { + const plant = fakePlant(); + plant.body.id = 1; + plant.body.planted_at = "2024-01-01T00:00:00.000Z"; + return { + kind: "plant", + plant, + ...objectBase({ kind: "plant", id: 1 }), + }; +}; + +const pointObject = (): ResolvedThreeDObject => { + const point = fakePoint(); + point.body.id = 2; + return { + kind: "point", + point, + ...objectBase({ kind: "point", id: 2 }), + }; +}; + +const weedObject = (): ResolvedThreeDObject => { + const weed = fakeWeed(); + weed.body.id = 3; + return { + kind: "weed", + weed, + ...objectBase({ kind: "weed", id: 3 }), + }; +}; + +const slotObject = ( + gantryMounted = false, +): Extract => { + const tool = fakeTool(); + tool.body.id = 4; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 5; + toolSlot.body.tool_id = tool.body.id; + toolSlot.body.gantry_mounted = gantryMounted; + const slot: SlotWithTool = { toolSlot, tool }; + return { + kind: "slot", + slot, + ...objectBase({ kind: "slot", id: 5 }), + }; +}; + +const locationObject = (): ResolvedLocationObject => ({ + kind: "location", + selection: { kind: "location", x: 1, y: 2, z: 3 }, + name: "(1, 2, 3)", + worldPosition: [1, 2, 3], + popupPosition: [4, 5, 6], + ringRadius: 35, + locationCoordinate: { x: 1, y: 2, z: 3 }, +}); + +const cameraObject = (): ResolvedThreeDObject => ({ + kind: "camera", + ...objectBase({ kind: "camera", id: 0 }), +}); + +const blurable = (wrapper: ReturnType, name: string) => + wrapper.root.findAll(node => + node.props.name == name && typeof node.props.onCommit == "function")[0]; + +const commit = ( + wrapper: ReturnType, + name: string, + value: string, +) => + blurable(wrapper, name).props.onCommit({ + currentTarget: { value }, + }); + +describe("selection routes", () => { + it("resolves selections from routes", () => { + expect(routeSelectionFromPath("/app/designer/plants/1")).toEqual({ + kind: "plant", + id: 1, + }); + expect(routeSelectionFromPath("/app/designer/points/2")).toEqual({ + kind: "point", + id: 2, + }); + expect(routeSelectionFromPath("/app/designer/weeds/3")).toEqual({ + kind: "weed", + id: 3, + }); + expect(routeSelectionFromPath("/app/designer/tool-slots/4")).toEqual({ + kind: "slot", + id: 4, + }); + expect(routeSelectionFromPath("/app/controls")).toBeUndefined(); + expect(routeSelectionFromPath("/app/designer/plants/nope")).toBeUndefined(); + expect(routeSelectionFromPath("/app/designer/tools/1")).toBeUndefined(); + }); + + it("resolves selected locations from routes", () => { + expect(routeLocationSelectionFromPath( + "/app/designer/location", + "?x=1.5&y=2.5&z=3.5", + )).toEqual({ kind: "location", x: 1.5, y: 2.5, z: 3.5 }); + expect(routeLocationSelectionFromPath( + "/app/designer/location", + "?x=1.5&y=2.5", + )).toEqual({ kind: "location", x: 1.5, y: 2.5, z: 0 }); + expect(routeLocationSelectionFromPath( + "/app/designer/plants/1", + "?x=1&y=2", + )).toBeUndefined(); + expect(routeLocationSelectionFromPath( + "/app/designer/location", + "?x=bad&y=2", + )).toBeUndefined(); + }); + + it("resolves hovered objects from designer state", () => { + const plant = fakePlant(); + plant.body.id = 1; + const point = fakePoint(); + point.body.id = 2; + const weed = fakeWeed(); + weed.body.id = 3; + const slot = fakeToolSlot(); + slot.body.id = 4; + const designer = fakeDesignerState(); + designer.hoveredPlant.plantUUID = plant.uuid; + expect(hoverSelectionFromDesigner( + designer, [plant], [point], [weed], [{ toolSlot: slot, tool: undefined }], + )).toEqual({ kind: "plant", id: 1 }); + designer.hoveredPlant.plantUUID = undefined; + designer.hoveredPlantListItem = plant.uuid; + expect(hoverSelectionFromDesigner(designer, [plant], [], [], [])) + .toEqual({ kind: "plant", id: 1 }); + designer.hoveredPlantListItem = undefined; + designer.hoveredPoint = point.uuid; + expect(hoverSelectionFromDesigner(designer, [], [point], [weed], [])) + .toEqual({ kind: "point", id: 2 }); + designer.hoveredPoint = weed.uuid; + expect(hoverSelectionFromDesigner(designer, [], [point], [weed], [])) + .toEqual({ kind: "weed", id: 3 }); + designer.hoveredPoint = undefined; + designer.hoveredToolSlot = slot.uuid; + expect(hoverSelectionFromDesigner( + designer, [], [], [], [{ toolSlot: slot, tool: undefined }], + )).toEqual({ kind: "slot", id: 4 }); + slot.body.id = undefined; + expect(hoverSelectionFromDesigner( + designer, [], [], [], [{ toolSlot: slot, tool: undefined }], + )).toBeUndefined(); + }); + + it("builds paths", () => { + expect(pathForThreeDSelection({ kind: "plant", id: 1 })) + .toEqual(Path.plants(1)); + expect(pathForThreeDSelection({ kind: "point", id: 2 })) + .toEqual(Path.points(2)); + expect(pathForThreeDSelection({ kind: "weed", id: 3 })) + .toEqual(Path.weeds(3)); + expect(pathForThreeDSelection({ kind: "slot", id: 4 })) + .toEqual(Path.toolSlots(4)); + expect(pathForThreeDSelection({ kind: "utm", id: 0 })) + .toEqual(Path.tools()); + expect(pathForThreeDSelection({ kind: "electronics", id: 0 })) + .toEqual(Path.settings("farmbot")); + expect(pathForThreeDSelection({ kind: "camera", id: 0 })) + .toEqual(Path.photos()); + }); +}); + +describe("selection resolve", () => { + it("skips missing selected objects", () => { + const props = resolveProps(); + expect(resolveSelectedObject(props, undefined)).toBeUndefined(); + expect(resolveSelectedObject(props, { kind: "plant", id: 999 })) + .toBeUndefined(); + expect(resolveSelectedObject(props, { kind: "point", id: 999 })) + .toBeUndefined(); + expect(resolveSelectedObject(props, { kind: "weed", id: 999 })) + .toBeUndefined(); + expect(resolveSelectedObject(props, { kind: "slot", id: 999 })) + .toBeUndefined(); + }); + + it("resolves plant, point, and weed selections", () => { + const props = resolveProps(); + const plant = resolveSelectedObject(props, { kind: "plant", id: 1 }); + expect(plant?.kind).toEqual("plant"); + expect(plant?.name).toEqual("Plant 1"); + expect(plant?.ringRadius).toEqual(35); + expect(plant?.locationCoordinate).toEqual({ x: 100, y: 200, z: 0 }); + + const point = resolveSelectedObject(props, { kind: "point", id: 2 }); + expect(point?.kind).toEqual("point"); + expect(point?.ringRadius).toEqual(35); + + const weed = resolveSelectedObject(props, { kind: "weed", id: 3 }); + expect(weed?.kind).toEqual("weed"); + expect(weed?.ringRadius).toEqual(50); + }); + + it("resolves slot, UTM, electronics, and camera selections", () => { + const props = resolveProps(); + const staticSlot = resolveSelectedObject(props, { kind: "slot", id: 5 }); + expect(staticSlot?.kind).toEqual("slot"); + expect(staticSlot?.name).toEqual("Empty slot"); + expect(staticSlot?.locationCoordinate.x).toEqual(100); + + const gantrySlot = resolveSelectedObject(props, { kind: "slot", id: 6 }); + expect(gantrySlot?.kind).toEqual("slot"); + expect(gantrySlot?.name).toEqual("Seeder"); + expect(gantrySlot?.locationCoordinate.x).toEqual(123); + + const utm = resolveSelectedObject(props, { kind: "utm", id: 0 }); + expect(utm?.kind).toEqual("utm"); + expect(utm?.locationCoordinate).toEqual({ + x: props.configPosition.x, + y: props.configPosition.y, + z: props.configPosition.z, + }); + + const electronics = + resolveSelectedObject(props, { kind: "electronics", id: 0 }); + expect(electronics?.kind).toEqual("electronics"); + expect(electronics?.name).toEqual("FarmBot Prime"); + + const camera = resolveSelectedObject(props, { kind: "camera", id: 0 }); + expect(camera?.kind).toEqual("camera"); + expect(camera?.name).toEqual("Camera"); + }); + + it("resolves selected locations and overlay eligibility", () => { + const props = resolveProps(); + const selection: ThreeDLocationSelection = { + kind: "location", + x: 1.2, + y: 2.8, + z: 3.4, + }; + const location = resolveLocationObject(props, selection); + expect(resolveLocationObject(props, undefined)).toBeUndefined(); + expect(location?.name).toEqual("(1, 3, 3)"); + expect(location?.locationCoordinate).toEqual({ + x: selection.x, + y: selection.y, + z: selection.z, + }); + expect(objectHasSelectionOverlay(undefined)).toBeFalsy(); + expect(objectHasSelectionOverlay(location)).toBeTruthy(); + expect(objectHasSelectionOverlay( + resolveSelectedObject(props, { kind: "utm", id: 0 }), + )).toBeFalsy(); + expect(objectHasSelectionOverlay( + resolveSelectedObject(props, { kind: "electronics", id: 0 }), + )).toBeFalsy(); + expect(objectHasSelectionOverlay( + resolveSelectedObject(props, { kind: "camera", id: 0 }), + )).toBeFalsy(); + }); +}); + +describe("selection overlay and popups", () => { + it("renders selected object overlays", () => { + const refSpy = jest.spyOn(React, "useRef") + .mockReturnValue({ current: { rotation: { z: 0 } } }); + const visible = render(); + expect(visible.container).toContainHTML("selected-object-overlay"); + expect(visible.container).toContainHTML("selected-object-ring"); + expect(visible.container).toContainHTML("selected-object-x-crosshair"); + expect(visible.container).toContainHTML("selected-object-y-crosshair"); + visible.unmount(); + refSpy.mockRestore(); + const hidden = render(); + expect(hidden.container).not.toContainHTML("selected-object-x-crosshair"); + }); + + it("handles object popup actions", () => { + const p = layerProps(); + const object = { + kind: "utm" as const, + ...objectBase({ kind: "utm", id: 0 }), + }; + const { container } = render(); + const popup = container.querySelector(".three-d-object-popup"); + popup && fireEvent.pointerDown(popup); + popup && fireEvent.contextMenu(popup); + popup && fireEvent.click(popup); + const buttons = container.querySelectorAll("button"); + fireEvent.click(buttons[0]); + fireEvent.click(buttons[1]); + expect(p.onOpenPanel).toHaveBeenCalledWith({ kind: "utm", id: 0 }); + expect(p.onClosePopup).toHaveBeenCalled(); + }); + + it("handles location popup actions", () => { + const p = layerProps(); + const { container } = render(); + expect(container.querySelector(".three-d-object-popup")?.className) + .toContain("hidden"); + const buttons = container.querySelectorAll("button"); + fireEvent.click(buttons[0]); + fireEvent.click(buttons[1]); + expect(p.onOpenLocationPanel).toHaveBeenCalledWith({ + kind: "location", + x: 1, + y: 2, + z: 3, + }); + expect(p.onClosePopup).toHaveBeenCalled(); + }); + + it("animates selection layer popup state", async () => { + const p = layerProps(); + const plant = fakePlant(); + plant.body.id = 1; + p.plants = [plant]; + const { container, rerender } = render(); + expect(container).not.toContainHTML("selected-object-popup"); + rerender(); + await waitFor(() => + expect(container).toContainHTML("three-d-object-popup")); + const point = fakePoint(); + point.body.id = 2; + rerender(); + await waitFor(() => + expect(container).toContainHTML("Point 1")); + rerender(); + await waitFor(() => + expect(container).not.toContainHTML("three-d-object-popup")); + }); + + it("clears pending selection layer animation work", () => { + const clearTimeoutSpy = jest.spyOn(window, "clearTimeout"); + const cancelFrameSpy = jest.spyOn(window, "cancelAnimationFrame") + .mockImplementation(jest.fn()); + clearPendingSelectionLayerAnimation([1], [2]); + expect(clearTimeoutSpy).toHaveBeenCalledWith(1); + expect(cancelFrameSpy).toHaveBeenCalledWith(2); + clearTimeoutSpy.mockRestore(); + cancelFrameSpy.mockRestore(); + }); + +}); + +describe("selection popup controls", () => { + const renderLocationRow = (object: ResolvedThreeDObject) => { + const p = layerProps(); + p.dispatch = jest.fn(); + const wrapper = createRenderer(); + return { p, wrapper }; + }; + + it("updates object coordinates", () => { + [ + plantObject(), + pointObject(), + weedObject(), + slotObject(), + ].forEach(object => { + const { p, wrapper } = renderLocationRow(object); + commit(wrapper, "x", "123"); + expect(p.dispatch).toHaveBeenCalled(); + unmountRenderer(wrapper); + }); + }); + + it("disables object coordinate edits without dispatch and for gantry slots", () => { + const p = layerProps(); + p.dispatch = undefined; + let wrapper = createRenderer(); + expect(wrapper.root.findAll(node => node.type == "input" && + node.props.disabled).length).toEqual(3); + unmountRenderer(wrapper); + p.dispatch = jest.fn(); + wrapper = createRenderer(); + expect(wrapper.root.findAll(node => node.type == "input" && + node.props.name == "x" && node.props.disabled).length).toEqual(1); + unmountRenderer(wrapper); + }); + + it("updates selected location coordinates", () => { + const p = layerProps(); + const wrapper = createRenderer(); + commit(wrapper, "z", "45.6"); + expect(p.onUpdateLocationSelection).toHaveBeenCalledWith({ + kind: "location", + x: 1, + y: 2, + z: 45, + }); + unmountRenderer(wrapper); + }); + + it("renders plant, point, weed, slot, and camera controls", () => { + [ + plantObject(), + pointObject(), + weedObject(), + slotObject(), + cameraObject(), + ].forEach(object => { + const p = layerProps(); + const { container, unmount } = render(); + expect(container).not.toBeEmptyDOMElement(); + unmount(); + }); + }); + + it("updates plant values", () => { + const p = layerProps(); + const controls = render(); + const radius = controls.container.querySelector("input[name='radius']"); + expect(radius).toBeTruthy(); + radius && fireEvent.focus(radius); + radius && fireEvent.change(radius, { + target: { value: "42" }, + currentTarget: { value: "42" }, + }); + radius && fireEvent.blur(radius, { + target: { value: "42" }, + currentTarget: { value: "42" }, + }); + expect(p.dispatch).toHaveBeenCalled(); + controls.unmount(); + }); + + it("updates slot and mounted tool selections", () => { + const p = layerProps(); + const tool = fakeTool(); + tool.body.id = 4; + p.tools = [tool]; + p.toolSlots = [slotObject().slot]; + let controls = render(); + expect(controls.container).not.toBeEmptyDOMElement(); + controls.unmount(); + + p.dispatch = jest.fn(); + p.deviceAccount = fakeDevice({ mounted_tool_id: undefined }); + controls = render(); + const trailToggle = controls.container + .querySelector(".fb-toggle-button"); + trailToggle && fireEvent.click(trailToggle); + expect(p.dispatch).toHaveBeenCalled(); + controls.unmount(); + + const toolSelectionSpy = + jest.spyOn(toolSlotEditComponents, "ToolSelection") + .mockImplementation(((props: React.ComponentProps< + typeof toolSlotEditComponents.ToolSelection + >) => { + props.isActive(4); + return + +
+ + props.dispatch?.(setWebAppConfigValue( + BooleanSetting.show_camera_view_area, !props.config.cameraView))} + disabled={!props.dispatch} + title={`${t("toggle")} ${t(DeviceSetting.cameraView)}`} + customText={{ textFalse: t("off"), textTrue: t("on") }} /> +
+ ; +}; + +interface ElectronicsPopupButtonRowProps { + botOnline: boolean; + label: DeviceSetting; + description: string; + buttonText: string; + color: string; + action(): void; +} + +const ElectronicsPopupButtonRow = (props: ElectronicsPopupButtonRowProps) => +
+
+ + +
+ +
; + +const sequence2DropdownItem = ( + sequence: TaggedSequence, +): DropDownItem | undefined => { + const emptyScope = (sequence.body.args.locals.body || []).length == 0; + if (emptyScope && sequence.body.id) { + return { label: sequence.body.name, value: sequence.body.id }; + } +}; + +interface PopupBootSequenceSelectorProps { + dispatch: Function | undefined; + fbosConfig: TaggedFbosConfig | undefined; + sequences: TaggedSequence[]; +} + +const disabledBootSequenceRow = () => +
+ + +
; + +const PopupBootSequenceSelector = (props: PopupBootSequenceSelectorProps) => { + const { dispatch, fbosConfig, sequences } = props; + if (!dispatch || !fbosConfig) { return disabledBootSequenceRow(); } + const list = betterCompact(sequences.map(sequence2DropdownItem)); + const bootSequenceId = fbosConfig.body.boot_sequence_id; + const selectedSequence = sequences.filter(sequence => + sequence.body.id == bootSequenceId)[0]; + const selectedItem = selectedSequence + ? sequence2DropdownItem(selectedSequence) + : undefined; + const firmwareHardware: FirmwareHardware | undefined = + getFwHardwareValue(fbosConfig); + return
+ + { + const boot_sequence_id = selected.isNull + ? undefined + : selected.value as number; + dispatch(edit(fbosConfig, { boot_sequence_id })); + dispatch(save(fbosConfig.uuid)); + }} /> +
; +}; + +const ElectronicsPopupControls = (props: PopupControlProps) => { + if (props.object.kind != "electronics") { return undefined; } + return
+ + + +
; +}; + +export const ObjectPopupControls = (props: PopupControlProps) => { + switch (props.object.kind) { + case "plant": return ; + case "point": return ; + case "weed": return ; + case "slot": return ; + case "utm": return ; + case "electronics": return ; + case "camera": return ; + } +}; + +export const ObjectPopupHeaderColor = (props: PopupControlProps) => { + if (!props.dispatch) { return undefined; } + const point = (() => { + switch (props.object.kind) { + case "point": return props.object.point; + case "weed": return props.object.weed; + default: return undefined; + } + })(); + if (!point) { return undefined; } + const update = updatePoint(point, props.dispatch); + return ; +}; + +type DeletableResolvedThreeDObject = Exclude< + ResolvedThreeDObject, + { kind: "utm" } | { kind: "electronics" } | { kind: "camera" } +>; + +const objectUuid = (object: DeletableResolvedThreeDObject) => { + switch (object.kind) { + case "plant": return object.plant.uuid; + case "point": return object.point.uuid; + case "weed": return object.weed.uuid; + case "slot": return object.slot.toolSlot.uuid; + } +}; + +export const ObjectPopupDeleteButton = (props: PopupControlProps) => { + const object = props.object; + if (!props.dispatch + || object.kind == "utm" + || object.kind == "electronics" + || object.kind == "camera") { + return undefined; + } + return } - {threeDGarden && - } - {threeDGarden && - } -
-
- - {threeDGarden && - } -
- dispatch(setWebAppConfigValue( - BooleanSetting.three_d_garden, !threeDGarden))} /> -
- ; -}; From 5361461792aa53932ccfe9b80fb7bf50bc75faab Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 4 Jun 2026 21:44:34 -0700 Subject: [PATCH 04/18] zoom in/out of cursor location --- frontend/three_d_garden/garden_model.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 0b2863388..1be99e0bc 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -1468,6 +1468,7 @@ export const GardenModel = (props: GardenModelProps) => { maxAzimuthAngle={topDownCameraAngle} enableRotate={config.rotate} enableZoom={config.zoom} + zoomToCursor={true} enablePan={config.pan} dampingFactor={0.2} {...orbitControlProps} From 0e9a71bb35752666c80388c1421fbffeb7847a71 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 4 Jun 2026 22:00:03 -0700 Subject: [PATCH 05/18] fix plant selectability load bug --- frontend/three_d_garden/garden/plant_instances.tsx | 11 +++++++++++ frontend/three_d_garden/garden/plants.tsx | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 985a44049..8e771e3ad 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -6,6 +6,7 @@ import { Quaternion, Vector3, MeshBasicMaterial as ThreeMeshBasicMaterial, + type Object3D, type Intersection, type Raycaster, } from "three"; @@ -137,10 +138,20 @@ const plantIconRaycast = function ( raycaster: Raycaster, intersects: Intersection[], ) { + if (!objectVisible(this)) { return; } if (HOVER_OBJECT_MODES.includes(getMode())) { return; } ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects); }; +const objectVisible = (object: Object3D) => { + let current: Object3D | undefined = object; + while (current) { + if (current.visible === false) { return false; } + current = current.parent || undefined; + } + return true; +}; + const useStaticPlantIconInstances = ( plants: ThreeDGardenPlant[], config: Config, diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 5a443b473..83f02fe6b 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -12,6 +12,7 @@ import { Matrix4, Quaternion, InstancedBufferAttribute, + type Object3D, type Raycaster, type Intersection, } from "three"; @@ -178,10 +179,20 @@ const plantSpreadRaycast = function ( raycaster: Raycaster, intersects: Intersection[], ) { + if (!objectVisible(this)) { return; } if (HOVER_OBJECT_MODES.includes(getMode())) { return; } ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects); }; +const objectVisible = (object: Object3D) => { + let current: Object3D | undefined = object; + while (current) { + if (current.visible === false) { return false; } + current = current.parent || undefined; + } + return true; +}; + interface StaticPlantSpreadInstance { id?: number; x: number; From 62a0e4b6322a70a09af0fbf9f413022c596ce63f Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 4 Jun 2026 22:58:28 -0700 Subject: [PATCH 06/18] add 3D support to /plants/select panel --- frontend/three_d_garden/bot/bot.tsx | 10 +- .../bot/components/electronics_box.tsx | 4 +- .../three_d_garden/bot/components/tools.tsx | 27 ++- .../three_d_garden/garden/plant_instances.tsx | 5 +- frontend/three_d_garden/garden/plants.tsx | 5 +- frontend/three_d_garden/garden/point.tsx | 22 +- frontend/three_d_garden/garden/weed.tsx | 8 +- frontend/three_d_garden/garden_model.tsx | 114 ++++++++-- frontend/three_d_garden/selection.tsx | 5 +- frontend/three_d_garden/selection/layer.tsx | 29 ++- frontend/three_d_garden/selection/overlay.tsx | 200 ++++++++++++++---- frontend/three_d_garden/selection/props.ts | 1 + frontend/three_d_garden/selection/routes.ts | 70 +++++- frontend/three_d_garden/selection_types.ts | 2 +- 14 files changed, 415 insertions(+), 87 deletions(-) diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index b713fd3f6..f67e86bcc 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -121,6 +121,7 @@ export interface FarmbotModelProps { dispatch?: Function; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onToolSlotHoverObject?: ThreeDObjectHoverHandler; } interface RequestedShapes { @@ -677,8 +678,8 @@ const BotVerticalToolheadSubassemblyBase = return; } if (props.onSelectObject) { - event.stopPropagation?.(); - props.onSelectObject({ kind: "utm", id: 0 }); + props.onSelectObject({ kind: "utm", id: 0 }) !== false && + event.stopPropagation?.(); } }; const selectCamera = (event: ThreeEvent) => { @@ -687,8 +688,8 @@ const BotVerticalToolheadSubassemblyBase = return; } if (props.onSelectObject) { - event.stopPropagation?.(); - props.onSelectObject({ kind: "camera", id: 0 }); + props.onSelectObject({ kind: "camera", id: 0 }) !== false && + event.stopPropagation?.(); } }; const utmComponent = { toolSlots={props.toolSlots} onSelectObject={props.onSelectObject} onHoverObject={props.onHoverObject} + onToolSlotHoverObject={props.onToolSlotHoverObject} mountedToolName={props.mountedToolName} /> {config.waterFlow && diff --git a/frontend/three_d_garden/bot/components/electronics_box.tsx b/frontend/three_d_garden/bot/components/electronics_box.tsx index e8fa7fa09..a14396707 100644 --- a/frontend/three_d_garden/bot/components/electronics_box.tsx +++ b/frontend/three_d_garden/bot/components/electronics_box.tsx @@ -151,8 +151,8 @@ const ElectronicsBoxBase = (props: ElectronicsBoxProps) => { return; } if (onSelectObject) { - event.stopPropagation?.(); - onSelectObject({ kind: "electronics", id: 0 }); + onSelectObject({ kind: "electronics", id: 0 }) !== false && + event.stopPropagation?.(); } }, [onSelectObject]); const hoverElectronics = React.useCallback(( diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 885583f7f..043fb514a 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -33,7 +33,8 @@ import { ThreeEvent, useFrame } from "@react-three/fiber"; import { Model, ModelMesh } from "../../model_mesh"; import { SuctionAnimations } from "./suction_animation"; import { - ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, + ThreeDObjectHoverHandler, ThreeDObjectSelection, + ThreeDObjectSelectionHandler, } from "../../selection_types"; import { getToolPositionHelpers, getToolRenderPosition, ToolPositionHelpers, @@ -88,6 +89,7 @@ export interface ToolsProps { getZ(x: number, y: number): number; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onToolSlotHoverObject?: ThreeDObjectHoverHandler; } export interface ThreeDTool { @@ -126,6 +128,7 @@ export const toolsPropsEqual = (prev: ToolsProps, next: ToolsProps) => prev.getZ === next.getZ && prev.onSelectObject === next.onSelectObject && prev.onHoverObject === next.onHoverObject && + prev.onToolSlotHoverObject === next.onToolSlotHoverObject && prev.configPosition.x === next.configPosition.x && prev.configPosition.y === next.configPosition.y && prev.configPosition.z === next.configPosition.z && @@ -208,6 +211,7 @@ const ToolsBase = (props: ToolsProps) => { dispatch={props.dispatch} onSelectObject={props.onSelectObject} onHoverObject={props.onHoverObject} + onToolSlotHoverObject={props.onToolSlotHoverObject} positionHelpers={positionHelpers} mountedToolName={mountedToolName} x={props.configPosition.x} @@ -223,6 +227,7 @@ const ToolsBase = (props: ToolsProps) => { dispatch={props.dispatch} onSelectObject={props.onSelectObject} onHoverObject={props.onHoverObject} + onToolSlotHoverObject={props.onToolSlotHoverObject} positionHelpers={positionHelpers} mountedToolName={mountedToolName} {...tool} @@ -329,6 +334,13 @@ interface ToolbaySlotProps { onHoverObject?: ThreeDObjectHoverHandler; } +const stopPropagationForSelectedSlot = ( + event: ThreeEvent, + onSelectObject: ThreeDObjectSelectionHandler, + selection: ThreeDObjectSelection, +) => + onSelectObject(selection) !== false && event.stopPropagation?.(); + const useToolSlotClick = (props: ToolbaySlotProps) => { const navigate = useNavigate(); return (event: ThreeEvent) => { @@ -336,13 +348,14 @@ const useToolSlotClick = (props: ToolbaySlotProps) => { const utmSelection = !props.inToolbay; if ((props.id || utmSelection) && (props.dispatch || props.onSelectObject) && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - event.stopPropagation?.(); if (props.onSelectObject) { - props.onSelectObject(props.id + const selection: ThreeDObjectSelection = props.id ? { kind: "slot", id: props.id } - : { kind: "utm", id: 0 }); + : { kind: "utm", id: 0 }; + stopPropagationForSelectedSlot(event, props.onSelectObject, selection); return; } + event.stopPropagation?.(); if (props.id) { props.dispatch?.(setPanelOpen3D(true)); navigate(Path.toolSlots(props.id)); @@ -416,6 +429,7 @@ interface ToolProps extends ThreeDTool { dispatch?: Function; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onToolSlotHoverObject?: ThreeDObjectHoverHandler; positionHelpers: ToolPositionHelpers; } @@ -640,10 +654,13 @@ const ToolBase = (props: ToolProps) => { const mounted = inToolbay && props.toolName == mountedToolName; const position = getToolRenderPosition(config, props, inToolbay, props.positionHelpers); + const onHoverObject = inToolbay + ? props.onToolSlotHoverObject || props.onHoverObject + : props.onHoverObject; const common: ToolbaySlotProps = { mounted, position, toolPulloutDirection, id, inToolbay, config, dispatch, onSelectObject: props.onSelectObject, - onHoverObject: props.onHoverObject, + onHoverObject, }; switch (props.toolName) { case ToolName.rotaryTool: diff --git a/frontend/three_d_garden/garden/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx index 8e771e3ad..e52e38d53 100644 --- a/frontend/three_d_garden/garden/plant_instances.tsx +++ b/frontend/three_d_garden/garden/plant_instances.tsx @@ -294,11 +294,12 @@ const usePlantIconClick = ( const plant = plants[instanceId]; if (plant?.id && (dispatch || onSelectObject) && visible && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - event.stopPropagation?.(); if (onSelectObject) { - onSelectObject({ kind: "plant", id: plant.id }); + onSelectObject({ kind: "plant", id: plant.id }) !== false && + event.stopPropagation?.(); return; } + event.stopPropagation?.(); dispatch?.(setPanelOpen3D(true)); navigate(Path.plants(plant.id)); } diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index 83f02fe6b..a65a6afe0 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -479,11 +479,12 @@ const PlantSpreadInstancesBase = (props: PlantSpreadInstancesProps) => { const plant = plants[instanceId]; if (plant?.id && (dispatch || props.onSelectObject) && visible && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - event.stopPropagation?.(); if (props.onSelectObject) { - props.onSelectObject({ kind: "plant", id: plant.id }); + props.onSelectObject({ kind: "plant", id: plant.id }) !== false && + event.stopPropagation?.(); return; } + event.stopPropagation?.(); dispatch?.(setPanelOpen3D(true)); navigate(Path.plants(plant.id)); } diff --git a/frontend/three_d_garden/garden/point.tsx b/frontend/three_d_garden/garden/point.tsx index 9d493c60f..8c402b596 100644 --- a/frontend/three_d_garden/garden/point.tsx +++ b/frontend/three_d_garden/garden/point.tsx @@ -35,7 +35,8 @@ import { } from "../bed/objects/pointer_objects"; import { clickWasDragged } from "../click_event"; import { - ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, + ThreeDObjectHoverHandler, ThreeDObjectSelection, + ThreeDObjectSelectionHandler, } from "../selection_types"; const POINT_PIN_RADIUS = 12.5; @@ -47,6 +48,13 @@ export const POINT_CYLINDER_SCALE_FACTOR = round(1 / POINT_CYLINDER_TUBE_SIZE ** 2); const SEGMENTS = 64; +const stopPropagationForSelectedPoint = ( + event: ThreeEvent, + onSelectObject: ThreeDObjectSelectionHandler, + selection: ThreeDObjectSelection, +) => + onSelectObject(selection) !== false && event.stopPropagation?.(); + const makePointMarkerGeometry = () => { const pinGeometry = new CylinderGeometry( POINT_PIN_RADIUS, @@ -117,11 +125,13 @@ export const Point = (props: PointProps) => { if (point.body.id && (props.dispatch || props.onSelectObject) && props.visible && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - event.stopPropagation?.(); if (props.onSelectObject) { - props.onSelectObject({ kind: "point", id: point.body.id }); + stopPropagationForSelectedPoint(event, props.onSelectObject, { + kind: "point", id: point.body.id, + }); return; } + event.stopPropagation?.(); props.dispatch?.(setPanelOpen3D(true)); navigate(Path.points(point.body.id)); } @@ -261,11 +271,13 @@ const PointBucketInstances = (props: PointInstanceBucketProps) => { const point = instances[instanceId]?.point; if (point?.body.id && (dispatch || props.onSelectObject) && visible && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - event.stopPropagation?.(); if (props.onSelectObject) { - props.onSelectObject({ kind: "point", id: point.body.id }); + stopPropagationForSelectedPoint(event, props.onSelectObject, { + kind: "point", id: point.body.id, + }); return; } + event.stopPropagation?.(); dispatch?.(setPanelOpen3D(true)); navigate(Path.points(point.body.id)); } diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index 323e05783..08883bb32 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -64,11 +64,12 @@ export const Weed = (props: WeedProps) => { if (weed.body.id && (props.dispatch || props.onSelectObject) && props.visible && ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - event.stopPropagation?.(); if (props.onSelectObject) { - props.onSelectObject({ kind: "weed", id: weed.body.id }); + props.onSelectObject({ kind: "weed", id: weed.body.id }) !== false && + event.stopPropagation?.(); return; } + event.stopPropagation?.(); props.dispatch?.(setPanelOpen3D(true)); navigate(Path.weeds(weed.body.id)); } @@ -247,8 +248,7 @@ const useNavigateToWeed = ( return false; } if (onSelectObject) { - onSelectObject({ kind: "weed", id: weed.body.id }); - return true; + return onSelectObject({ kind: "weed", id: weed.body.id }) !== false; } const dispatchPanelOpen = dispatch as Function; dispatchPanelOpen(setPanelOpen3D(true)); diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 1be99e0bc..a561658bd 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -79,9 +79,11 @@ import { BotPosition, BotState, UserEnv } from "../devices/interfaces"; import { MovementState, TimeSettings } from "../interfaces"; import { Path } from "../internal_urls"; import { - hoverSelectionFromDesigner, pathForThreeDSelection, + createSelectionLookup, hoverSelectionFromDesigner, pathForThreeDSelection, routeLocationSelectionFromPath, routeSelectionFromPath, + selectionForUuid, selectionKindAllowed, ThreeDObjectSelectionLayer, + uuidForSelection, } from "./selection"; import { ThreeDLocationSelection, ThreeDObjectHoverHandler, ThreeDObjectSelection, @@ -93,11 +95,13 @@ import { zero as zeroFunc, zZero as zZeroFunc, } from "./helpers"; import { clickWasDragged } from "./click_event"; +import { clickMapPlant } from "../farm_designer/map/actions"; const AnimatedGroup = animated(Group); const GRID_HOVER_TARGET_Z_OFFSET = 1; const GRID_SELECTION_BLOCKED_MODES = [ ...HOVER_OBJECT_MODES, + Mode.boxSelect, Mode.cameraSelection, ]; const gridSelectionAllowed = () => @@ -391,6 +395,9 @@ interface StaticGardenLayersProps { showWeeds: boolean; weeds: TaggedWeedPointer[]; showPoints: boolean; + plantsSelectable: boolean; + pointsSelectable: boolean; + weedsSelectable: boolean; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; onPlantHoverChange(hovered: boolean): void; @@ -407,7 +414,8 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { plantIconAtlas, setHover, threeDPlants, plantIconCapacities, startTimeRef, dispatch, showSpread, plantInstanceCapacity, routeKey, seasonResetKey, showWeeds, weeds, - showPoints, onSelectObject, onHoverObject, onPlantHoverChange, + showPoints, plantsSelectable, pointsSelectable, weedsSelectable, + onSelectObject, onHoverObject, onPlantHoverChange, } = props; const seasonLayerKey = `${config.plants}-${seasonResetKey || 0}`; const gridVisible = config.grid && activeFocus != "Planter bed"; @@ -519,9 +527,9 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { + onPointerEnter={plantsSelectable ? handlePlantPointerEnter : undefined} + onPointerMove={plantsSelectable ? handlePlantPointerMove : undefined} + onPointerLeave={plantsSelectable ? handlePlantPointerLeave : undefined}> { iconCapacities={plantIconCapacities} plantIconAtlas={plantIconAtlas} startTimeRef={startTimeRef} - onSelectObject={onSelectObject} - onHoverObject={onPlantHoverChange} - dispatch={dispatch} /> + onSelectObject={plantsSelectable ? onSelectObject : undefined} + onHoverObject={plantsSelectable ? onPlantHoverChange : undefined} + dispatch={plantsSelectable ? dispatch : undefined} /> { activePositionRef={activePositionRef} routeKey={routeKey} getZ={getZ} - onSelectObject={onSelectObject} - onHoverObject={onPlantHoverChange} - dispatch={dispatch} /> + onSelectObject={plantsSelectable ? onSelectObject : undefined} + onHoverObject={plantsSelectable ? onPlantHoverChange : undefined} + dispatch={plantsSelectable ? dispatch : undefined} /> }
@@ -570,9 +578,9 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { config={config} getZ={getZ} plantIconAtlas={plantIconAtlas} - onSelectObject={onSelectObject} - onHoverObject={onHoverObject} - dispatch={dispatch} /> + onSelectObject={weedsSelectable ? onSelectObject : undefined} + onHoverObject={weedsSelectable ? onHoverObject : undefined} + dispatch={weedsSelectable ? dispatch : undefined} />
} @@ -597,9 +605,9 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { visible={true} config={config} getZ={getZ} - onSelectObject={onSelectObject} - onHoverObject={onHoverObject} - dispatch={dispatch} /> + onSelectObject={pointsSelectable ? onSelectObject : undefined} + onHoverObject={pointsSelectable ? onHoverObject : undefined} + dispatch={pointsSelectable ? dispatch : undefined} />
} @@ -711,6 +719,7 @@ interface FarmbotLoadInProps { toolSlots: SlotWithTool[] | undefined; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onToolSlotHoverObject?: ThreeDObjectHoverHandler; } const FarmbotLoadIn = (props: FarmbotLoadInProps) => @@ -735,6 +744,7 @@ const FarmbotLoadIn = (props: FarmbotLoadInProps) => mountedToolName={props.mountedToolName} onSelectObject={props.onSelectObject} onHoverObject={props.onHoverObject} + onToolSlotHoverObject={props.onToolSlotHoverObject} toolSlots={props.toolSlots} /> ; @@ -782,6 +792,7 @@ const FarmbotLayer = (props: FarmbotLayerProps) => { onExitRest={markFarmbotHidden} onLoadInComplete={markFarmbotLoaded} onHoverObject={props.onHoverObject} + onToolSlotHoverObject={props.onToolSlotHoverObject} onSelectObject={props.onSelectObject} reveal={layerReveal} toolSlots={props.toolSlots} /> @@ -1044,6 +1055,25 @@ export const GardenModel = (props: GardenModelProps) => { const sensors = props.sensors || EMPTY_SENSORS; const sensorReadings = props.sensorReadings || EMPTY_SENSOR_READINGS; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; + const selectionPanelOpen = getMode() == Mode.boxSelect; + const selectionPointType = addPlantProps?.designer.selectionPointType; + const kindSelectable = (kind: ThreeDObjectSelection["kind"]) => + !selectionPanelOpen || selectionKindAllowed(kind, selectionPointType); + const plantsSelectable = kindSelectable("plant"); + const pointsSelectable = kindSelectable("point"); + const weedsSelectable = kindSelectable("weed"); + const slotsSelectable = kindSelectable("slot"); + const selectionLookup = React.useMemo(() => createSelectionLookup({ + plants, + points: mapPoints, + weeds, + toolSlots, + }), [ + mapPoints, + plants, + toolSlots, + weeds, + ]); const [hoveredPlant, setHoveredPlant] = React.useState(undefined); @@ -1074,10 +1104,11 @@ export const GardenModel = (props: GardenModelProps) => { if (config.eventDebug) { console.log(event.intersections.map(x => x.object.name)); } - const nextPlantIntersected = hasPlantIntersection(event); + const nextPlantIntersected = + plantsSelectable && hasPlantIntersection(event); setPlantIntersected(current => current == nextPlantIntersected ? current : nextPlantIntersected); - }, [config.eventDebug]); + }, [config.eventDebug, plantsSelectable]); const getI = React.useCallback((e: ThreeEvent) => { if (e.buttons) { return -1; } @@ -1194,9 +1225,25 @@ export const GardenModel = (props: GardenModelProps) => { const onSelectObject = React.useCallback(( selection: ThreeDObjectSelection, ) => { + if (selectionPanelOpen) { + const uuid = uuidForSelection(selectionLookup, selection); + if (uuid && selectionKindAllowed(selection.kind, selectionPointType)) { + dispatch?.(clickMapPlant(uuid)); + setLocationSelection(undefined); + setPopupSelection(undefined); + return true; + } + return false; + } setLocationSelection(undefined); setPopupSelection(selection); - }, []); + return true; + }, [ + dispatch, + selectionLookup, + selectionPanelOpen, + selectionPointType, + ]); const onSelectLocation = React.useCallback(( selection: ThreeDLocationSelection, ) => { @@ -1268,6 +1315,20 @@ export const GardenModel = (props: GardenModelProps) => { showSpread, showMoistureMap, showMoistureReadings, topDownAtStart, } = layerVisibility; + const selectedObjectSelections = React.useMemo(() => { + const selectedPoints = addPlantProps?.designer.selectedPoints; + if (!selectionPanelOpen || !selectedPoints) { return undefined; } + const selections: ThreeDObjectSelection[] = []; + selectedPoints.forEach(uuid => { + const selection = selectionForUuid(selectionLookup, uuid); + if (selection) { selections.push(selection); } + }); + return selections; + }, [ + addPlantProps?.designer.selectedPoints, + selectionLookup, + selectionPanelOpen, + ]); const routeKey = `${routeLocation.pathname}?${routeLocation.search}`; const routeSelection = React.useMemo( () => routeSelectionFromPath(routeLocation.pathname), @@ -1516,6 +1577,9 @@ export const GardenModel = (props: GardenModelProps) => { seasonResetKey={props.seasonResetKey} showWeeds={showWeeds} weeds={weeds} + plantsSelectable={plantsSelectable} + pointsSelectable={pointsSelectable} + weedsSelectable={weedsSelectable} onSelectObject={onSelectObject} onHoverObject={setSelectableObjectHover} onPlantHoverChange={setPlantIntersected} @@ -1538,7 +1602,7 @@ export const GardenModel = (props: GardenModelProps) => { config={config} configPosition={props.configPosition} detailsReveal={detailsReveal} - dispatch={dispatch} + dispatch={selectionPanelOpen ? undefined : dispatch} getZ={getZ} loadProgress={loadProgress} markStep={markLoadStep} @@ -1546,13 +1610,17 @@ export const GardenModel = (props: GardenModelProps) => { reveal={farmbotReveal} showLoadProgress={props.showFarmbotLayerLoadProgress !== false} toolSlots={props.toolSlots} - onSelectObject={onSelectObject} - onHoverObject={setSelectableObjectHover} + onSelectObject={slotsSelectable ? onSelectObject : undefined} + onHoverObject={selectionPanelOpen ? undefined : setSelectableObjectHover} + onToolSlotHoverObject={selectionPanelOpen && slotsSelectable + ? setSelectableObjectHover + : undefined} visible={farmbotVisible} /> { : selectionKey(object.selection); }; +interface SelectedObjectRingBatchProps { + objects: React.ComponentProps["objects"]; +} + +const SelectedObjectRingBatch = (props: SelectedObjectRingBatchProps) => + props.objects.length > 0 + ? + : <>; + export const clearPendingSelectionLayerAnimation = ( timeouts: number[], frames: number[], @@ -55,6 +64,16 @@ export const ThreeDObjectSelectionLayer = ( const selectedObject = React.useMemo( () => resolveSelectedObject(resolverProps, props.selection), [props.selection, resolverProps]); + const selectedOverlayObjects = React.useMemo(() => { + const objects = [] as NonNullable[]; + props.selectedObjects?.forEach(selection => { + const object = resolveSelectedObject(resolverProps, selection); + if (object && objectHasSelectionOverlay(object)) { + objects.push(object); + } + }); + return objects; + }, [props.selectedObjects, resolverProps]); const popupObject = React.useMemo( () => resolveSelectedObject(resolverProps, props.popupSelection), [props.popupSelection, resolverProps]); @@ -70,6 +89,13 @@ export const ThreeDObjectSelectionLayer = ( const overlayObject = objectHasSelectionOverlay(selectedOverlayObject) ? selectedOverlayObject : undefined; + const overlayObjectKey = overlayObject && overlayObject.kind != "location" + ? selectionKey(overlayObject.selection) + : ""; + const selectedRings = React.useMemo(() => + selectedOverlayObjects.filter(object => + selectionKey(object.selection) != overlayObjectKey), + [overlayObjectKey, selectedOverlayObjects]); const [renderedPopupObject, setRenderedPopupObject] = React.useState(activePopupObject); const [popupVisible, setPopupVisible] = React.useState(false); @@ -120,6 +146,7 @@ export const ThreeDObjectSelectionLayer = ( ]); return <> + {overlayObject && - range(65).map(index => { - const angle = index / 64 * Math.PI * 2; - return [ - round(Math.cos(angle) * radius, 3), - round(Math.sin(angle) * radius, 3), - 0, - ]; +const RING_SEGMENTS = 192; +const RING_Z_OFFSET = 6; +const RING_THICKNESS = 4; +const RING_DASH_SIZE = 14; +const RING_GAP_SIZE = 10; +const RING_DASH_SPEED = 45; +const FULL_CIRCLE = Math.PI * 2; + +type RingObject = Pick; + +const createRingGeometry = (objects: RingObject[]) => { + const vertexCount = (RING_SEGMENTS + 1) * 2; + const geometry = new InstancedBufferGeometry(); + const positions = new Float32Array(vertexCount * 3); + const angles = new Float32Array(vertexCount); + const sides = new Float32Array(vertexCount); + const indices = new Uint16Array(RING_SEGMENTS * 6); + + range(RING_SEGMENTS + 1).forEach(index => { + const angle = index / RING_SEGMENTS * FULL_CIRCLE; + const vertexIndex = index * 2; + angles[vertexIndex] = angle; + angles[vertexIndex + 1] = angle; + sides[vertexIndex] = -1; + sides[vertexIndex + 1] = 1; + }); + range(RING_SEGMENTS).forEach(index => { + const vertexIndex = index * 2; + const indexOffset = index * 6; + indices[indexOffset] = vertexIndex; + indices[indexOffset + 1] = vertexIndex + 2; + indices[indexOffset + 2] = vertexIndex + 1; + indices[indexOffset + 3] = vertexIndex + 1; + indices[indexOffset + 4] = vertexIndex + 2; + indices[indexOffset + 5] = vertexIndex + 3; + }); + + const instancePositions = new Float32Array(objects.length * 3); + const instanceRadii = new Float32Array(objects.length); + objects.forEach((object, index) => { + const [x, y, z] = object.worldPosition; + const offset = index * 3; + instancePositions[offset] = x; + instancePositions[offset + 1] = y; + instancePositions[offset + 2] = z + RING_Z_OFFSET; + instanceRadii[index] = object.ringRadius; + }); + + geometry.setIndex(new BufferAttribute(indices, 1)); + geometry.setAttribute("position", new BufferAttribute(positions, 3)); + geometry.setAttribute("ringAngle", new BufferAttribute(angles, 1)); + geometry.setAttribute("ringSide", new BufferAttribute(sides, 1)); + geometry.setAttribute("ringInstancePosition", + new InstancedBufferAttribute(instancePositions, 3)); + geometry.setAttribute("ringInstanceRadius", + new InstancedBufferAttribute(instanceRadii, 1)); + geometry.instanceCount = objects.length; + return geometry; +}; + +const createRingMaterial = () => + new ShaderMaterial({ + uniforms: { + uColor: { value: new Color("white") }, + uDashSize: { value: RING_DASH_SIZE }, + uDashOffset: { value: 0 }, + uGapSize: { value: RING_GAP_SIZE }, + uOpacity: { value: 0.95 }, + uThickness: { value: RING_THICKNESS }, + }, + vertexShader: ` + attribute float ringAngle; + attribute float ringSide; + attribute vec3 ringInstancePosition; + attribute float ringInstanceRadius; + + uniform float uThickness; + + varying float vAngle; + varying float vRadius; + + void main() { + float angle = ringAngle; + float radius = + max(ringInstanceRadius + ringSide * uThickness * 0.5, 0.0); + vec3 ringPosition = ringInstancePosition + vec3( + cos(angle) * radius, + sin(angle) * radius, + 0.0 + ); + vAngle = ringAngle; + vRadius = ringInstanceRadius; + gl_Position = projectionMatrix * modelViewMatrix + * vec4(ringPosition, 1.0); + } + `, + fragmentShader: ` + uniform vec3 uColor; + uniform float uDashOffset; + uniform float uDashSize; + uniform float uGapSize; + uniform float uOpacity; + + varying float vAngle; + varying float vRadius; + + void main() { + float patternSize = uDashSize + uGapSize; + float dashPosition = mod(vAngle * vRadius + uDashOffset, patternSize); + if (dashPosition > uDashSize) { discard; } + gl_FragColor = vec4(uColor, uOpacity); + } + `, + depthWrite: false, + side: DoubleSide, + transparent: true, + }); + +interface SelectedObjectRingsProps { + objects: RingObject[]; +} + +export const SelectedObjectRings = (props: SelectedObjectRingsProps) => { + const geometry = React.useMemo( + () => createRingGeometry(props.objects), + [props.objects]); + const material = React.useMemo(() => createRingMaterial(), []); + const materialRef = React.useRef(material); + + React.useEffect(() => () => geometry.dispose(), [geometry]); + React.useEffect(() => { + materialRef.current = material; + return () => { + materialRef.current = undefined; + material.dispose(); + }; + }, [material]); + + useFrame((_state, delta = 0) => { + const ringMaterial = materialRef.current; + if (!ringMaterial?.uniforms?.uDashOffset) { return; } + const patternSize = RING_DASH_SIZE + RING_GAP_SIZE; + const value = ringMaterial.uniforms.uDashOffset.value as number; + ringMaterial.uniforms.uDashOffset.value = + (value + delta * RING_DASH_SPEED) % patternSize; }); + if (props.objects.length == 0) { return <>; } + return ; +}; + interface SelectedObjectOverlayProps { - object: Pick; + object: RingObject; config: Config; showCrosshairs: boolean; } export const SelectedObjectOverlay = (props: SelectedObjectOverlayProps) => { - // eslint-disable-next-line no-null/no-null - const ringRef = React.useRef(null); const zero = zeroFunc(props.config); const extents = extentsFunc(props.config); const [x, y, z] = props.object.worldPosition; - const lineZ = z + 6; - const points = React.useMemo( - () => ringPoints(props.object.ringRadius), - [props.object.ringRadius]); - useFrame((_state, delta) => { - if (ringRef.current) { ringRef.current.rotation.z += delta * 1.5; } - }); + const lineZ = z + RING_Z_OFFSET; + const ringObjects = React.useMemo(() => [props.object], [props.object]); return - - - + {props.showCrosshairs && > = { + plant: "Plant", + point: "GenericPointer", + weed: "Weed", + slot: "ToolSlot", + }; + +export const selectionKindAllowed = ( + kind: ThreeDObjectSelection["kind"], + selectionPointType: PointType[] | undefined, +) => { + const pointType = POINT_TYPE_BY_SELECTION_KIND[kind]; + return !!pointType && (selectionPointType || ["Plant"]).includes(pointType); +}; + +interface SelectionLookupProps { + plants: TaggedPlant[]; + points: TaggedGenericPointer[]; + weeds: TaggedWeedPointer[]; + toolSlots: SlotWithTool[]; +} + +export interface ThreeDObjectSelectionLookup { + bySelection: Map; + byUuid: Map; +} + +const selectionLookupKey = (selection: ThreeDObjectSelection) => + `${selection.kind}-${selection.id}`; + +const addLookupSelection = ( + lookup: ThreeDObjectSelectionLookup, + uuid: string, + selection: ThreeDObjectSelection, +) => { + lookup.bySelection.set(selectionLookupKey(selection), uuid); + lookup.byUuid.set(uuid, selection); +}; + +export const createSelectionLookup = (props: SelectionLookupProps) => { + const lookup: ThreeDObjectSelectionLookup = { + bySelection: new Map(), + byUuid: new Map(), + }; + props.plants.forEach(plant => plant.body.id && addLookupSelection( + lookup, plant.uuid, { kind: "plant", id: plant.body.id })); + props.points.forEach(point => point.body.id && addLookupSelection( + lookup, point.uuid, { kind: "point", id: point.body.id })); + props.weeds.forEach(weed => weed.body.id && addLookupSelection( + lookup, weed.uuid, { kind: "weed", id: weed.body.id })); + props.toolSlots.forEach(slot => slot.toolSlot.body.id && addLookupSelection( + lookup, slot.toolSlot.uuid, { + kind: "slot", id: slot.toolSlot.body.id, + })); + return lookup; +}; + +export const uuidForSelection = ( + lookup: ThreeDObjectSelectionLookup, + selection: ThreeDObjectSelection, +) => lookup.bySelection.get(selectionLookupKey(selection)); + +export const selectionForUuid = ( + lookup: ThreeDObjectSelectionLookup, + uuid: string, +) => lookup.byUuid.get(uuid); + export const routeSelectionFromPath = ( pathname: string, ): ThreeDObjectSelection | undefined => { diff --git a/frontend/three_d_garden/selection_types.ts b/frontend/three_d_garden/selection_types.ts index 3f097f4e5..a750b0253 100644 --- a/frontend/three_d_garden/selection_types.ts +++ b/frontend/three_d_garden/selection_types.ts @@ -14,7 +14,7 @@ export interface ThreeDLocationSelection { } export interface ThreeDObjectSelectionHandler { - (selection: ThreeDObjectSelection): void; + (selection: ThreeDObjectSelection): boolean | void; } export interface ThreeDObjectHoverHandler { From 8ad7be2c3b718af0d84be6f438e1f47323c07613 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Thu, 4 Jun 2026 23:18:21 -0700 Subject: [PATCH 07/18] fix object selectability when editing groups --- .../__tests__/group_order_visual_test.tsx | 41 ------------ frontend/three_d_garden/bed/bed.tsx | 28 ++++---- .../garden/__tests__/point_test.tsx | 4 +- frontend/three_d_garden/garden/weed.tsx | 41 +++++++----- frontend/three_d_garden/garden_model.tsx | 52 +++++++++----- .../three_d_garden/group_order_visual.tsx | 67 ++----------------- frontend/three_d_garden/selection/overlay.tsx | 2 +- 7 files changed, 85 insertions(+), 150 deletions(-) diff --git a/frontend/three_d_garden/__tests__/group_order_visual_test.tsx b/frontend/three_d_garden/__tests__/group_order_visual_test.tsx index eae4ffe5a..1eaad7366 100644 --- a/frontend/three_d_garden/__tests__/group_order_visual_test.tsx +++ b/frontend/three_d_garden/__tests__/group_order_visual_test.tsx @@ -12,8 +12,6 @@ let mockGroupPoints = [fakePlant(), fakeToolSlot(), fakePoint(), fakeWeed()]; import React from "react"; import { render } from "@testing-library/react"; -import { useFrame } from "@react-three/fiber"; -import { Quaternion } from "three"; import { areGroupOrderPropsEqual, GroupOrderProps, @@ -55,45 +53,6 @@ describe("", () => { expect(sortGroupBySpy).toHaveBeenCalledWith("random", mockGroupPoints); }); - it("uses one instanced marker disk mesh", () => { - const p = fakeProps(); - mockGroup = fakePointGroup(); - mockGroup.body.sort_type = "random"; - mockGroupPoints = [fakePlant(), fakeToolSlot(), fakePoint(), fakeWeed()]; - const { container } = render(); - const disks = container.querySelector("[name='group-order-marker-disks']"); - expect(disks?.tagName.toLowerCase()).toEqual("instancedmesh"); - expect(disks?.getAttribute("count")).toEqual("4"); - }); - - it("updates order marker disk matrices on frame", () => { - const markerRef = { - current: { - setMatrixAt: jest.fn(), - instanceMatrix: { needsUpdate: false }, - }, - }; - const useRefSpy = jest.spyOn(React, "useRef") - .mockImplementation((initial: unknown) => - // eslint-disable-next-line no-null/no-null - initial === null ? markerRef : { current: initial }); - (useFrame as jest.Mock).mockClear(); - (useFrame as jest.Mock).mockImplementation(() => undefined); - const p = fakeProps(); - mockGroup = fakePointGroup(); - mockGroup.body.sort_type = "random"; - mockGroupPoints = [fakePlant(), fakePoint()]; - try { - render(); - const frameFn = (useFrame as jest.Mock).mock.calls[0][0]; - frameFn({ camera: { quaternion: new Quaternion() } }); - expect(markerRef.current.setMatrixAt).toHaveBeenCalledTimes(2); - expect(markerRef.current.instanceMatrix.needsUpdate).toBeTruthy(); - } finally { - useRefSpy.mockRestore(); - } - }); - it("renders order visual: sort preview", () => { const p = fakeProps(); mockGroup = fakePointGroup(); diff --git a/frontend/three_d_garden/bed/bed.tsx b/frontend/three_d_garden/bed/bed.tsx index 650bdf9f4..7af3f046b 100644 --- a/frontend/three_d_garden/bed/bed.tsx +++ b/frontend/three_d_garden/bed/bed.tsx @@ -817,19 +817,21 @@ const BedBase = (props: BedProps) => { side={DoubleSide} /> } - {props.addPlantProps && - } + + {props.addPlantProps && + } + {props.config.lowDetail ? diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 49466f35d..caf33d757 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -377,7 +377,7 @@ describe("", () => { location.pathname = Path.mock(Path.weeds("add")); const p = fakeProps(); const { container } = render(); - expect(container).toContainHTML("generic-weed"); + expect(container).toContainHTML("weed-icon"); expect(container).toContainHTML("position=\"0,0,0\""); expect(container).toContainHTML("scale=\"30\""); expect(container).toContainHTML("color=\"green\""); @@ -391,7 +391,7 @@ describe("", () => { point.r = 0; p.designer.drawnPoint = point; const { container } = render(); - expect(container).toContainHTML("generic-weed"); + expect(container).toContainHTML("weed-icon"); expect(container).toContainHTML("position=\"0,0,0\""); expect(container).toContainHTML("scale=\"50\""); expect(container).toContainHTML("color=\"green\""); diff --git a/frontend/three_d_garden/garden/weed.tsx b/frontend/three_d_garden/garden/weed.tsx index 08883bb32..2e9741246 100644 --- a/frontend/three_d_garden/garden/weed.tsx +++ b/frontend/three_d_garden/garden/weed.tsx @@ -1,11 +1,12 @@ import React from "react"; import { TaggedWeedPointer, Xyz } from "farmbot"; import { Config } from "../config"; -import { ASSETS, HOVER_OBJECT_MODES, RenderOrder } from "../constants"; +import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; import { - Group, InstancedMesh, MeshBasicMaterial, MeshPhongMaterial, PlaneGeometry, + Group, InstancedMesh, Mesh, MeshBasicMaterial, MeshPhongMaterial, + PlaneGeometry, } from "../components"; -import { Image, Billboard, Sphere, useTexture } from "@react-three/drei"; +import { Billboard, Sphere, useTexture } from "@react-three/drei"; import { BufferGeometry, InstancedMesh as InstancedMeshType, @@ -37,6 +38,15 @@ import { export const WEED_IMG_SIZE_FRACTION = 0.89; +const useWeedIconTexture = (plantIconAtlas = PLANT_ICON_ATLAS) => { + const baseTexture = useTexture( + getPlantIconTextureUrl(GENERIC_WEED_ICON, plantIconAtlas)); + return React.useMemo( + () => getPlantIconTexture(baseTexture, GENERIC_WEED_ICON, plantIconAtlas), + [baseTexture, plantIconAtlas], + ); +}; + let weedRadiusGeometry: BufferGeometry | undefined = undefined; const getWeedRadiusGeometry = () => { weedRadiusGeometry ||= new SphereGeometry(1, 32, 32); @@ -107,6 +117,7 @@ export const WeedBase = (props: WeedBaseProps) => { const getWorldPosition = getWorldPositionFunc(config); const weedSize = radius == 0 ? 50 : radius; const iconSize = weedSize * WEED_IMG_SIZE_FRACTION; + const texture = useWeedIconTexture(); return { ref={billboardRef} follow={true} position={[0, 0, iconSize / 2]}> - + position={[0, 0, 0]}> + + + { const { weedInstances, dispatch, visible } = props; - const plantIconAtlas = props.plantIconAtlas || PLANT_ICON_ATLAS; - const baseTexture = useTexture( - getPlantIconTextureUrl(GENERIC_WEED_ICON, plantIconAtlas)); - const texture = React.useMemo( - () => getPlantIconTexture(baseTexture, GENERIC_WEED_ICON, plantIconAtlas), - [baseTexture, plantIconAtlas], - ); + const texture = useWeedIconTexture(props.plantIconAtlas || PLANT_ICON_ATLAS); const navigateToWeed = useNavigateToWeed(dispatch, visible, props.onSelectObject); // eslint-disable-next-line no-null/no-null diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index a561658bd..f27c100f5 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -96,12 +96,14 @@ import { } from "./helpers"; import { clickWasDragged } from "./click_event"; import { clickMapPlant } from "../farm_designer/map/actions"; +import { pointsSelectedByGroup } from "../point_groups/criteria/apply"; const AnimatedGroup = animated(Group); const GRID_HOVER_TARGET_Z_OFFSET = 1; const GRID_SELECTION_BLOCKED_MODES = [ ...HOVER_OBJECT_MODES, Mode.boxSelect, + Mode.editGroup, Mode.cameraSelection, ]; const gridSelectionAllowed = () => @@ -1055,10 +1057,13 @@ export const GardenModel = (props: GardenModelProps) => { const sensors = props.sensors || EMPTY_SENSORS; const sensorReadings = props.sensorReadings || EMPTY_SENSOR_READINGS; const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera; - const selectionPanelOpen = getMode() == Mode.boxSelect; + const mode = getMode(); + const selectionPanelOpen = mode == Mode.boxSelect; + const groupPanelOpen = mode == Mode.editGroup; + const objectSelectionMode = selectionPanelOpen || groupPanelOpen; const selectionPointType = addPlantProps?.designer.selectionPointType; const kindSelectable = (kind: ThreeDObjectSelection["kind"]) => - !selectionPanelOpen || selectionKindAllowed(kind, selectionPointType); + !objectSelectionMode || selectionKindAllowed(kind, selectionPointType); const plantsSelectable = kindSelectable("plant"); const pointsSelectable = kindSelectable("point"); const weedsSelectable = kindSelectable("weed"); @@ -1225,7 +1230,7 @@ export const GardenModel = (props: GardenModelProps) => { const onSelectObject = React.useCallback(( selection: ThreeDObjectSelection, ) => { - if (selectionPanelOpen) { + if (objectSelectionMode) { const uuid = uuidForSelection(selectionLookup, selection); if (uuid && selectionKindAllowed(selection.kind, selectionPointType)) { dispatch?.(clickMapPlant(uuid)); @@ -1240,8 +1245,8 @@ export const GardenModel = (props: GardenModelProps) => { return true; }, [ dispatch, + objectSelectionMode, selectionLookup, - selectionPanelOpen, selectionPointType, ]); const onSelectLocation = React.useCallback(( @@ -1259,6 +1264,9 @@ export const GardenModel = (props: GardenModelProps) => { setPopupSelection(undefined); setLocationSelection(undefined); }, []); + const activePopupSelection = objectSelectionMode ? undefined : popupSelection; + const activeLocationSelection = + objectSelectionMode ? undefined : locationSelection; const openSelectedObjectPanel = React.useCallback(( selection: ThreeDObjectSelection, ) => { @@ -1279,13 +1287,13 @@ export const GardenModel = (props: GardenModelProps) => { }, []); React.useEffect(() => { - if (!popupSelection && !locationSelection) { return; } + if (!activePopupSelection && !activeLocationSelection) { return; } const closeOnEscape = (event: KeyboardEvent) => { if (event.key == "Escape") { closePopup(); } }; window.addEventListener("keydown", closeOnEscape); return () => window.removeEventListener("keydown", closeOnEscape); - }, [closePopup, locationSelection, popupSelection]); + }, [activeLocationSelection, activePopupSelection, closePopup]); React.useEffect(() => { if (!detailsReveal || detailsRevealNotified.current) { return; } @@ -1315,9 +1323,22 @@ export const GardenModel = (props: GardenModelProps) => { showSpread, showMoistureMap, showMoistureReadings, topDownAtStart, } = layerVisibility; + const routeKey = `${routeLocation.pathname}?${routeLocation.search}`; + const groupIdFromPath = React.useMemo(() => { + const groupId = parseInt( + routeLocation.pathname.split("/").filter(Boolean).pop() || ""); + return isFinite(groupId) ? groupId : undefined; + }, [routeLocation.pathname]); + const groupSelectedPoints = React.useMemo(() => { + if (!groupPanelOpen || groupIdFromPath == undefined) { return undefined; } + const group = groups.filter(group => group.body.id == groupIdFromPath)[0]; + return group ? pointsSelectedByGroup(group, allPoints) : undefined; + }, [allPoints, groupIdFromPath, groupPanelOpen, groups]); const selectedObjectSelections = React.useMemo(() => { - const selectedPoints = addPlantProps?.designer.selectedPoints; - if (!selectionPanelOpen || !selectedPoints) { return undefined; } + const selectedPoints = selectionPanelOpen + ? addPlantProps?.designer.selectedPoints + : groupSelectedPoints?.map(point => point.uuid); + if (!selectedPoints) { return undefined; } const selections: ThreeDObjectSelection[] = []; selectedPoints.forEach(uuid => { const selection = selectionForUuid(selectionLookup, uuid); @@ -1326,10 +1347,10 @@ export const GardenModel = (props: GardenModelProps) => { return selections; }, [ addPlantProps?.designer.selectedPoints, + groupSelectedPoints, selectionLookup, selectionPanelOpen, ]); - const routeKey = `${routeLocation.pathname}?${routeLocation.search}`; const routeSelection = React.useMemo( () => routeSelectionFromPath(routeLocation.pathname), [routeLocation.pathname]); @@ -1352,7 +1373,8 @@ export const GardenModel = (props: GardenModelProps) => { weeds, toolSlots, ]); - const visualSelection = popupSelection || hoverSelection || routeSelection; + const visualSelection = + activePopupSelection || hoverSelection || routeSelection; const gridHoverEnabled = config.grid && props.activeFocus != "Planter bed" && gridSelectionAllowed(); const activeGridHoverPosition = gridHoverEnabled @@ -1602,7 +1624,7 @@ export const GardenModel = (props: GardenModelProps) => { config={config} configPosition={props.configPosition} detailsReveal={detailsReveal} - dispatch={selectionPanelOpen ? undefined : dispatch} + dispatch={objectSelectionMode ? undefined : dispatch} getZ={getZ} loadProgress={loadProgress} markStep={markLoadStep} @@ -1611,8 +1633,8 @@ export const GardenModel = (props: GardenModelProps) => { showLoadProgress={props.showFarmbotLayerLoadProgress !== false} toolSlots={props.toolSlots} onSelectObject={slotsSelectable ? onSelectObject : undefined} - onHoverObject={selectionPanelOpen ? undefined : setSelectableObjectHover} - onToolSlotHoverObject={selectionPanelOpen && slotsSelectable + onHoverObject={objectSelectionMode ? undefined : setSelectableObjectHover} + onToolSlotHoverObject={objectSelectionMode && slotsSelectable ? setSelectableObjectHover : undefined} visible={farmbotVisible} /> @@ -1621,8 +1643,8 @@ export const GardenModel = (props: GardenModelProps) => { configPosition={props.configPosition} selection={visualSelection} selectedObjects={selectedObjectSelections} - popupSelection={popupSelection} - locationSelection={locationSelection} + popupSelection={activePopupSelection} + locationSelection={activeLocationSelection} selectedLocation={selectedLocation} onClosePopup={closePopup} onOpenPanel={openSelectedObjectPanel} diff --git a/frontend/three_d_garden/group_order_visual.tsx b/frontend/three_d_garden/group_order_visual.tsx index 51097fbcd..cad9a59d9 100644 --- a/frontend/three_d_garden/group_order_visual.tsx +++ b/frontend/three_d_garden/group_order_visual.tsx @@ -1,5 +1,4 @@ import { Billboard, Line } from "@react-three/drei"; -import { useFrame } from "@react-three/fiber"; import React from "react"; import { getWorldPositionFunc } from "./helpers"; import { Config } from "./config"; @@ -8,16 +7,11 @@ import { sortGroupBy } from "../point_groups/point_group_sort"; import { TaggedPoint, TaggedPointGroup } from "farmbot"; import { findGroupFromUrl } from "../point_groups/find_group_from_url"; import { pointsSelectedByGroup } from "../point_groups/criteria/apply"; -import { Group, InstancedMesh, MeshPhongMaterial } from "./components"; +import { Group } from "./components"; import { Text } from "./elements"; import { RenderOrder } from "./constants"; import { isEqual } from "lodash"; -import { - InstancedMesh as InstancedMeshType, - Matrix4, - Quaternion, - Vector3, -} from "three"; +import { RING_THICKNESS } from "./selection/overlay"; interface CommonProps { config: Config; @@ -97,58 +91,6 @@ export interface GroupOrderProps extends CommonProps { groupPoints: TaggedPoint[]; } -interface OrderMarkerDisksProps { - positions: [number, number, number][]; -} - -const OrderMarkerDisks = (props: OrderMarkerDisksProps) => { - // eslint-disable-next-line no-null/no-null - const ref = React.useRef(null); - const matrix = React.useMemo(() => new Matrix4(), []); - const position = React.useMemo(() => new Vector3(), []); - const scale = React.useMemo(() => new Vector3(1, 1, 1), []); - const markerRotation = React.useMemo(() => - new Quaternion().setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 2), []); - const quaternion = React.useMemo(() => new Quaternion(), []); - const lastCameraQuaternion = React.useMemo(() => new Quaternion(), []); - const hasCameraQuaternion = React.useRef(false); - const matrixNeedsUpdate = React.useRef(true); - - React.useEffect(() => { - matrixNeedsUpdate.current = true; - }, [props.positions]); - - useFrame(state => { - const mesh = ref.current; - if (!mesh) { return; } - const cameraChanged = !hasCameraQuaternion.current || - !lastCameraQuaternion.equals(state.camera.quaternion); - if (!matrixNeedsUpdate.current && !cameraChanged) { return; } - quaternion.copy(state.camera.quaternion).multiply(markerRotation); - for (let index = 0; index < props.positions.length; index++) { - const coords = props.positions[index]; - position.set(coords[0], coords[1], coords[2]); - matrix.compose(position, quaternion, scale); - mesh.setMatrixAt(index, matrix); - } - mesh.instanceMatrix.needsUpdate = true; - lastCameraQuaternion.copy(state.camera.quaternion); - hasCameraQuaternion.current = true; - matrixNeedsUpdate.current = false; - }); - - return - - - ; -}; - export const areGroupOrderPropsEqual = (prev: GroupOrderProps, next: GroupOrderProps) => { if (prev.config.exaggeratedZ != next.config.exaggeratedZ) { return false; } @@ -187,14 +129,13 @@ const GroupOrder = (props: GroupOrderProps) => { }); return - {positions.map((p, i) => Date: Fri, 5 Jun 2026 01:02:17 -0700 Subject: [PATCH 08/18] label improvements --- frontend/promo/__tests__/promo_test.tsx | 4 + frontend/promo/promo.tsx | 1 + .../__tests__/garden_model_test.tsx | 118 ++++++++- frontend/three_d_garden/bot/bot.tsx | 5 +- .../bot/components/__tests__/tools_test.tsx | 15 +- .../three_d_garden/bot/components/tools.tsx | 41 ++- frontend/three_d_garden/elements/text.tsx | 4 +- .../garden/__tests__/plants_test.tsx | 90 +------ .../garden/__tests__/point_test.tsx | 9 +- .../garden/__tests__/weed_test.tsx | 51 ++-- frontend/three_d_garden/garden/plants.tsx | 56 +--- frontend/three_d_garden/garden/point.tsx | 46 +++- frontend/three_d_garden/garden/weed.tsx | 59 +++-- frontend/three_d_garden/garden_model.tsx | 247 ++++++++++++++++-- .../three_d_garden/group_order_visual.tsx | 14 +- frontend/three_d_garden/selection.tsx | 1 + frontend/three_d_garden/selection/routes.ts | 6 +- frontend/three_d_garden/selection_types.ts | 4 + 18 files changed, 540 insertions(+), 231 deletions(-) diff --git a/frontend/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx index 2aa19e401..d82823e41 100644 --- a/frontend/promo/__tests__/promo_test.tsx +++ b/frontend/promo/__tests__/promo_test.tsx @@ -103,6 +103,10 @@ describe("", () => { const configBtn = container.querySelector(".gear") as HTMLElement; fireEvent.click(configBtn); expect(container).toContainHTML("all-configs"); + fireEvent.click(screen.getByRole("button", { name: "Summer" })); + const lastCall = + gardenModelSpy.mock.calls[gardenModelSpy.mock.calls.length - 1]; + expect(lastCall[0].seasonResetKey).toEqual(1); unmount(); }); diff --git a/frontend/promo/promo.tsx b/frontend/promo/promo.tsx index 500bb64f3..f2d767368 100644 --- a/frontend/promo/promo.tsx +++ b/frontend/promo/promo.tsx @@ -235,6 +235,7 @@ export const Promo = () => { plantIconAtlas={PROMO_PLANT_ICON_ATLAS} plantInstanceCapacity={plantCapacities.plantInstanceCapacity} seasonResetKey={seasonResetKey} + promo={true} preloadEnvironmentScenes={true} showFarmbotLayerLoadProgress={false} onDetailsRevealStart={handleThreeDLoadComplete} diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index 4af1012dd..cb9483738 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -13,7 +13,8 @@ import { clone } from "lodash"; import { INITIAL, INITIAL_POSITION, SurfaceDebugOption } from "../config"; import { render, waitFor } from "@testing-library/react"; import { - fakePlant, fakePoint, fakeSensor, fakeSensorReading, fakeSequence, fakeWeed, + fakePlant, fakePoint, fakePointGroup, fakeSensor, fakeSensorReading, + fakeSequence, fakeTool, fakeToolSlot, fakeWeed, } from "../../__test_support__/fake_state/resources"; import { fakeAddPlantProps } from "../../__test_support__/fake_props"; import { Path } from "../../internal_urls"; @@ -661,6 +662,19 @@ describe("", () => { expect(botLoadIn).toBeTruthy(); }); + it("handles FarmBot layer progress callbacks", () => { + const wrapper = createWrapper(fakeProps()); + const progressNodes = wrapper.root.findAll(node => + node.props.progress?.markStep && node.props.progress?.isStepAllowed); + actRenderer(() => { + progressNodes.forEach(node => { + node.props.progress.markStep("farmbot"); + node.props.progress.isStepAllowed("farmbot"); + }); + }); + expect(progressNodes.length).toBeGreaterThan(0); + }); + it("renders other options", async () => { mockIsDesktop = false; const p = fakeProps(); @@ -924,7 +938,109 @@ describe("", () => { expect(mockNavigate).toHaveBeenCalled(); }); + it("updates object selections in selection modes", () => { + const getModeSpy = jest.spyOn(mapUtil, "getMode") + .mockReturnValue(Mode.boxSelect); + const p = fakeProps(); + const point = fakePoint(); + point.body.id = 1; + p.mapPoints = [point]; + p.allPoints = [point]; + p.addPlantProps = fakeAddPlantProps(); + p.addPlantProps.designer.selectedPoints = [point.uuid]; + p.addPlantProps.designer.selectionPointType = ["GenericPointer"]; + p.addPlantProps.dispatch = jest.fn(); + const wrapper = createWrapper(p); + const staticLayers = wrapper.root.findAll(node => + typeof node.props.onSelectObject == "function" + && typeof node.props.onPlantHoverChange == "function")[0]; + const selectionLayer = wrapper.root.findByType(ThreeDObjectSelectionLayer); + expect(selectionLayer.props.selectedObjects) + .toEqual([{ kind: "point", id: 1 }]); + expect(staticLayers.props.onSelectObject({ kind: "point", id: 1 })) + .toBeTruthy(); + expect(staticLayers.props.onSelectObject({ kind: "weed", id: 999 })) + .toBeFalsy(); + expect(p.addPlantProps.dispatch).toHaveBeenCalled(); + + const group = fakePointGroup(); + group.body.id = 2; + group.body.point_ids = [point.body.id]; + location.pathname = Path.mock(Path.groups(2)); + getModeSpy.mockReturnValue(Mode.editGroup); + actRenderer(() => wrapper.update()); + expect(wrapper.root.findByType(ThreeDObjectSelectionLayer) + .props.selectedObjects).toEqual([{ kind: "point", id: 1 }]); + getModeSpy.mockRestore(); + }); + + it("suppresses promo popups for FarmBot hardware", () => { + const p = fakeProps(); + p.promo = true; + const wrapper = createWrapper(p); + const selectObject = wrapper.root.findAll(node => + typeof node.props.onSelectObject == "function")[0].props.onSelectObject; + expect(selectObject({ kind: "camera", id: 0 })).toBeTruthy(); + expect(wrapper.root.findByType(ThreeDObjectSelectionLayer) + .props.popupSelection).toBeUndefined(); + }); + + it("renders object hover labels", () => { + useStateSpy.mockRestore(); + const actualUseState = jest.requireActual("react") + .useState as typeof React.useState; + useStateSpy = jest.spyOn(React, "useState") + .mockImplementation(actualUseState); + const p = fakeProps(); + p.config.labelsOnHover = true; + const weed = fakeWeed(); + weed.body.id = 1; + weed.body.name = "Weed label"; + const point = fakePoint(); + point.body.id = 2; + point.body.name = "Point label"; + const tool = fakeTool(); + tool.body.name = "Tool label"; + const toolSlot = fakeToolSlot(); + toolSlot.body.id = 3; + toolSlot.body.tool_id = tool.body.id; + p.weeds = [weed]; + p.mapPoints = [point]; + p.toolSlots = [{ toolSlot, tool }]; + const wrapper = createWrapper(p); + const staticLayers = wrapper.root.findAll(node => + typeof node.props.onHoverObject == "function" + && typeof node.props.onPlantHoverChange == "function")[0]; + const garden = wrapper.root.findAll(node => + typeof node.props.onPointerMove == "function" + && typeof node.props.onPointerLeave == "function")[0]; + const setHoverLabel = wrapper.root.findAll(node => + typeof node.props.onHoverLabel == "function")[0].props.onHoverLabel; + const hasText = (text: string) => wrapper.root.findAll(node => + node.children.includes(text)).length > 0; + + actRenderer(() => { + staticLayers.props.onHoverObject(true); + staticLayers.props.onHoverObject(false); + garden.props.onPointerMove({ intersections: [] }); + }); + actRenderer(() => setHoverLabel({ kind: "weed", id: 1 })); + expect(hasText("Weed label")).toBeTruthy(); + actRenderer(() => setHoverLabel({ kind: "point", id: 2 })); + expect(hasText("Point label")).toBeTruthy(); + actRenderer(() => setHoverLabel({ kind: "slot", id: 3 })); + expect(hasText("Tool label")).toBeTruthy(); + actRenderer(() => setHoverLabel({ kind: "weed", id: 999 })); + expect(hasText("Weed label")).toBeFalsy(); + actRenderer(() => setHoverLabel({ kind: "camera", id: 0 })); + expect(hasText("Point label")).toBeFalsy(); + }); + it("closes selections on Escape", () => { + location.pathname = Path.mock(Path.designer()); useStateSpy.mockRestore(); const actualUseState = jest.requireActual("react") .useState as typeof React.useState; diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index f67e86bcc..c3892e532 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -41,7 +41,8 @@ import { FocusVisibilityGroup } from "../focus_transition"; import { useTextureVariant } from "../texture_variants"; import { WaterFlowTextureProvider } from "./components/water_stream"; import { - ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, + ThreeDObjectHoverHandler, ThreeDObjectHoverLabelHandler, + ThreeDObjectSelectionHandler, } from "../selection_types"; import { clickWasDragged } from "../click_event"; import { Mode } from "../../farm_designer/map/interfaces"; @@ -122,6 +123,7 @@ export interface FarmbotModelProps { onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; onToolSlotHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?: ThreeDObjectHoverLabelHandler; } interface RequestedShapes { @@ -1026,6 +1028,7 @@ const EnabledBot = (props: FarmbotModelProps) => { onSelectObject={props.onSelectObject} onHoverObject={props.onHoverObject} onToolSlotHoverObject={props.onToolSlotHoverObject} + onHoverLabel={props.onHoverLabel} mountedToolName={props.mountedToolName} /> {config.waterFlow && diff --git a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx index c7de19827..e61c846c9 100644 --- a/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/tools_test.tsx @@ -540,11 +540,16 @@ describe("", () => { it("hovers selectable tools", () => { const p = fakeProps(); p.onHoverObject = jest.fn(); + p.onHoverLabel = jest.fn(); p.toolSlots = configuredUserTools(); + p.toolSlots.forEach((slot, index) => { + slot.toolSlot.body.id = index + 1; + }); const { container } = render(); - const slot = container.querySelector("[name='slot']"); - slot && fireEvent.pointerOver(slot); - slot && fireEvent.pointerOut(slot); + container.querySelectorAll("[name='slot']").forEach(slot => { + fireEvent.pointerOver(slot); + fireEvent.pointerOut(slot); + }); const utm = container.querySelector("[name='utm-tool']"); utm && fireEvent.pointerOver(utm); utm && fireEvent.pointerOut(utm); @@ -554,6 +559,10 @@ describe("", () => { }); expect(p.onHoverObject).toHaveBeenCalledWith(true); expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onHoverLabel).toHaveBeenCalledWith(expect.objectContaining({ + kind: "slot", + })); + expect(p.onHoverLabel).toHaveBeenCalledWith(undefined); }); it("navigates to tools from the mounted UTM tool", () => { diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 043fb514a..8376d9f89 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -33,7 +33,8 @@ import { ThreeEvent, useFrame } from "@react-three/fiber"; import { Model, ModelMesh } from "../../model_mesh"; import { SuctionAnimations } from "./suction_animation"; import { - ThreeDObjectHoverHandler, ThreeDObjectSelection, + ThreeDObjectHoverHandler, ThreeDObjectHoverLabelHandler, + ThreeDObjectSelection, ThreeDObjectSelectionHandler, } from "../../selection_types"; import { @@ -90,6 +91,7 @@ export interface ToolsProps { onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; onToolSlotHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?: ThreeDObjectHoverLabelHandler; } export interface ThreeDTool { @@ -129,6 +131,7 @@ export const toolsPropsEqual = (prev: ToolsProps, next: ToolsProps) => prev.onSelectObject === next.onSelectObject && prev.onHoverObject === next.onHoverObject && prev.onToolSlotHoverObject === next.onToolSlotHoverObject && + prev.onHoverLabel === next.onHoverLabel && prev.configPosition.x === next.configPosition.x && prev.configPosition.y === next.configPosition.y && prev.configPosition.z === next.configPosition.z && @@ -219,6 +222,7 @@ const ToolsBase = (props: ToolsProps) => { z={props.configPosition.z + (isUndefined(props.toolSlots) ? 1 : -2)} toolName={mountedToolName} toolPulloutDirection={ToolPulloutDirection.NONE} + onHoverLabel={props.onHoverLabel} inToolbay={false} /> {isUndefined(props.toolSlots) && } {tools.map((tool, i) => @@ -228,6 +232,7 @@ const ToolsBase = (props: ToolsProps) => { onSelectObject={props.onSelectObject} onHoverObject={props.onHoverObject} onToolSlotHoverObject={props.onToolSlotHoverObject} + onHoverLabel={props.onHoverLabel} positionHelpers={positionHelpers} mountedToolName={mountedToolName} {...tool} @@ -332,6 +337,7 @@ interface ToolbaySlotProps { config: Config; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?: ThreeDObjectHoverLabelHandler; } const stopPropagationForSelectedSlot = ( @@ -394,6 +400,10 @@ const SlotHitTarget = (props: SlotHitTargetProps) => const ToolbaySlot = (props: ToolbaySlotProps) => { const { position, children, toolPulloutDirection, mounted } = props; const selectable = !!props.id || !props.inToolbay; + let selection: ThreeDObjectSelection | undefined = undefined; + if (props.id) { + selection = { kind: "slot", id: props.id }; + } const rotationMultiplier = rotationFactor(displayedPulloutDirection( toolPulloutDirection, @@ -407,8 +417,16 @@ const ToolbaySlot = (props: ToolbaySlotProps) => { position.z + TOOLBAY_SLOT_Z_OFFSET, ]} onClick={onClick} - onPointerOver={() => selectable && props.onHoverObject?.(true)} - onPointerOut={() => selectable && props.onHoverObject?.(false)}> + onPointerOver={() => { + if (!selectable) { return; } + props.onHoverObject?.(true); + props.onHoverLabel?.(selection); + }} + onPointerOut={() => { + if (!selectable) { return; } + props.onHoverObject?.(false); + props.onHoverLabel?.(undefined); + }}> {selectable && props.inToolbay && } {rotationMultiplier && @@ -430,6 +448,7 @@ interface ToolProps extends ThreeDTool { onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; onToolSlotHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?: ThreeDObjectHoverLabelHandler; positionHelpers: ToolPositionHelpers; } @@ -610,6 +629,9 @@ interface SeedTroughToolSlotProps extends ToolbaySlotProps { const SeedTroughToolSlot = (props: SeedTroughToolSlotProps) => { const onClick = useToolSlotClick(props); const selectable = !!props.id; + const selection: ThreeDObjectSelection | undefined = props.id + ? { kind: "slot", id: props.id } + : undefined; return { ]} rotation={[0, 0, Math.PI / 2]} onClick={onClick} - onPointerOver={() => selectable && props.onHoverObject?.(true)} - onPointerOut={() => selectable && props.onHoverObject?.(false)}> + onPointerOver={() => { + if (!selectable) { return; } + props.onHoverObject?.(true); + props.onHoverLabel?.(selection); + }} + onPointerOut={() => { + if (!selectable) { return; } + props.onHoverObject?.(false); + props.onHoverLabel?.(undefined); + }}> {selectable && } @@ -661,6 +691,7 @@ const ToolBase = (props: ToolProps) => { mounted, position, toolPulloutDirection, id, inToolbay, config, dispatch, onSelectObject: props.onSelectObject, onHoverObject, + onHoverLabel: props.onHoverLabel, }; switch (props.toolName) { case ToolName.rotaryTool: diff --git a/frontend/three_d_garden/elements/text.tsx b/frontend/three_d_garden/elements/text.tsx index a94040dd4..b16a88ff2 100644 --- a/frontend/three_d_garden/elements/text.tsx +++ b/frontend/three_d_garden/elements/text.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Center, Text3D } from "@react-three/drei"; import { ASSETS, RenderOrder } from "../constants"; -import { MeshPhongMaterial } from "../components"; +import { MeshBasicMaterial } from "../components"; export interface TextProps { children: React.ReactNode; @@ -46,7 +46,7 @@ const TextBase = (props: TextProps) => { height={props.thickness || 0.01} rotation={props.rotation}> {props.children} - + ; }; diff --git a/frontend/three_d_garden/garden/__tests__/plants_test.tsx b/frontend/three_d_garden/garden/__tests__/plants_test.tsx index 7cfafb75d..7d2546c21 100644 --- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx @@ -13,17 +13,13 @@ import { outOfBoundsShaderModification, } from "../plants"; import { Path } from "../../../internal_urls"; -import { Actions } from "../../../constants"; import { convertPlants } from "../../../farm_designer/three_d_garden_map"; import { setMockInstanceId } from "../../../__test_support__/three_d_mocks"; import { useFrame } from "@react-three/fiber"; import * as reactSpring from "@react-spring/three"; import { - InstancedMesh as ThreeInstancedMesh, Quaternion, WebGLProgramParametersWithUniforms, - type Intersection, - type Raycaster, } from "three"; import { Mode } from "../../../farm_designer/map/interfaces"; import * as mapUtil from "../../../farm_designer/map/util"; @@ -287,48 +283,20 @@ describe("", () => { expect(container.querySelectorAll("instancedmesh").length).toBe(1); }); - it("handles click on spread part", () => { - setMockInstanceId(0); - queueMeshRef(); - const p = fakeProps(); - p.spreadVisible = true; - const dispatch = jest.fn(); - p.dispatch = mockDispatch(dispatch); - const { container } = render(); - const mesh = container.querySelector("instancedmesh"); - mesh && fireEvent.click(mesh, { instanceId: 0 }); - expect(dispatch).toHaveBeenCalledWith({ - type: Actions.SET_PANEL_OPEN, payload: true, - }); - expect(mockNavigate).toHaveBeenCalledWith(Path.plants("1")); - }); - - it("selects a spread plant instead of navigating when handler is present", () => { - setMockInstanceId(0); + it("keeps spread spheres inert", () => { queueMeshRef(); const p = fakeProps(); p.spreadVisible = true; p.dispatch = mockDispatch(jest.fn()); p.onSelectObject = jest.fn(); - const { container } = render(); - const mesh = container.querySelector("instancedmesh"); - mesh && fireEvent.click(mesh, { instanceId: 0 }); - expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "plant", id: 1 }); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it("hovers spread plants", () => { - queueMeshRef(); - const p = fakeProps(); - p.spreadVisible = true; p.onHoverObject = jest.fn(); const wrapper = createRenderer(); const mesh = wrapper.root.findAll(node => (node.type as string) == "instancedMesh")[0]; - mesh.props.onPointerOver(); - mesh.props.onPointerOut(); - expect(p.onHoverObject).toHaveBeenCalledWith(true); - expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(mesh.props.raycast()).toBeUndefined(); + expect(p.onSelectObject).not.toHaveBeenCalled(); + expect(p.onHoverObject).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); unmountRenderer(wrapper); }); @@ -341,7 +309,7 @@ describe("", () => { const wrapper = createRenderer(); const mesh = wrapper.root.findAll(node => (node.type as string) == "instancedMesh")[0]; - mesh.props.onClick({ instanceId: 0, delta: 3 }); + expect(mesh.props.raycast()).toBeUndefined(); unmountRenderer(wrapper); expect(dispatch).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); @@ -361,52 +329,6 @@ describe("", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - const spreadRaycast = (p = fakeProps()) => { - queueMeshRef(); - p.spreadVisible = true; - const wrapper = createRenderer(); - const mesh = wrapper.root.findAll(node => - (node.type as string) == "instancedMesh")[0]; - const raycast = mesh.props.raycast as ( - this: ThreeInstancedMesh, - raycaster: Raycaster, - intersects: Intersection[], - ) => void; - unmountRenderer(wrapper); - return raycast; - }; - - it.each([ - Mode.clickToAdd, - Mode.createPoint, - Mode.createWeed, - ])("allows %s raycasts through spread spheres", mode => { - getModeSpy.mockReturnValue(mode); - const defaultRaycast = jest.spyOn( - ThreeInstancedMesh.prototype, - "raycast", - ); - const intersects: Intersection[] = []; - const raycaster = {} as Raycaster; - spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); - expect(defaultRaycast).not.toHaveBeenCalled(); - expect(intersects).toEqual([]); - defaultRaycast.mockRestore(); - }); - - it("keeps spread sphere raycasts outside placement modes", () => { - getModeSpy.mockReturnValue(Mode.none); - const defaultRaycast = jest.spyOn( - ThreeInstancedMesh.prototype, - "raycast", - ).mockImplementation(() => undefined); - const intersects: Intersection[] = []; - const raycaster = {} as Raycaster; - spreadRaycast().call({} as ThreeInstancedMesh, raycaster, intersects); - expect(defaultRaycast).toHaveBeenCalledWith(raycaster, intersects); - defaultRaycast.mockRestore(); - }); - it("updates instance colors on frame", () => { queueMeshRef(); const p = fakeProps(); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index caf33d757..45a701787 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -95,6 +95,7 @@ describe("", () => { it("hovers a point", () => { const p = fakeProps(); p.onHoverObject = jest.fn(); + p.onHoverLabel = jest.fn(); p.point.body.id = 1; const { container } = render(); const point = container.querySelector("[name='point-1']"); @@ -102,6 +103,8 @@ describe("", () => { point && fireEvent.pointerOut(point); expect(p.onHoverObject).toHaveBeenCalledWith(true); expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onHoverLabel).toHaveBeenCalledWith({ kind: "point", id: 1 }); + expect(p.onHoverLabel).toHaveBeenCalledWith(undefined); }); it("doesn't navigate after orbiting over a point", () => { @@ -303,6 +306,8 @@ describe("", () => { it("hovers point instances", () => { const p = fakeInstanceProps(); p.onHoverObject = jest.fn(); + p.onHoverLabel = jest.fn(); + p.points[0].body.id = 1; const wrapper = createRenderer(); mountedWrappers.push(wrapper); const meshes = wrapper.root.findAll(node => { @@ -311,11 +316,13 @@ describe("", () => { && ["marker", "marker-radius"].includes(name); }); meshes.forEach(mesh => { - mesh.props.onPointerOver(); + mesh.props.onPointerOver({ instanceId: 0 }); mesh.props.onPointerOut(); }); expect(p.onHoverObject).toHaveBeenCalledWith(true); expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onHoverLabel).toHaveBeenCalledWith({ kind: "point", id: 1 }); + expect(p.onHoverLabel).toHaveBeenCalledWith(undefined); }); it("doesn't navigate after orbiting over a point instance", () => { diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index edee5c54e..ed8de1824 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -89,6 +89,7 @@ describe("", () => { it("hovers a weed", () => { const p = fakeProps(); p.onHoverObject = jest.fn(); + p.onHoverLabel = jest.fn(); p.weed.body.id = 1; const { container } = render(); const weed = container.querySelector("[name='weed-1']"); @@ -96,6 +97,8 @@ describe("", () => { weed && fireEvent.pointerOut(weed); expect(p.onHoverObject).toHaveBeenCalledWith(true); expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onHoverLabel).toHaveBeenCalledWith({ kind: "weed", id: 1 }); + expect(p.onHoverLabel).toHaveBeenCalledWith(undefined); }); it("doesn't navigate after orbiting over a weed", () => { @@ -249,52 +252,39 @@ describe("", () => { expect(mockNavigate).not.toHaveBeenCalled(); }); - it("navigates from a weed radius instance", () => { - const p = fakeInstanceProps(); - const dispatch = jest.fn(); - p.dispatch = mockDispatch(dispatch); - p.weeds[0].body.id = 1; - const wrapper = createRenderer(); - mountedWrappers.push(wrapper); - const weedRadius = wrapper.root - .findAll(node => node.props.name == "weed-radius")[0]; - weedRadius.props.onClick({ instanceId: 0 }); - expect(dispatch).toHaveBeenCalledWith({ - type: Actions.SET_PANEL_OPEN, payload: true, - }); - expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("1")); - }); - - it("selects a weed radius instead of navigating when handler is present", () => { + it("keeps weed radius instances inert", () => { const p = fakeInstanceProps(); p.dispatch = mockDispatch(jest.fn()); p.onSelectObject = jest.fn(); - p.weeds[0].body.id = 1; + p.onHoverObject = jest.fn(); const wrapper = createRenderer(); mountedWrappers.push(wrapper); const weedRadius = wrapper.root .findAll(node => node.props.name == "weed-radius")[0]; - weedRadius.props.onClick({ instanceId: 0 }); - expect(p.onSelectObject).toHaveBeenCalledWith({ kind: "weed", id: 1 }); + expect(weedRadius.props.onClick).toBeUndefined(); + expect(weedRadius.props.onPointerOver).toBeUndefined(); + expect(weedRadius.props.onPointerOut).toBeUndefined(); + expect(weedRadius.props.raycast()).toBeUndefined(); + expect(p.onSelectObject).not.toHaveBeenCalled(); + expect(p.onHoverObject).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); }); it("hovers weed instances", () => { const p = fakeInstanceProps(); p.onHoverObject = jest.fn(); + p.onHoverLabel = jest.fn(); + p.weeds[0].body.id = 1; const wrapper = createRenderer(); mountedWrappers.push(wrapper); - const meshes = wrapper.root.findAll(node => { - const name: unknown = node.props.name; - return typeof name == "string" - && ["weed-icons", "weed-radius"].includes(name); - }); - meshes.forEach(mesh => { - mesh.props.onPointerOver(); - mesh.props.onPointerOut(); - }); + const weedIcons = wrapper.root + .findAll(node => node.props.name == "weed-icons")[0]; + weedIcons.props.onPointerOver({ instanceId: 0 }); + weedIcons.props.onPointerOut(); expect(p.onHoverObject).toHaveBeenCalledWith(true); expect(p.onHoverObject).toHaveBeenCalledWith(false); + expect(p.onHoverLabel).toHaveBeenCalledWith({ kind: "weed", id: 1 }); + expect(p.onHoverLabel).toHaveBeenCalledWith(undefined); }); it("doesn't navigate after orbiting over weed instances", () => { @@ -306,10 +296,7 @@ describe("", () => { mountedWrappers.push(wrapper); const weedIcons = wrapper.root .findAll(node => node.props.name == "weed-icons")[0]; - const weedRadius = wrapper.root - .findAll(node => node.props.name == "weed-radius")[0]; weedIcons.props.onClick({ instanceId: 0, delta: 3 }); - weedRadius.props.onClick({ instanceId: 0, delta: 3 }); expect(dispatch).not.toHaveBeenCalled(); expect(mockNavigate).not.toHaveBeenCalled(); }); diff --git a/frontend/three_d_garden/garden/plants.tsx b/frontend/three_d_garden/garden/plants.tsx index a65a6afe0..43c8fe96c 100644 --- a/frontend/three_d_garden/garden/plants.tsx +++ b/frontend/three_d_garden/garden/plants.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useSpring } from "@react-spring/three"; import { Config } from "../config"; -import { HOVER_OBJECT_MODES, RenderOrder } from "../constants"; +import { RenderOrder } from "../constants"; import { Billboard } from "@react-three/drei"; import { Vector3, @@ -12,9 +12,6 @@ import { Matrix4, Quaternion, InstancedBufferAttribute, - type Object3D, - type Raycaster, - type Intersection, } from "three"; import { getGardenPositionFunc, @@ -23,12 +20,9 @@ import { get3DPositionFunc, } from "../helpers"; import { Text } from "../elements"; -import { isUndefined } from "lodash"; import { Path } from "../../internal_urls"; -import { useNavigate } from "react-router"; -import { setPanelOpen3D } from "../panel_actions"; import { getMode, round } from "../../farm_designer/map/util"; -import { ThreeEvent, useFrame } from "@react-three/fiber"; +import { useFrame } from "@react-three/fiber"; import { InstancedMesh, MeshPhongMaterial, SphereGeometry } from "../components"; import { getSpreadOverlap, getSpreadRadii, @@ -37,7 +31,6 @@ import { ActivePositionRef } from "../bed/objects/pointer_objects"; import { Mode } from "../../farm_designer/map/interfaces"; import { findCropMetadata } from "../../crops/metadata"; import { perfMeasure } from "../../performance/perf"; -import { clickWasDragged } from "../click_event"; import { ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler, } from "../selection_types"; @@ -174,24 +167,7 @@ interface PlantSpreadUpdateState { lastUpdateKey: string; } -const plantSpreadRaycast = function ( - this: ThreeInstancedMesh, - raycaster: Raycaster, - intersects: Intersection[], -) { - if (!objectVisible(this)) { return; } - if (HOVER_OBJECT_MODES.includes(getMode())) { return; } - ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects); -}; - -const objectVisible = (object: Object3D) => { - let current: Object3D | undefined = object; - while (current) { - if (current.visible === false) { return false; } - current = current.parent || undefined; - } - return true; -}; +const noRaycast = () => undefined; interface StaticPlantSpreadInstance { id?: number; @@ -226,10 +202,9 @@ export const findPlantById = ( const PlantSpreadInstancesBase = (props: PlantSpreadInstancesProps) => { const { - config, plants, getZ, visible, dispatch, activePositionRef, spreadVisible, + config, plants, getZ, visible, activePositionRef, spreadVisible, } = props; const instanceCapacity = Math.max(props.instanceCapacity || 0, plants.length); - const navigate = useNavigate(); // eslint-disable-next-line no-null/no-null const instancedRef = React.useRef(null); const tempMatrix = React.useMemo(() => new Matrix4(), []); @@ -472,24 +447,6 @@ const PlantSpreadInstancesBase = (props: PlantSpreadInstancesProps) => { updateState.lastUpdateKey = updateKey; }); - const onClick = (event: ThreeEvent) => { - if (clickWasDragged(event)) { return; } - const instanceId = event.instanceId; - if (isUndefined(instanceId)) { return; } - const plant = plants[instanceId]; - if (plant?.id && (dispatch || props.onSelectObject) && visible && - ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) { - if (props.onSelectObject) { - props.onSelectObject({ kind: "plant", id: plant.id }) !== false && - event.stopPropagation?.(); - return; - } - event.stopPropagation?.(); - dispatch?.(setPanelOpen3D(true)); - navigate(Path.plants(plant.id)); - } - }; - if (!spreadInstancesVisible) { return <>; } return { count={plants.length} userData={{ plantIndexes }} visible={visible} - raycast={plantSpreadRaycast} - onClick={onClick} - onPointerOver={() => props.onHoverObject?.(true)} - onPointerOut={() => props.onHoverObject?.(false)}> + raycast={noRaycast}> { const { point, config } = props; const navigate = useNavigate(); const unsaved = point.specialStatus !== SpecialStatus.SAVED; + const pointId = point.body.id; return { color={point.body.meta.color} radius={point.body.radius} onHoverObject={props.onHoverObject} + onHoverLabel={pointId + ? hovered => props.onHoverLabel?.(hovered + ? { kind: "point", id: pointId } + : undefined) + : undefined} />; }; @@ -164,6 +172,7 @@ export interface PointInstancesProps { getZ(x: number, y: number): number; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?: ThreeDObjectHoverLabelHandler; } const pointAlpha = (point: TaggedGenericPointer) => @@ -282,6 +291,17 @@ const PointBucketInstances = (props: PointInstanceBucketProps) => { navigate(Path.points(point.body.id)); } }; + const onHover = (instances: PointInstance[], hovered: boolean) => + (event?: ThreeEvent) => { + props.onHoverObject?.(hovered); + const instanceId = event?.instanceId; + if (!hovered || isUndefined(instanceId)) { + props.onHoverLabel?.(undefined); + return; + } + const id = instances[instanceId]?.point.body.id; + props.onHoverLabel?.(id ? { kind: "point", id } : undefined); + }; return <> { dispose={null} visible={visible} onClick={onClick(group.points)} - onPointerOver={() => props.onHoverObject?.(true)} - onPointerOut={() => props.onHoverObject?.(false)} + onPointerOver={onHover(group.points, true)} + onPointerOut={onHover(group.points, false)} renderOrder={RenderOrder.points}> { dispose={null} visible={visible} onClick={onClick(group.ringPoints)} - onPointerOver={() => props.onHoverObject?.(true)} - onPointerOut={() => props.onHoverObject?.(false)} + onPointerOver={onHover(group.ringPoints, true)} + onPointerOut={onHover(group.ringPoints, false)} renderOrder={RenderOrder.points}> { @@ -456,6 +477,7 @@ interface PointBaseProps { torusRef?: TorusRef; billboardRef?: BillboardRef; imageRef?: ImageRef; + onHoverLabel?(hovered: boolean): void; } const PointBase = (props: PointBaseProps) => { @@ -470,8 +492,14 @@ const PointBase = (props: PointBaseProps) => { position={position ? getWorldPosition(position) : [0, 0, 0]} - onPointerOver={() => props.onHoverObject?.(true)} - onPointerOut={() => props.onHoverObject?.(false)}> + onPointerOver={() => { + props.onHoverObject?.(true); + props.onHoverLabel?.(true); + }} + onPointerOut={() => { + props.onHoverObject?.(false); + props.onHoverLabel?.(false); + }}> undefined; const useWeedIconTexture = (plantIconAtlas = PLANT_ICON_ATLAS) => { const baseTexture = useTexture( @@ -61,10 +63,12 @@ export interface WeedProps { getZ(x: number, y: number): number; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?: ThreeDObjectHoverLabelHandler; } export const Weed = (props: WeedProps) => { const { weed, config } = props; + const weedId = weed.body.id; const navigate = useNavigate(); return { config={config} color={weed.body.meta.color} radius={weed.body.radius} + onHoverLabel={weedId + ? hovered => props.onHoverLabel?.(hovered + ? { kind: "weed", id: weedId } + : undefined) + : undefined} onHoverObject={props.onHoverObject} />; }; @@ -107,6 +116,7 @@ interface WeedBaseProps { billboardRef?: BillboardRef; imageRef?: ImageRef; onHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?(hovered: boolean): void; } export const WeedBase = (props: WeedBaseProps) => { @@ -124,8 +134,14 @@ export const WeedBase = (props: WeedBaseProps) => { ? getWorldPosition(position) : [0, 0, 0]} onClick={onClick} - onPointerOver={() => props.onHoverObject?.(true)} - onPointerOut={() => props.onHoverObject?.(false)}> + onPointerOver={() => { + props.onHoverObject?.(true); + props.onHoverLabel?.(true); + }} + onPointerOut={() => { + props.onHoverObject?.(false); + props.onHoverLabel?.(false); + }}> { scale={weedSize} renderOrder={RenderOrder.weedSpheres} args={[1, 32, 32]} + raycast={noRaycast} position={[0, 0, iconSize / 2]}> { event.stopPropagation?.(); } }; + const onHover = (hovered: boolean) => + (event?: ThreeEvent) => { + props.onHoverObject?.(hovered); + if (!hovered) { + props.onHoverLabel?.(undefined); + return; + } + const instanceId = event?.instanceId; + const id = isUndefined(instanceId) + ? undefined + : weedInstances[instanceId]?.weed.body.id; + props.onHoverLabel?.(id ? { kind: "weed", id } : undefined); + }; return { args={[undefined, undefined, weedInstances.length]} visible={visible} onClick={onClick} - onPointerOver={() => props.onHoverObject?.(true)} - onPointerOut={() => props.onHoverObject?.(false)} + onPointerOver={onHover(true)} + onPointerOut={onHover(false)} renderOrder={RenderOrder.weedImages}> { - const { bucket, dispatch, visible } = props; - const navigateToWeed = - useNavigateToWeed(dispatch, visible, props.onSelectObject); + const { bucket, visible } = props; // eslint-disable-next-line no-null/no-null const instancedRef = React.useRef(null); const radiusGeometry = getWeedRadiusGeometry(); @@ -388,15 +417,6 @@ const WeedRadiusInstances = (props: WeedRadiusInstancesProps) => { tempScale, ]); - const onClick = (event: ThreeEvent) => { - if (clickWasDragged(event)) { return; } - const instanceId = event.instanceId; - if (isUndefined(instanceId)) { return; } - if (navigateToWeed(bucket.weeds[instanceId]?.weed)) { - event.stopPropagation?.(); - } - }; - return { // eslint-disable-next-line no-null/no-null dispose={null} visible={visible} - onClick={onClick} - onPointerOver={() => props.onHoverObject?.(true)} - onPointerOut={() => props.onHoverObject?.(false)} + raycast={noRaycast} renderOrder={RenderOrder.weedSpheres}> !GRID_SELECTION_BLOCKED_MODES.includes(getMode()); +const PROMO_POPUP_DISABLED_KINDS = ["camera", "utm", "electronics"]; +const promoPopupDisabled = ( + promo: boolean | undefined, + selection: ThreeDObjectSelection, +) => !!promo && PROMO_POPUP_DISABLED_KINDS.includes(selection.kind); +const HOVER_LABEL_FONT_SIZE = 50; +const HOVER_LABEL_PADDING = 40; +const TOOL_LABEL_Z_OFFSET = 35; +const TOOL_LABEL_TEXT_OFFSET = 80; +const useMultiSelectModifier = () => { + const modifierRef = React.useRef(false); + React.useEffect(() => { + const updateModifier = (event: KeyboardEvent) => { + modifierRef.current = event.ctrlKey || event.metaKey; + }; + const clearModifier = () => { modifierRef.current = false; }; + window.addEventListener("keydown", updateModifier); + window.addEventListener("keyup", updateModifier); + window.addEventListener("blur", clearModifier); + return () => { + window.removeEventListener("keydown", updateModifier); + window.removeEventListener("keyup", updateModifier); + window.removeEventListener("blur", clearModifier); + }; + }, []); + return modifierRef; +}; + +const selectionPointTypeFor = ( + selection: ThreeDObjectSelection | undefined, +) => selection && pointTypeForSelectionKind(selection.kind); + +const selectionPointTypesFor = ( + currentType: PointType, + selectionType: PointType, +) => currentType == selectionType ? [currentType] : [...POINTER_TYPES]; + const LazyBot = React.lazy(() => import("./bot").then(module => ({ default: module.Bot }))); const LazyVisualization = React.lazy(() => @@ -117,6 +164,84 @@ const LazyVisualization = React.lazy(() => export const SMOOTH_XL_CAMERA_BED_SCALE = 1.9; export const SMOOTH_XL_CAMERA_HEIGHT_SCALE = 1.45; +interface ObjectHoverLabelProps { + label: string; + position: [number, number, number]; + textOffset: number; +} + +const ObjectHoverLabel = (props: ObjectHoverLabelProps) => + + + {props.label} + + ; + +interface ObjectHoverLabelLookupProps { + selection: ThreeDObjectSelection; + config: Config; + configPosition: PositionConfig; + getZ(x: number, y: number): number; + mapPoints: TaggedGenericPointer[]; + toolSlots: SlotWithTool[]; + weeds: TaggedWeedPointer[]; +} + +const weedHoverLabel = (props: ObjectHoverLabelLookupProps) => { + const weed = props.weeds.find(resource => + resource.body.id == props.selection.id); + if (!weed) { return undefined; } + const radius = weed.body.radius == 0 ? 50 : weed.body.radius; + return ; +}; + +const pointHoverLabel = (props: ObjectHoverLabelLookupProps) => { + const point = props.mapPoints.find(resource => + resource.body.id == props.selection.id); + if (!point) { return undefined; } + return ; +}; + +const slotHoverLabel = (props: ObjectHoverLabelLookupProps) => { + const slot = props.toolSlots.find(resource => + resource.toolSlot.body.id == props.selection.id); + if (!slot) { return undefined; } + const position = + getToolSlotRenderPosition(props.config, props.configPosition, slot); + return ; +}; + +const objectHoverLabel = (props: ObjectHoverLabelLookupProps) => { + switch (props.selection.kind) { + case "weed": return weedHoverLabel(props); + case "point": return pointHoverLabel(props); + case "slot": return slotHoverLabel(props); + default: return undefined; + } +}; + interface ZoomBeaconsLoadInProps extends ZoomBeaconsProps { reveal?: boolean; onRest?: () => void; @@ -213,6 +338,7 @@ export interface GardenModelProps { seasonResetKey?: number; preloadEnvironmentScenes?: boolean; showFarmbotLayerLoadProgress?: boolean; + promo?: boolean; onDetailsRevealStart?(): void; onLoadComplete?(): void; } @@ -402,6 +528,7 @@ interface StaticGardenLayersProps { weedsSelectable: boolean; onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?(selection: ThreeDObjectSelection | undefined): void; onPlantHoverChange(hovered: boolean): void; } @@ -417,7 +544,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { dispatch, showSpread, plantInstanceCapacity, routeKey, seasonResetKey, showWeeds, weeds, showPoints, plantsSelectable, pointsSelectable, weedsSelectable, - onSelectObject, onHoverObject, onPlantHoverChange, + onSelectObject, onHoverObject, onHoverLabel, onPlantHoverChange, } = props; const seasonLayerKey = `${config.plants}-${seasonResetKey || 0}`; const gridVisible = config.grid && activeFocus != "Planter bed"; @@ -582,6 +709,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { plantIconAtlas={plantIconAtlas} onSelectObject={weedsSelectable ? onSelectObject : undefined} onHoverObject={weedsSelectable ? onHoverObject : undefined} + onHoverLabel={weedsSelectable ? onHoverLabel : undefined} dispatch={weedsSelectable ? dispatch : undefined} /> } @@ -609,6 +737,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => { getZ={getZ} onSelectObject={pointsSelectable ? onSelectObject : undefined} onHoverObject={pointsSelectable ? onHoverObject : undefined} + onHoverLabel={pointsSelectable ? onHoverLabel : undefined} dispatch={pointsSelectable ? dispatch : undefined} /> } @@ -622,6 +751,7 @@ const isStaticGardenLayerIgnoredProp = ( key == "markStep" || key == "onSelectObject" || key == "onHoverObject" + || key == "onHoverLabel" || key == "onPlantHoverChange"; const staticGardenLayersPropsEqual = ( @@ -722,6 +852,7 @@ interface FarmbotLoadInProps { onSelectObject?: ThreeDObjectSelectionHandler; onHoverObject?: ThreeDObjectHoverHandler; onToolSlotHoverObject?: ThreeDObjectHoverHandler; + onHoverLabel?(selection: ThreeDObjectSelection | undefined): void; } const FarmbotLoadIn = (props: FarmbotLoadInProps) => @@ -747,6 +878,7 @@ const FarmbotLoadIn = (props: FarmbotLoadInProps) => onSelectObject={props.onSelectObject} onHoverObject={props.onHoverObject} onToolSlotHoverObject={props.onToolSlotHoverObject} + onHoverLabel={props.onHoverLabel} toolSlots={props.toolSlots} /> ; @@ -796,6 +928,7 @@ const FarmbotLayer = (props: FarmbotLayerProps) => { onHoverObject={props.onHoverObject} onToolSlotHoverObject={props.onToolSlotHoverObject} onSelectObject={props.onSelectObject} + onHoverLabel={props.onHoverLabel} reveal={layerReveal} toolSlots={props.toolSlots} /> @@ -1039,6 +1172,7 @@ export const GardenModel = (props: GardenModelProps) => { baseConfig, props.smoothConfigTransitions, ); + const configPosition = props.configPosition; const cameraConfig = props.smoothConfigTransitions ? baseConfig : config; @@ -1079,9 +1213,12 @@ export const GardenModel = (props: GardenModelProps) => { toolSlots, weeds, ]); + const multiSelectModifier = useMultiSelectModifier(); const [hoveredPlant, setHoveredPlant] = React.useState(undefined); + const [hoveredObjectLabel, setHoveredObjectLabel] = + React.useState(undefined); const [selectableObjectHoverCount, setSelectableObjectHoverCount] = React.useState(0); const [plantIntersected, setPlantIntersected] = React.useState(false); @@ -1093,8 +1230,13 @@ export const GardenModel = (props: GardenModelProps) => { (hovered: boolean) => setSelectableObjectHoverCount(count => hovered ? count + 1 : Math.max(0, count - 1)), []); + const setObjectHoverLabel = React.useCallback( + (selection: ThreeDObjectSelection | undefined) => + setHoveredObjectLabel(selection), + []); const handleCameraDragStart = React.useCallback(() => { setSelectableObjectHoverCount(0); + setHoveredObjectLabel(undefined); setCameraDragging(true); }, []); const handleCameraDragEnd = React.useCallback(() => { @@ -1103,6 +1245,7 @@ export const GardenModel = (props: GardenModelProps) => { const handleScenePointerLeave = React.useCallback(() => { setSelectableObjectHoverCount(0); setPlantIntersected(false); + setHoveredObjectLabel(undefined); setGridHoverPosition(undefined); }, []); const handleScenePointerMove = React.useCallback((event: ThreeEvent) => { @@ -1227,9 +1370,55 @@ export const GardenModel = (props: GardenModelProps) => { React.useState(undefined); const [locationSelection, setLocationSelection] = React.useState(undefined); + const routeSelection = React.useMemo( + () => routeSelectionFromPath(routeLocation.pathname), + [routeLocation.pathname]); + const activePopupSelection = objectSelectionMode ? undefined : popupSelection; + const activeLocationSelection = + objectSelectionMode ? undefined : locationSelection; + const closePopup = React.useCallback(() => { + setPopupSelection(undefined); + setLocationSelection(undefined); + }, []); + const openMultiSelectPanel = React.useCallback(( + selection: ThreeDObjectSelection, + ) => { + if (!dispatch) { return false; } + const currentSelection = activePopupSelection || routeSelection; + const currentType = selectionPointTypeFor(currentSelection); + const selectionType = selectionPointTypeFor(selection); + const currentUuid = currentSelection && + uuidForSelection(selectionLookup, currentSelection); + const selectionUuid = uuidForSelection(selectionLookup, selection); + if (!currentUuid || !selectionUuid || currentUuid == selectionUuid) { + return false; + } + if (!currentType || !selectionType) { return false; } + dispatch({ + type: Actions.SET_SELECTION_POINT_TYPE, + payload: selectionPointTypesFor(currentType, selectionType), + }); + dispatch(selectPoint(uniq([currentUuid, selectionUuid]))); + dispatch(setPanelOpen3D(true)); + navigate(Path.plants("select")); + closePopup(); + return true; + }, [ + activePopupSelection, + closePopup, + dispatch, + navigate, + routeSelection, + selectionLookup, + ]); const onSelectObject = React.useCallback(( selection: ThreeDObjectSelection, ) => { + if (promoPopupDisabled(props.promo, selection)) { + setLocationSelection(undefined); + setPopupSelection(undefined); + return true; + } if (objectSelectionMode) { const uuid = uuidForSelection(selectionLookup, selection); if (uuid && selectionKindAllowed(selection.kind, selectionPointType)) { @@ -1240,12 +1429,18 @@ export const GardenModel = (props: GardenModelProps) => { } return false; } + if (multiSelectModifier.current && openMultiSelectPanel(selection)) { + return true; + } setLocationSelection(undefined); setPopupSelection(selection); return true; }, [ dispatch, + multiSelectModifier, objectSelectionMode, + openMultiSelectPanel, + props.promo, selectionLookup, selectionPointType, ]); @@ -1260,13 +1455,6 @@ export const GardenModel = (props: GardenModelProps) => { ) => { setLocationSelection(selection); }, []); - const closePopup = React.useCallback(() => { - setPopupSelection(undefined); - setLocationSelection(undefined); - }, []); - const activePopupSelection = objectSelectionMode ? undefined : popupSelection; - const activeLocationSelection = - objectSelectionMode ? undefined : locationSelection; const openSelectedObjectPanel = React.useCallback(( selection: ThreeDObjectSelection, ) => { @@ -1351,9 +1539,6 @@ export const GardenModel = (props: GardenModelProps) => { selectionLookup, selectionPanelOpen, ]); - const routeSelection = React.useMemo( - () => routeSelectionFromPath(routeLocation.pathname), - [routeLocation.pathname]); const selectedLocation = React.useMemo( () => routeLocationSelectionFromPath( routeLocation.pathname, @@ -1376,7 +1561,10 @@ export const GardenModel = (props: GardenModelProps) => { const visualSelection = activePopupSelection || hoverSelection || routeSelection; const gridHoverEnabled = - config.grid && props.activeFocus != "Planter bed" && gridSelectionAllowed(); + !props.promo + && config.grid + && props.activeFocus != "Planter bed" + && gridSelectionAllowed(); const activeGridHoverPosition = gridHoverEnabled ? gridHoverPosition : undefined; @@ -1513,6 +1701,26 @@ export const GardenModel = (props: GardenModelProps) => { hoveredPlant, plantLabelConfig, ]); + const objectHoverLabelNode = React.useMemo(() => { + if (!config.labelsOnHover || !hoveredObjectLabel) { return undefined; } + return objectHoverLabel({ + selection: hoveredObjectLabel, + config, + configPosition, + getZ, + mapPoints, + toolSlots, + weeds, + }); + }, [ + config, + configPosition, + getZ, + hoveredObjectLabel, + mapPoints, + toolSlots, + weeds, + ]); let cameraScale: number | typeof scale = scale; if (props.smoothFocusTransitions || props.activeFocus) { @@ -1604,8 +1812,10 @@ export const GardenModel = (props: GardenModelProps) => { weedsSelectable={weedsSelectable} onSelectObject={onSelectObject} onHoverObject={setSelectableObjectHover} + onHoverLabel={config.labelsOnHover ? setObjectHoverLabel : undefined} onPlantHoverChange={setPlantIntersected} showPoints={showPoints} /> + {objectHoverLabelNode} {gridHoverEnabled && { onToolSlotHoverObject={objectSelectionMode && slotsSelectable ? setSelectableObjectHover : undefined} + onHoverLabel={config.labelsOnHover && slotsSelectable + ? setObjectHoverLabel + : undefined} visible={farmbotVisible} /> { const positions: [number, number, number][] = sortedPoints .map(p => { if (p.body.pointer_type == "ToolSlot") { - return getWorldPosition({ x: p.body.x, y: p.body.y, z: p.body.z + 25 }); + return getWorldPosition({ x: p.body.x, y: p.body.y, z: p.body.z + 35 }); } if (p.body.pointer_type == "GenericPointer") { return getWorldPosition({ x: p.body.x, y: p.body.y, - z: getZ(p.body.x, p.body.y) + 75, + z: getZ(p.body.x, p.body.y) + 100, + }); + } + if (p.body.pointer_type == "Weed") { + return getWorldPosition({ + x: p.body.x, + y: p.body.y, + z: getZ(p.body.x, p.body.y) + p.body.radius + 25, }); } return getWorldPosition({ x: p.body.x, y: p.body.y, - z: getZ(p.body.x, p.body.y) + p.body.radius + 10, + z: getZ(p.body.x, p.body.y) + 2 * p.body.radius + 25, }); }); return @@ -144,7 +151,6 @@ const GroupOrder = (props: GroupOrderProps) => { diff --git a/frontend/three_d_garden/selection.tsx b/frontend/three_d_garden/selection.tsx index a72d2bfe6..4ef4943b9 100644 --- a/frontend/three_d_garden/selection.tsx +++ b/frontend/three_d_garden/selection.tsx @@ -2,6 +2,7 @@ export { ThreeDObjectSelectionLayer } from "./selection/layer"; export { createSelectionLookup, hoverSelectionFromDesigner, pathForThreeDSelection, + pointTypeForSelectionKind, routeLocationSelectionFromPath, routeSelectionFromPath, selectionForUuid, selectionKindAllowed, uuidForSelection diff --git a/frontend/three_d_garden/selection/routes.ts b/frontend/three_d_garden/selection/routes.ts index 3dd690651..a5e1137f6 100644 --- a/frontend/three_d_garden/selection/routes.ts +++ b/frontend/three_d_garden/selection/routes.ts @@ -17,11 +17,15 @@ const POINT_TYPE_BY_SELECTION_KIND: slot: "ToolSlot", }; +export const pointTypeForSelectionKind = ( + kind: ThreeDObjectSelection["kind"], +) => POINT_TYPE_BY_SELECTION_KIND[kind]; + export const selectionKindAllowed = ( kind: ThreeDObjectSelection["kind"], selectionPointType: PointType[] | undefined, ) => { - const pointType = POINT_TYPE_BY_SELECTION_KIND[kind]; + const pointType = pointTypeForSelectionKind(kind); return !!pointType && (selectionPointType || ["Plant"]).includes(pointType); }; diff --git a/frontend/three_d_garden/selection_types.ts b/frontend/three_d_garden/selection_types.ts index a750b0253..891c64210 100644 --- a/frontend/three_d_garden/selection_types.ts +++ b/frontend/three_d_garden/selection_types.ts @@ -20,3 +20,7 @@ export interface ThreeDObjectSelectionHandler { export interface ThreeDObjectHoverHandler { (hovered: boolean): void; } + +export interface ThreeDObjectHoverLabelHandler { + (selection: ThreeDObjectSelection | undefined): void; +} From 4d0143ea5b96036cceda40c6e92d9dc55b9f4ef2 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 5 Jun 2026 11:15:30 -0700 Subject: [PATCH 09/18] remove extra tool click hit targets --- .../three_d_garden/bot/components/tools.tsx | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 8376d9f89..37453b7e9 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -13,7 +13,7 @@ import { SeedTroughHolderFull, SeedTroughHolderModel, } from "../parts"; import { - BoxGeometry, Group, Mesh, MeshBasicMaterial, MeshPhongMaterial, + Group, Mesh, MeshPhongMaterial, } from "../../components"; import { distinguishableBlack } from "../positioning"; import { SlotWithTool } from "../../../resources/interfaces"; @@ -373,30 +373,9 @@ const useToolSlotClick = (props: ToolbaySlotProps) => { }; }; -const SLOT_HITBOX_SIZE: [number, number, number] = [120, 120, 30]; -const SLOT_HITBOX_TOP_ABOVE_POSITION = 10; const TOOLBAY_SLOT_Z_OFFSET = -9; const SEED_TROUGH_SLOT_Z_OFFSET = -40; -interface SlotHitTargetProps { - groupZOffset: number; -} - -const SlotHitTarget = (props: SlotHitTargetProps) => - - - - ; - const ToolbaySlot = (props: ToolbaySlotProps) => { const { position, children, toolPulloutDirection, mounted } = props; const selectable = !!props.id || !props.inToolbay; @@ -427,8 +406,6 @@ const ToolbaySlot = (props: ToolbaySlotProps) => { props.onHoverObject?.(false); props.onHoverLabel?.(undefined); }}> - {selectable && props.inToolbay && - } {rotationMultiplier && @@ -650,8 +627,6 @@ const SeedTroughToolSlot = (props: SeedTroughToolSlotProps) => { props.onHoverObject?.(false); props.onHoverLabel?.(undefined); }}> - {selectable && - } ; }; From df88bf14fe9f03cdd3c70383a663a2609e5ce1b2 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 5 Jun 2026 11:54:32 -0700 Subject: [PATCH 10/18] fix go button issue --- frontend/farm_designer/move_to.tsx | 10 ++++++---- frontend/three_d_garden/selection/popup_controls.tsx | 1 + frontend/three_d_garden/selection/popups.tsx | 5 +++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/farm_designer/move_to.tsx b/frontend/farm_designer/move_to.tsx index c291ffad0..03dd9794f 100644 --- a/frontend/farm_designer/move_to.tsx +++ b/frontend/farm_designer/move_to.tsx @@ -166,6 +166,7 @@ export interface GoToThisLocationButtonProps { dispatch: Function; currentBotLocation: BotPosition; movementState: MovementState; + noOptions?: boolean; } interface GoToThisLocationButtonState { @@ -259,10 +260,11 @@ export class GoToThisLocationButton onChange={this.toggle("setAsDefault")} checked={this.state.setAsDefault} /> - - {t("More options")} - - + {!this.props.noOptions && + + {t("More options")} + + } } /> ; } diff --git a/frontend/three_d_garden/selection/popup_controls.tsx b/frontend/three_d_garden/selection/popup_controls.tsx index f7bf8b1ef..44bd5b15a 100644 --- a/frontend/three_d_garden/selection/popup_controls.tsx +++ b/frontend/three_d_garden/selection/popup_controls.tsx @@ -76,6 +76,7 @@ const GoButton = (props: GoButtonProps) => arduinoBusy={props.arduinoBusy} currentBotLocation={props.currentBotLocation} movementState={props.movementState} + noOptions={true} defaultAxes={props.defaultAxes} />; const PopupLocationRow = (props: PopupLocationRowProps) => diff --git a/frontend/three_d_garden/selection/popups.tsx b/frontend/three_d_garden/selection/popups.tsx index 745a42773..c67502aa4 100644 --- a/frontend/three_d_garden/selection/popups.tsx +++ b/frontend/three_d_garden/selection/popups.tsx @@ -19,8 +19,8 @@ interface ObjectPopupProps extends ThreeDObjectSelectionLayerProps { visible: boolean; } -export const ObjectPopup = (props: ObjectPopupProps) => - { + return ; +}; interface LocationPopupProps extends ThreeDObjectSelectionLayerProps { object: ResolvedLocationObject; From 8f13a2977a2c9664523d1cc7250226efc76f2838 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 5 Jun 2026 12:04:23 -0700 Subject: [PATCH 11/18] add laser toggle --- .../css/farm_designer/three_d_garden.scss | 1 + frontend/farm_designer/index.tsx | 12 ++++++-- frontend/farm_designer/map/interfaces.ts | 3 ++ .../map/legend/garden_map_legend.tsx | 29 +++++++++++++++++-- frontend/farm_designer/three_d_garden_map.tsx | 4 ++- frontend/three_d_garden/garden_model.tsx | 2 ++ frontend/three_d_garden/index.tsx | 2 ++ .../selection/popup_controls.tsx | 10 +++++++ frontend/three_d_garden/selection/props.ts | 1 + 9 files changed, 58 insertions(+), 6 deletions(-) diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss index 96dc44455..17a0cf5ea 100644 --- a/frontend/css/farm_designer/three_d_garden.scss +++ b/frontend/css/farm_designer/three_d_garden.scss @@ -152,6 +152,7 @@ } .object-popup-trail-row, + .object-popup-laser-row, .object-popup-camera-row { align-items: center; diff --git a/frontend/farm_designer/index.tsx b/frontend/farm_designer/index.tsx index 90762bf8c..172ce3405 100755 --- a/frontend/farm_designer/index.tsx +++ b/frontend/farm_designer/index.tsx @@ -26,7 +26,9 @@ import { ProfileViewer } from "./map/profile"; import { ThreeDGardenMap } from "./three_d_garden_map"; import { NavigateFunction, Outlet } from "react-router"; import { ErrorBoundary } from "../error_boundary"; -import { get3DConfigValueFunction } from "../settings/three_d_settings"; +import { + findOrCreate3DConfigFunction, get3DConfigValueFunction, +} from "../settings/three_d_settings"; import { isDesktop, isMobile } from "../screen_size"; import { NavigationContext } from "../routes_helpers"; @@ -169,6 +171,9 @@ export class RawFarmDesigner const padHeightOffset = mapPadding.top - mapPadding.top / zoom_level; const threeDGarden = !!this.props.getConfigValue(BooleanSetting.three_d_garden); + const get3DConfigValue = get3DConfigValueFunction(this.props.farmwareEnvs); + const set3DConfigValue = findOrCreate3DConfigFunction( + this.props.dispatch, this.props.farmwareEnvs); return
@@ -191,6 +196,8 @@ export class RawFarmDesigner dispatch={this.props.dispatch} timeSettings={this.props.timeSettings} getConfigValue={this.props.getConfigValue} + get3DConfigValue={get3DConfigValue} + set3DConfigValue={set3DConfigValue} allPoints={this.props.allPoints} sourceFbosConfig={this.props.sourceFbosConfig} firmwareConfig={this.props.botMcuParams} @@ -220,7 +227,8 @@ export class RawFarmDesigner deviceAccount={this.props.deviceAccount} bot={this.props.bot} plants={this.props.plants} - get3DConfigValue={get3DConfigValueFunction(this.props.farmwareEnvs)} + get3DConfigValue={get3DConfigValue} + set3DConfigValue={set3DConfigValue} sourceFbosConfig={this.props.sourceFbosConfig} negativeZ={!!this.props.botMcuParams.movement_home_up_z} gridOffset={gridOffset} diff --git a/frontend/farm_designer/map/interfaces.ts b/frontend/farm_designer/map/interfaces.ts index 64ff0e8b5..50b0cf20c 100644 --- a/frontend/farm_designer/map/interfaces.ts +++ b/frontend/farm_designer/map/interfaces.ts @@ -19,6 +19,7 @@ import type { TimeSettings } from "../../interfaces"; import type { UUID } from "../../resources/interfaces"; import type { PeripheralValues } from "./layers/farmbot/bot_trail"; import type { GetColor } from "./layers/points/interpolation_map"; +import type { Config } from "../../three_d_garden/config"; export type TaggedPlant = TaggedPlantPointer | TaggedPlantTemplate; @@ -57,6 +58,8 @@ export interface GardenMapLegendProps { dispatch: Function; timeSettings: TimeSettings; getConfigValue: GetWebAppConfigValue; + get3DConfigValue?(key: keyof Config): number; + set3DConfigValue?(key: keyof Config, value: string): void; imageAgeInfo: { newestDate: string, toOldest: number }; gardenId?: number; className?: string; diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx index ad12211dd..9e61e0cc8 100644 --- a/frontend/farm_designer/map/legend/garden_map_legend.tsx +++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx @@ -29,6 +29,7 @@ import { McuParams } from "farmbot"; import { DesignerState } from "../../interfaces"; import { isTopDown } from "../../../three_d_garden/helpers"; import { isMobile } from "../../../screen_size"; +import type { Config } from "../../../three_d_garden/config"; export interface ZoomControlsProps { zoom(value: number): () => void; @@ -93,6 +94,8 @@ const NonLayerToggle = (props: NonLayerToggleProps) => { export interface SettingsSubMenuProps { dispatch: Function; getConfigValue: GetWebAppConfigValue; + get3DConfigValue?(key: keyof Config): number; + set3DConfigValue?(key: keyof Config, value: string): void; firmwareConfig: McuParams; designer: DesignerState; } @@ -117,18 +120,31 @@ export const PlantsSubMenu = (props: SettingsSubMenuProps) => helpText={Content.CONFIRM_PLANT_DELETION} />
; -export const FarmbotSubMenu = (props: SettingsSubMenuProps) => -
+export const FarmbotSubMenu = (props: SettingsSubMenuProps) => { + const laser = !!props.get3DConfigValue?.("laser"); + const is3D = props.getConfigValue(BooleanSetting.three_d_garden); + const laserAvailable = !!(props.get3DConfigValue && props.set3DConfigValue); + return
+ {is3D && laserAvailable && + + props.set3DConfigValue?.( + "laser", laser ? "0" : "1")} /> + }
; +}; interface LayerTogglesProps extends GardenMapLegendProps { zDisplayOpen: boolean; @@ -165,7 +181,14 @@ const GardenMapLegendToggle = (props: GardenMapLegendToggleProps) => { const LayerToggles = (props: LayerTogglesProps) => { const { toggle, getConfigValue, dispatch, firmwareConfig, designer } = props; - const subMenuProps = { dispatch, getConfigValue, firmwareConfig, designer }; + const subMenuProps = { + dispatch, + getConfigValue, + get3DConfigValue: props.get3DConfigValue, + set3DConfigValue: props.set3DConfigValue, + firmwareConfig, + designer, + }; const is3D = getConfigValue(BooleanSetting.three_d_garden); const only2DClass = is3D ? "disabled" : ""; const topDown = isTopDown(designer, getConfigValue); diff --git a/frontend/farm_designer/three_d_garden_map.tsx b/frontend/farm_designer/three_d_garden_map.tsx index dc8d94ded..1b19f963d 100644 --- a/frontend/farm_designer/three_d_garden_map.tsx +++ b/frontend/farm_designer/three_d_garden_map.tsx @@ -34,7 +34,8 @@ export interface ThreeDGardenMapProps { botSize: BotSize; mapTransformProps: MapTransformProps; gridOffset: AxisNumberProperty; - get3DConfigValue(key: string): number; + get3DConfigValue(key: keyof Config): number; + set3DConfigValue?(key: keyof Config, value: string): void; sourceFbosConfig: SourceFbosConfig; negativeZ: boolean; designer: DesignerState; @@ -413,6 +414,7 @@ export const ThreeDGardenMap = (props: ThreeDGardenMapProps) => { sensorReadings={props.sensorReadings} sensors={props.sensors} env={props.env} + set3DConfigValue={props.set3DConfigValue} addPlantProps={addPlantProps} />; }; diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index 05640b8ca..fd19eb9e7 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -330,6 +330,7 @@ export interface GardenModelProps { sensorReadings?: TaggedSensorReading[]; sensors?: TaggedSensor[]; env?: UserEnv; + set3DConfigValue?(key: keyof Config, value: string): void; smoothFocusTransitions?: boolean; smoothConfigTransitions?: boolean; plantIconCapacities?: Record; @@ -1881,6 +1882,7 @@ export const GardenModel = (props: GardenModelProps) => { deviceAccount={props.deviceAccount} bot={props.bot} env={props.env || EMPTY_ENV} + set3DConfigValue={props.set3DConfigValue} dispatch={dispatch} gridLoaded={gridLoaded} getZ={getZ} /> diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx index 0afa8d2e8..9338c9369 100644 --- a/frontend/three_d_garden/index.tsx +++ b/frontend/three_d_garden/index.tsx @@ -49,6 +49,7 @@ export interface ThreeDGardenProps { sensorReadings?: TaggedSensorReading[]; sensors?: TaggedSensor[]; env?: UserEnv; + set3DConfigValue?(key: keyof Config, value: string): void; } export const ThreeDGarden = React.memo((props: ThreeDGardenProps) => { @@ -93,6 +94,7 @@ export const ThreeDGarden = React.memo((props: ThreeDGardenProps) => { sensorReadings={props.sensorReadings} sensors={props.sensors} env={props.env} + set3DConfigValue={props.set3DConfigValue} addPlantProps={props.addPlantProps} />
diff --git a/frontend/three_d_garden/selection/popup_controls.tsx b/frontend/three_d_garden/selection/popup_controls.tsx index 44bd5b15a..1bd8e9658 100644 --- a/frontend/three_d_garden/selection/popup_controls.tsx +++ b/frontend/three_d_garden/selection/popup_controls.tsx @@ -297,6 +297,16 @@ const UtmPopupControls = (props: PopupControlProps) => { title={`${t("toggle")} ${t(DeviceSetting.trail)}`} customText={{ textFalse: t("off"), textTrue: t("on") }} /> + {props.set3DConfigValue && +
+ + props.set3DConfigValue?.( + "laser", props.config.laser ? "0" : "1")} + title={`${t("toggle")} ${t("LASER")}`} + customText={{ textFalse: t("off"), textTrue: t("on") }} /> +
} ; }; diff --git a/frontend/three_d_garden/selection/props.ts b/frontend/three_d_garden/selection/props.ts index 77af9bfbb..f6862795a 100644 --- a/frontend/three_d_garden/selection/props.ts +++ b/frontend/three_d_garden/selection/props.ts @@ -41,6 +41,7 @@ export interface ThreeDObjectSelectionLayerProps { deviceAccount: TaggedDevice | undefined; bot: BotState | undefined; env: UserEnv; + set3DConfigValue?(key: keyof Config, value: string): void; dispatch: Function | undefined; gridLoaded: boolean; getZ(x: number, y: number): number; From edffa432b9564d6cd39bcee1824756c2ed775873 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 5 Jun 2026 12:39:35 -0700 Subject: [PATCH 12/18] second click to deselect --- .../__tests__/garden_map_legend_test.tsx | 4 + .../__tests__/garden_model_test.tsx | 93 ++++++++++++++++++- .../__tests__/electronics_box_test.tsx | 10 +- frontend/three_d_garden/garden_model.tsx | 24 ++++- 4 files changed, 125 insertions(+), 6 deletions(-) diff --git a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx index 65e6cd8bc..d75db3b04 100644 --- a/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx +++ b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx @@ -23,11 +23,13 @@ import { } from "../../../../__test_support__/fake_state/resources"; import { fakeDesignerState } from "../../../../__test_support__/fake_designer_state"; import { Actions } from "../../../../constants"; +import * as screenSize from "../../../../screen_size"; let atMaxZoomSpy: jest.SpyInstance; let atMinZoomSpy: jest.SpyInstance; let getWebAppConfigValueSpy: jest.SpyInstance; let setWebAppConfigValueSpy: jest.SpyInstance; +let isMobileSpy: jest.SpyInstance; beforeEach(() => { atMaxZoomSpy = jest.spyOn(zoom, "atMaxZoom").mockImplementation(() => mockAtMax); @@ -36,6 +38,7 @@ beforeEach(() => { .mockImplementation(() => () => false); setWebAppConfigValueSpy = jest.spyOn(configStorageActions, "setWebAppConfigValue") .mockImplementation(jest.fn()); + isMobileSpy = jest.spyOn(screenSize, "isMobile").mockReturnValue(false); }); afterEach(() => { @@ -43,6 +46,7 @@ afterEach(() => { atMinZoomSpy.mockRestore(); getWebAppConfigValueSpy.mockRestore(); setWebAppConfigValueSpy.mockRestore(); + isMobileSpy.mockRestore(); }); describe("", () => { diff --git a/frontend/three_d_garden/__tests__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx index cb9483738..c3871b927 100644 --- a/frontend/three_d_garden/__tests__/garden_model_test.tsx +++ b/frontend/three_d_garden/__tests__/garden_model_test.tsx @@ -899,6 +899,41 @@ describe("", () => { expect(event.stopPropagation).toHaveBeenCalled(); }); + it("deselects active objects and grid locations on second click", () => { + location.pathname = Path.mock(Path.designer()); + useStateSpy.mockRestore(); + const actualUseState = jest.requireActual("react") + .useState as typeof React.useState; + useStateSpy = jest.spyOn(React, "useState") + .mockImplementation(actualUseState); + const p = fakeProps(); + const wrapper = createWrapper(p); + const staticLayers = wrapper.root.findAll(node => + typeof node.props.onSelectObject == "function" + && typeof node.props.onPlantHoverChange == "function")[0]; + const selectObject = staticLayers.props.onSelectObject; + const getSelectionLayer = () => + wrapper.root.findByType(ThreeDObjectSelectionLayer).props; + + actRenderer(() => selectObject({ kind: "plant", id: 1 })); + expect(getSelectionLayer().popupSelection) + .toEqual({ kind: "plant", id: 1 }); + actRenderer(() => selectObject({ kind: "plant", id: 1 })); + expect(getSelectionLayer().popupSelection).toBeUndefined(); + + const hoverTarget = wrapper.root.findByProps({ name: "grid-hover-target" }); + const point = get3DPositionFunc(p.config)({ x: 100, y: 100 }); + const event = { + point, + stopPropagation: jest.fn(), + }; + const locationSelection = { kind: "location", x: 100, y: 100, z: -500 }; + actRenderer(() => hoverTarget.props.onClick(event)); + expect(getSelectionLayer().locationSelection).toEqual(locationSelection); + actRenderer(() => hoverTarget.props.onClick(event)); + expect(getSelectionLayer().locationSelection).toBeUndefined(); + }); + it("clears grid hover when grid selection becomes blocked", () => { const getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); location.pathname = Path.mock(Path.designer()); @@ -977,6 +1012,54 @@ describe("", () => { getModeSpy.mockRestore(); }); + it("opens multi-select from route selection", () => { + location.pathname = Path.mock(Path.plants(1)); + useStateSpy.mockRestore(); + const actualUseState = jest.requireActual("react") + .useState as typeof React.useState; + useStateSpy = jest.spyOn(React, "useState") + .mockImplementation(actualUseState); + const plant = fakePlant(); + plant.body.id = 1; + const point = fakePoint(); + point.body.id = 2; + const p = fakeProps(); + p.plants = [plant]; + p.mapPoints = [point]; + const addPlantProps = fakeAddPlantProps(); + addPlantProps.dispatch = jest.fn(); + p.addPlantProps = addPlantProps; + const addEventSpy = jest.spyOn(window, "addEventListener"); + const wrapper = createWrapper(p); + const staticLayers = wrapper.root.findAll(node => + typeof node.props.onSelectObject == "function" + && typeof node.props.onPlantHoverChange == "function")[0]; + const selectObject = staticLayers.props.onSelectObject; + const keydownHandler = addEventSpy.mock.calls + .find(call => call[0] == "keydown")?.[1] as + ((event: KeyboardEvent) => void) | undefined; + const keyupHandler = addEventSpy.mock.calls + .find(call => call[0] == "keyup")?.[1] as + ((event: KeyboardEvent) => void) | undefined; + const blurHandler = addEventSpy.mock.calls + .find(call => call[0] == "blur")?.[1] as + (() => void) | undefined; + + actRenderer(() => { + keydownHandler?.(new KeyboardEvent("keydown", { ctrlKey: true })); + selectObject({ kind: "plant", id: 1 }); + selectObject({ kind: "point", id: 2 }); + keyupHandler?.(new KeyboardEvent("keyup")); + blurHandler?.(); + }); + + addEventSpy.mockRestore(); + expect(addPlantProps.dispatch).toHaveBeenCalledWith({ + type: "SET_SELECTION_POINT_TYPE", + payload: ["Plant", "GenericPointer", "Weed", "ToolSlot"], + }); + }); + it("suppresses promo popups for FarmBot hardware", () => { const p = fakeProps(); p.promo = true; @@ -1057,14 +1140,20 @@ describe("", () => { z: 3, }); }); - const keydownHandler = addEventSpy.mock.calls - .find(call => call[0] == "keydown")?.[1] as + const keydownHandlers = addEventSpy.mock.calls + .filter(call => call[0] == "keydown") + .map(call => call[1]); + const keydownHandler = keydownHandlers[keydownHandlers.length - 1] as ((event: KeyboardEvent) => void) | undefined; expect(keydownHandler).toBeDefined(); actRenderer(() => { keydownHandler?.(new KeyboardEvent("keydown", { key: "Escape" })); }); addEventSpy.mockRestore(); + expect(wrapper.root.findByType(ThreeDObjectSelectionLayer) + .props.locationSelection).toBeUndefined(); + unmountRenderer(wrapper); + mountedWrappers.splice(mountedWrappers.indexOf(wrapper), 1); }); it("applies scene cursor styles", () => { diff --git a/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx b/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx index ef9af05ae..1af68c9ec 100644 --- a/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx +++ b/frontend/three_d_garden/bot/components/__tests__/electronics_box_test.tsx @@ -11,6 +11,7 @@ import * as mapUtil from "../../../../farm_designer/map/util"; import { Mode } from "../../../../farm_designer/map/interfaces"; const useGltfMock = useGLTF as unknown as jest.Mock; +let getModeSpy: jest.SpyInstance; interface ReactPropsElement extends Element { [key: string]: unknown; @@ -35,6 +36,11 @@ const electronicsBoxPosition = (container: HTMLElement) => { beforeEach(() => { useGltfMock.mockClear(); + getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none); +}); + +afterEach(() => { + getModeSpy.mockRestore(); }); describe("", () => { @@ -66,15 +72,13 @@ describe("", () => { }); it("doesn't select the electronics box in camera selection mode", () => { - const getModeSpy = jest.spyOn(mapUtil, "getMode") - .mockReturnValue(Mode.cameraSelection); + getModeSpy.mockReturnValue(Mode.cameraSelection); const p = fakeProps(); p.onSelectObject = jest.fn(); const { container } = render(); const box = container.querySelector("group[name='box']"); box && fireEvent.click(box); expect(p.onSelectObject).not.toHaveBeenCalled(); - getModeSpy.mockRestore(); }); it("calculates the electronics box position", () => { diff --git a/frontend/three_d_garden/garden_model.tsx b/frontend/three_d_garden/garden_model.tsx index fd19eb9e7..0bef4b0e4 100644 --- a/frontend/three_d_garden/garden_model.tsx +++ b/frontend/three_d_garden/garden_model.tsx @@ -1377,6 +1377,14 @@ export const GardenModel = (props: GardenModelProps) => { const activePopupSelection = objectSelectionMode ? undefined : popupSelection; const activeLocationSelection = objectSelectionMode ? undefined : locationSelection; + const activePopupSelectionRef = + React.useRef(activePopupSelection); + const activeLocationSelectionRef = + React.useRef(activeLocationSelection); + React.useLayoutEffect(() => { + activePopupSelectionRef.current = activePopupSelection; + activeLocationSelectionRef.current = activeLocationSelection; + }, [activeLocationSelection, activePopupSelection]); const closePopup = React.useCallback(() => { setPopupSelection(undefined); setLocationSelection(undefined); @@ -1433,10 +1441,17 @@ export const GardenModel = (props: GardenModelProps) => { if (multiSelectModifier.current && openMultiSelectPanel(selection)) { return true; } + const activeSelection = activePopupSelectionRef.current; + if (activeSelection?.kind == selection.kind && + activeSelection.id == selection.id) { + closePopup(); + return true; + } setLocationSelection(undefined); setPopupSelection(selection); return true; }, [ + closePopup, dispatch, multiSelectModifier, objectSelectionMode, @@ -1448,9 +1463,16 @@ export const GardenModel = (props: GardenModelProps) => { const onSelectLocation = React.useCallback(( selection: ThreeDLocationSelection, ) => { + const activeSelection = activeLocationSelectionRef.current; + if (activeSelection?.x == selection.x && + activeSelection.y == selection.y && + activeSelection.z == selection.z) { + closePopup(); + return; + } setPopupSelection(undefined); setLocationSelection(selection); - }, []); + }, [closePopup]); const updateLocationSelection = React.useCallback(( selection: ThreeDLocationSelection, ) => { From 4f9ed27a84c8c5f160994bbd1c504b51a1709235 Mon Sep 17 00:00:00 2001 From: Rory Aronson Date: Fri, 5 Jun 2026 14:16:02 -0700 Subject: [PATCH 13/18] update 3d render positions --- frontend/settings/three_d_settings.tsx | 2 +- .../__tests__/triangles_test.ts | 16 ++-- .../__tests__/visualization_test.tsx | 4 +- .../three_d_garden/bed/__tests__/bed_test.tsx | 12 +-- .../__tests__/pointer_objects_test.tsx | 4 +- frontend/three_d_garden/bot/bot.tsx | 82 ++++++++++--------- .../__tests__/electronics_box_test.tsx | 5 +- .../bot/components/__tests__/tools_test.tsx | 8 +- .../bot/components/cable_carriers.tsx | 21 ++--- .../bot/components/camera_view.tsx | 10 ++- .../bot/components/electronics_box.tsx | 20 ++++- .../bot/components/gantry_beam.tsx | 3 +- .../bot/components/solenoid.tsx | 14 ++-- .../bot/components/tool_slot_position.ts | 3 +- .../three_d_garden/bot/components/tools.tsx | 13 +-- .../bot/components/watering_animations.tsx | 3 +- .../bot/parts/seed_trough_holder.tsx | 4 +- frontend/three_d_garden/bot/positioning.ts | 35 -------- frontend/three_d_garden/config.ts | 8 +- .../garden/__tests__/plant_instances_test.tsx | 2 +- .../garden/__tests__/point_test.tsx | 4 +- .../garden/__tests__/weed_test.tsx | 2 +- frontend/three_d_garden/selection/resolve.ts | 26 ++++-- 23 files changed, 155 insertions(+), 146 deletions(-) delete mode 100644 frontend/three_d_garden/bot/positioning.ts diff --git a/frontend/settings/three_d_settings.tsx b/frontend/settings/three_d_settings.tsx index 9ab25300c..58089ceca 100644 --- a/frontend/settings/three_d_settings.tsx +++ b/frontend/settings/three_d_settings.tsx @@ -21,7 +21,7 @@ const DEFAULTS: Partial> = { beamLength: 1500, columnLength: 500, zAxisLength: 1000, - bedXOffset: 140, + bedXOffset: 150, bedYOffset: 20, bedZOffset: 0, legSize: 100, diff --git a/frontend/three_d_garden/__tests__/triangles_test.ts b/frontend/three_d_garden/__tests__/triangles_test.ts index a4b908974..203f4e30c 100644 --- a/frontend/three_d_garden/__tests__/triangles_test.ts +++ b/frontend/three_d_garden/__tests__/triangles_test.ts @@ -139,14 +139,14 @@ describe("filterMoisturePoints()", () => { const p = fakeProps(); const points = filterMoisturePoints(p); expect(points).toEqual([ - [-100, 20, 0], - [-100, 1300, 0], - [2820, 20, 0], - [2820, 1300, 0], - [-99.99, 20.01, 0], - [-99.99, 1299.99, 0], - [2819.99, 20.01, 0], - [2819.99, 1299.99, 0], + [-110, 20, 0], + [-110, 1300, 0], + [2810, 20, 0], + [2810, 1300, 0], + [-109.99, 20.01, 0], + [-109.99, 1299.99, 0], + [2809.99, 20.01, 0], + [2809.99, 1299.99, 0], ]); }); }); diff --git a/frontend/three_d_garden/__tests__/visualization_test.tsx b/frontend/three_d_garden/__tests__/visualization_test.tsx index 594a316ed..ae8936df4 100644 --- a/frontend/three_d_garden/__tests__/visualization_test.tsx +++ b/frontend/three_d_garden/__tests__/visualization_test.tsx @@ -179,8 +179,8 @@ describe("", () => { ]); expect(points).toEqual([ - [-1350, -640, 430], - [-1260, -460, 700], + [-1340, -640, 430], + [-1250, -460, 700], ]); }); diff --git a/frontend/three_d_garden/bed/__tests__/bed_test.tsx b/frontend/three_d_garden/bed/__tests__/bed_test.tsx index 992427fb7..511ff2ed5 100644 --- a/frontend/three_d_garden/bed/__tests__/bed_test.tsx +++ b/frontend/three_d_garden/bed/__tests__/bed_test.tsx @@ -326,7 +326,7 @@ describe("", () => { const soil = soilMesh(container); fireEvent.click(soil); expect(plantActions.dropPlant3D).toHaveBeenCalledWith(expect.objectContaining({ - gardenCoords: { x: 1360, y: 660 }, + gardenCoords: { x: 1350, y: 660 }, })); }); @@ -360,7 +360,7 @@ describe("", () => { fireEvent.click(soil); expect(p.addPlantProps.dispatch).toHaveBeenCalledWith({ type: Actions.SET_DRAWN_POINT_DATA, - payload: { ...point, cx: 1360, cy: 660, z: 0 }, + payload: { ...point, cx: 1350, cy: 660, z: 0 }, }); expect(p.addPlantProps.dispatch).toHaveBeenCalledTimes(1); }); @@ -510,10 +510,10 @@ describe("", () => { const soil = soilMesh(container); fireEvent.pointerMove(soil); expect(mockSetPlantPosition).not.toHaveBeenCalled(); - expect(mockSetRadiusScale).toHaveBeenCalledWith(1510, 1510, 1510); - expect(mockSetTorusScale).toHaveBeenCalledWith(1510, 1510, 400); - expect(mockSetBillboardPosition).toHaveBeenCalledWith(0, 0, 672); - expect(mockSetImageScale).toHaveBeenCalledWith(1344, 1344, 1344); + expect(mockSetRadiusScale).toHaveBeenCalledWith(1500, 1500, 1500); + expect(mockSetTorusScale).toHaveBeenCalledWith(1500, 1500, 400); + expect(mockSetBillboardPosition).toHaveBeenCalledWith(0, 0, 667.5); + expect(mockSetImageScale).toHaveBeenCalledWith(1335, 1335, 1335); }); it("doesn't update pointer point radius: no ref", () => { 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 41162141e..0ca571aab 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 @@ -164,7 +164,7 @@ describe("soilClick()", () => { soilClick(p)(e); expect(e.stopPropagation).toHaveBeenCalled(); expect(dropPlantSpy).toHaveBeenCalledWith(expect.objectContaining({ - gardenCoords: { x: 1360, y: 660 }, + gardenCoords: { x: 1350, y: 660 }, })); }); @@ -182,7 +182,7 @@ describe("soilClick()", () => { } as unknown as ThreeEvent; soilClick(p)(e); expect(dropPlantSpy).toHaveBeenCalledWith(expect.objectContaining({ - gardenCoords: { x: 1360, y: 660 }, + gardenCoords: { x: 1350, y: 660 }, })); }); diff --git a/frontend/three_d_garden/bot/bot.tsx b/frontend/three_d_garden/bot/bot.tsx index c3892e532..9443bbd46 100644 --- a/frontend/three_d_garden/bot/bot.tsx +++ b/frontend/three_d_garden/bot/bot.tsx @@ -47,12 +47,15 @@ import { import { clickWasDragged } from "../click_event"; import { Mode } from "../../farm_designer/map/interfaces"; import { getMode } from "../../farm_designer/map/util"; -import { - cameraMountOffset, distinguishableBlack, extrusionWidth, utmHeight, - utmRadius, -} from "./positioning"; const xTrackPadding = 280; +const extrusionWidth = 20; +const utmRadius = 35; +const cameraMountOffset = { + x: extrusionWidth - 8, + y: utmRadius, +}; +const distinguishableBlack = "#333"; type LeftBracket = GLTF & { nodes: { [PartName.leftBracket]: THREE.Mesh }; @@ -249,7 +252,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - x - extrusionWidth - 12, + x - extrusionWidth - 23, outerY + bedColumnYOffset, ), 30, @@ -264,7 +267,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - x - extrusionWidth - 12, + x - extrusionWidth - 23, outerY - (index == 0 ? 0 : 170) + bedColumnYOffset, ), columnLength - 30, @@ -280,7 +283,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - x - (index == 0 ? 47 : 77), + x - (index == 0 ? 58 : 88), outerY - (index == 0 ? 0 : -20) + bedColumnYOffset, ), columnLength + 70, @@ -293,7 +296,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - x - 68, + x - 79, outerY - (index == 0 ? 5 : -25) + bedColumnYOffset, ), columnLength + 80, @@ -309,7 +312,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - x - 63, + x - 74, outerY - (index == 0 ? 5 : -25) + bedColumnYOffset, ), columnLength + 55, @@ -327,8 +330,8 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { ...botOuterXY( props.config, index == 0 - ? botSizeX + xTrackPadding / 2 - : -xTrackPadding / 2, + ? botSizeX + xTrackPadding / 2 - 10 + : -xTrackPadding / 2 - 10, outerY + (index == 0 ? 2.5 : 17.5), ), 2, @@ -347,7 +350,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - -132, + -143, outerY + 10 + bedColumnYOffset, ), 2 + (index == 0 ? 0 : 5), @@ -365,7 +368,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - botSizeX - 5 + xTrackPadding / 2, + botSizeX - 16 + xTrackPadding / 2, outerY + 10 + bedColumnYOffset, ), 2 + (index == 0 ? 5 : 0), @@ -383,7 +386,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { position={[ ...botOuterXY( props.config, - x - 42, + x - 53, outerY + (index == 0 ? 0 : extrusionWidth + 5) - 2 - (index == 0 ? 1 : 0) + bedColumnYOffset, @@ -397,7 +400,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { {props.config.cableCarriers && } {props.config.cableCarriers && @@ -408,7 +411,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => { model={crossSlide} name={"crossSlide"} position={[ - ...botGardenXY(props.config, x - 1.5, y + 5), + ...botGardenXY(props.config, x - 12.5, y + 5), columnLength + 105, ]} rotation={[0, 0, Math.PI / 2]} @@ -499,7 +502,7 @@ const BotGantrySubassembliesBase = (props: BotGantrySubassembliesProps) => { configPosition={props.configPosition} />} { @@ -520,7 +523,7 @@ const BotGantrySubassembliesBase = (props: BotGantrySubassembliesProps) => { position={[ ...botOuterXY( props.config, - x - extrusionWidth + 2, + x - extrusionWidth - 9, botSizeY + bedYOffset + 135, ), columnLength + 40 + extrusionWidth * 3 + 5, @@ -646,10 +649,10 @@ const BotVerticalToolheadSubassemblyBase = const airTubeEndPosition = (kitVersion: string): [number, number, number] => { switch (kitVersion) { case "v1.7": - return [...gardenXY(x + 80, y + 100), zZero - zDir * z + 245]; + return [...gardenXY(x + 69, y + 100), zZero - zDir * z + 245]; case "v1.8": default: - return [...gardenXY(x + 35, y), zZero - zDir * z + 245]; + return [...gardenXY(x + 24, y), zZero - zDir * z + 245]; } }; const vacuumPumpCoverRotation = (kitVersion: string): [number, number, number] => { @@ -664,10 +667,10 @@ const BotVerticalToolheadSubassemblyBase = const vacuumPumpCoverPosition = (kitVersion: string): [number, number, number] => { switch (kitVersion) { case "v1.7": - return [...gardenXY(x + 12, y + 55), zZero - zDir * z + 490]; + return [...gardenXY(x + 1, y + 55), zZero - zDir * z + 490]; case "v1.8": default: - return [...gardenXY(x + 2, y + 110), zZero + columnLength + 25]; + return [...gardenXY(x - 9, y + 110), zZero + columnLength + 25]; } }; const cameraMountPosition = new THREE.Vector3( @@ -696,8 +699,8 @@ const BotVerticalToolheadSubassemblyBase = }; const utmComponent = @@ -719,7 +722,7 @@ const BotVerticalToolheadSubassemblyBase = { steps: 1, depth: zAxisLength, bevelEnabled: false }, ]} position={[ - ...gardenXY(x, y + utmRadius), + ...gardenXY(x - 11, y + utmRadius), zZero - zDir * z, ]} rotation={[0, 0, 0]}> @@ -728,7 +731,7 @@ const BotVerticalToolheadSubassemblyBase = @@ -767,7 +770,7 @@ const BotVerticalToolheadSubassemblyBase = @@ -788,7 +791,7 @@ const BotVerticalToolheadSubassemblyBase = material-color={"#555"} args={[4, 4, zAxisLength - 200]} position={[ - ...gardenXY(x + 6, y - 30), + ...gardenXY(x - 5, y - 30), zZero - zDir * z + zAxisLength / 2, ]} rotation={[Math.PI / 2, 0, 0]} /> @@ -797,10 +800,11 @@ const BotVerticalToolheadSubassemblyBase = config={config} configPosition={props.configPosition} />} {config.cableCarriers && - } + } + distanceToSoil={-props.getZ(x - 11, y) - zDir * z} /> {props.trailReady && trail ? ", () => { toolSlot.body.tool_id = tool.body.id; p.toolSlots = [{ toolSlot, tool }]; const { container } = render(); - expect(container).toContainHTML("position=\"1260,460,391\""); + expect(container).toContainHTML("position=\"1250,460,391\""); }); it("flips rendered pullout direction for mirrored axis", () => { @@ -381,7 +381,7 @@ describe("", () => { toolSlot.body.gantry_mounted = true; p.toolSlots = [{ toolSlot, tool }]; const { container } = render(); - expect(container).toContainHTML("position=\"1060,-680,391\""); + expect(container).toContainHTML("position=\"1050,-680,391\""); }); it("calculates static and gantry tool slot render positions", () => { @@ -402,7 +402,7 @@ describe("", () => { expect(getToolSlotRenderPosition(config, configPosition, { toolSlot, tool: undefined, - }).x).toEqual(560); + }).x).toEqual(550); }); it("doesn't mirror gantry-mounted tool y when mirrorY is active", () => { @@ -418,7 +418,7 @@ describe("", () => { toolSlot.body.gantry_mounted = true; p.toolSlots = [{ toolSlot, tool }]; const { container } = render(); - expect(container).toContainHTML("position=\"-1060,-680,391\""); + expect(container).toContainHTML("position=\"-1050,-680,391\""); }); it("renders vacuum animation when not in toolbay and vacuum", () => { diff --git a/frontend/three_d_garden/bot/components/cable_carriers.tsx b/frontend/three_d_garden/bot/components/cable_carriers.tsx index f3c6e8ebb..cca87bcd5 100644 --- a/frontend/three_d_garden/bot/components/cable_carriers.tsx +++ b/frontend/three_d_garden/bot/components/cable_carriers.tsx @@ -14,9 +14,10 @@ import { range } from "lodash"; import { Group, Mesh, MeshPhongMaterial, InstancedMesh, } from "../../components"; -import { distinguishableBlack, extrusionWidth } from "../positioning"; import { EMISSIVE_PROPS } from "./gantry_beam"; +const distinguishableBlack = "#333"; + type CCSupportHorizontal = GLTF & { nodes: { [PartName.ccSupportHorizontal]: THREE.Mesh }; materials: never; @@ -193,12 +194,12 @@ const VisibleCableCarrierX = (props: CableCarrierXProps) => { const bedCCSupportHeight = Math.min(150, bedHeight / 2); const get3DPosition = get3DPositionNoMirrorFunc(props.config); const position = get3DPosition({ - x: botSizeX / 2, - y: (tracks ? 0 : extrusionWidth) - 15 - bedYOffset, + x: botSizeX / 2 - 11, + y: (tracks ? 0 : 20) - 15 - bedYOffset, }); const args = React.useMemo(() => [ ccPath( - botSizeX / 2, botSizeX / 2 - x + 20, + botSizeX / 2, botSizeX / 2 - x + 31, bedCCSupportHeight - 40, true), { steps: 1, depth: 22, bevelEnabled: false }, @@ -239,7 +240,7 @@ const VisibleCableCarrierY = (props: CableCarrierYProps) => { } }; const getPosition = (): [number, number, number] => { - const position = get3DPosition({ x: x - 28, y: 20 }); + const position = get3DPosition({ x: x - 39, y: 20 }); return [position.x, position.y, columnLength + 150]; }; const args = React.useMemo(() => [ @@ -270,7 +271,7 @@ const VisibleCableCarrierZ = (props: CableCarrierZProps) => { const zZero = zZeroFunc(props.config); const zDir = zDirFunc(props.config); const get3DPosition = get3DPositionNoMirrorFunc(props.config); - const position = get3DPosition({ x: x - 41, y: y - 25 }); + const position = get3DPosition({ x: x - 52, y: y - 25 }); const args = React.useMemo(() => [ ccPath(botSizeZ + zGantryOffset - 100, zDir * z + zGantryOffset - 15, 87), { steps: 1, depth: 60, bevelEnabled: false }, @@ -319,7 +320,7 @@ const CableCarrierSupportVerticalV17 = if (!verticalRef.current || verticalInstances.length === 0) { return; } const temp = new THREE.Object3D(); verticalInstances.forEach((i, index) => { - const position = get3DPosition({ x: x + 20, y: y + 55 }); + const position = get3DPosition({ x: x + 9, y: y + 55 }); temp.position.set( position.x, position.y, @@ -383,7 +384,7 @@ const CableCarrierSupportVerticalV18 = }, [zAxisLength]); React.useEffect(() => () => verticalGeometry.dispose(), [verticalGeometry]); const getPosition = (): [number, number, number] => { - const position = get3DPosition({ x: x + 20, y: y + 35 }); + const position = get3DPosition({ x: x + 9, y: y + 35 }); return [position.x, position.y, zZero - zDir * z + 125]; }; return @@ -428,7 +429,7 @@ const CableCarrierSupportHorizontalV17 = if (!horizontalRef.current || horizontalInstances.length === 0) { return; } const temp = new THREE.Object3D(); horizontalInstances.forEach((i, index) => { - const position = get3DPosition({ x: x - 28, y: 50 + i * 300 }); + const position = get3DPosition({ x: x - 39, y: 50 + i * 300 }); temp.position.set( position.x, position.y, @@ -484,7 +485,7 @@ const CableCarrierSupportHorizontalV18 = }); }, [botSizeY]); React.useEffect(() => () => horizontalGeometry.dispose(), [horizontalGeometry]); - const position = get3DPosition({ x: x - 28, y: 20 }); + const position = get3DPosition({ x: x - 39, y: 20 }); return { + const { bedYOffset, columnLength } = config; + const { x } = configPosition; + const get3DPosition = get3DPositionNoMirrorFunc(config); + const position = get3DPosition({ + x: x - 73, + y: -20 - bedYOffset, + }); + return new THREE.Vector3( + position.x, + position.y, + columnLength - 190, + ); +}; + const electronicsBoxPropsEqual = ( prevProps: ElectronicsBoxProps, nextProps: ElectronicsBoxProps, diff --git a/frontend/three_d_garden/bot/components/gantry_beam.tsx b/frontend/three_d_garden/bot/components/gantry_beam.tsx index 391647f92..d74f5f414 100644 --- a/frontend/three_d_garden/bot/components/gantry_beam.tsx +++ b/frontend/three_d_garden/bot/components/gantry_beam.tsx @@ -6,7 +6,6 @@ import { Config, PositionConfig } from "../../config"; import { DoubleSide, Shape, SpotLightHelper, Texture, SpotLight as ThreeSpotLight, Vector3, } from "three"; -import { extrusionWidth } from "../positioning"; import { range } from "lodash"; export interface GantryBeamProps { @@ -43,7 +42,7 @@ const GantryBeamComponent = (props: GantryBeamProps) => { const { x } = props.configPosition; const get3DPosition = get3DPositionNoMirrorFunc(props.config); const position = get3DPosition({ - x: x - extrusionWidth - 8, + x: x - 39, y: (bedWidthOuter + beamLength) / 2 - 50 - bedYOffset, }); return { return { lowerTubePath: easyCubicBezierCurve3( [ - ...outerXY(x - 45, -25), + ...outerXY(x - 60, -25), -49, ], [200, -55, 25], [5, 10, -250], [ - ...outerXY(x - 104.75, 20), + ...outerXY(x - 115.75, 20), columnLength - 217, ], ), solenoidPosition: [ - ...outerXY(x - 104, 20), + ...outerXY(x - 115, 20), columnLength - 200, ] as [number, number, number], upperTubePath: easyCubicBezierCurve3( [ - ...outerXY(x - 104.25, 20), + ...outerXY(x - 115.25, 20), columnLength - 98, ], [0, 0, 100], @@ -105,19 +105,19 @@ const SolenoidBase = (props: SolenoidProps) => { [0, -50, 0], [0, 0, -50], [ - ...gardenXY(x - 32.5, y - 10), + ...gardenXY(x - 43.5, y - 10), columnLength + 180, ], ), utmTubePath: easyCubicBezierCurve3( [ - ...gardenXY(x + 32.5, y - 10), + ...gardenXY(x + 21.5, y - 10), columnLength - zDir * z - zGantryOffset + 200, ], [0, 0, -50], [0, 0, 50], [ - ...gardenXY(x + 2, y + 15), + ...gardenXY(x - 9, y + 15), columnLength - zDir * z - zGantryOffset + 75, ], ), diff --git a/frontend/three_d_garden/bot/components/tool_slot_position.ts b/frontend/three_d_garden/bot/components/tool_slot_position.ts index aacab6b65..ec7c3a827 100644 --- a/frontend/three_d_garden/bot/components/tool_slot_position.ts +++ b/frontend/three_d_garden/bot/components/tool_slot_position.ts @@ -5,7 +5,6 @@ import { get3DPositionFunc, get3DPositionNoMirrorFunc, zDir as zDirFunc, zZero as zZeroFunc, } from "../../helpers"; -import { utmHeight } from "../positioning"; export interface ThreeDToolPositionInput { x: number; @@ -46,7 +45,7 @@ export const getToolRenderPosition = ( : noMirrorPosition.y, z: helpers.zZero - helpers.zDir * tool.z - + (inToolbay ? 0 : (utmHeight / 2 - 15)), + + (inToolbay ? 0 : (35 / 2 - 15)), }; }; diff --git a/frontend/three_d_garden/bot/components/tools.tsx b/frontend/three_d_garden/bot/components/tools.tsx index 37453b7e9..742f884e8 100644 --- a/frontend/three_d_garden/bot/components/tools.tsx +++ b/frontend/three_d_garden/bot/components/tools.tsx @@ -15,7 +15,6 @@ import { import { Group, Mesh, MeshPhongMaterial, } from "../../components"; -import { distinguishableBlack } from "../positioning"; import { SlotWithTool } from "../../../resources/interfaces"; import { isUndefined, sortBy } from "lodash"; import { @@ -42,6 +41,8 @@ import { } from "./tool_slot_position"; import { clickWasDragged } from "../../click_event"; +const distinguishableBlack = "#333"; + type Toolbay3 = GLTF & { nodes: { [PartName.toolbay3]: THREE.Mesh; @@ -572,7 +573,7 @@ const SeedTroughWithAssemblyToolModel = React.memo(() => { { const seedTrough = useGLTF(ASSETS.models.seedTrough, LIB_DIR) as unknown as SeedTrough; return { : undefined; return { const { waterFlow, getZ, config } = props; const { x, y, z } = props.configPosition; const get3DPosition = get3DPositionNoMirrorFunc(config); - const utmZ = -zDir(config) * z + utmHeight / 2 - 15; + const utmZ = -zDir(config) * z + 35 / 2 - 15; const nozzleToSoil = getZ(x, y) - utmZ; const [visible, setVisible] = React.useState(false); React.useEffect(() => { diff --git a/frontend/three_d_garden/bot/parts/seed_trough_holder.tsx b/frontend/three_d_garden/bot/parts/seed_trough_holder.tsx index 4706b9c50..1c8a905bc 100644 --- a/frontend/three_d_garden/bot/parts/seed_trough_holder.tsx +++ b/frontend/three_d_garden/bot/parts/seed_trough_holder.tsx @@ -29,11 +29,11 @@ export const SeedTroughHolderModel = (props: SeedTroughHolderProps) => { + position={[-0.002, 0.044, 0]} /> ; }; diff --git a/frontend/three_d_garden/bot/positioning.ts b/frontend/three_d_garden/bot/positioning.ts deleted file mode 100644 index f9c4dda90..000000000 --- a/frontend/three_d_garden/bot/positioning.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as THREE from "three"; -import { Config, PositionConfig } from "../config"; -import { get3DPositionNoMirrorFunc } from "../helpers"; - -export const extrusionWidth = 20; -export const utmRadius = 35; -export const utmHeight = 35; -export const cameraMountOffset = { - x: extrusionWidth + 3, - y: utmRadius, -}; -export const cameraMountToLensOffset = new THREE.Vector3( - 0, - extrusionWidth + 9, - 0, -); -export const distinguishableBlack = "#333"; - -export const getElectronicsBoxPosition = ( - config: Config, - configPosition: PositionConfig, -) => { - const { bedYOffset, columnLength } = config; - const { x } = configPosition; - const get3DPosition = get3DPositionNoMirrorFunc(config); - const position = get3DPosition({ - x: x - 62, - y: -20 - bedYOffset, - }); - return new THREE.Vector3( - position.x, - position.y, - columnLength - 190, - ); -}; diff --git a/frontend/three_d_garden/config.ts b/frontend/three_d_garden/config.ts index da3ecd137..c96db3676 100644 --- a/frontend/three_d_garden/config.ts +++ b/frontend/three_d_garden/config.ts @@ -139,7 +139,7 @@ export const INITIAL: ConfigWithPosition = { beamLength: 1500, columnLength: 500, zAxisLength: 1000, - bedXOffset: 140, + bedXOffset: 150, bedYOffset: 20, bedZOffset: 0, zGantryOffset: 140, @@ -282,7 +282,7 @@ export const PRESETS: Record = { beamLength: 550, columnLength: 300, zAxisLength: 750, - bedXOffset: 140, + bedXOffset: 150, bedYOffset: 80, zGantryOffset: 140, bedWidthOuter: 400, @@ -304,7 +304,7 @@ export const PRESETS: Record = { beamLength: 1500, columnLength: 500, zAxisLength: 1000, - bedXOffset: 140, + bedXOffset: 150, bedYOffset: 20, zGantryOffset: 140, bedWidthOuter: 1360, @@ -326,7 +326,7 @@ export const PRESETS: Record = { beamLength: 3000, columnLength: 500, zAxisLength: 1000, - bedXOffset: 140, + bedXOffset: 150, bedYOffset: 20, zGantryOffset: 140, bedWidthOuter: 2860, diff --git a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx index 2ca672a81..08e569d02 100644 --- a/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx @@ -532,7 +532,7 @@ describe("", () => { expect(instancedRef?.current?.setMatrixAt).toHaveBeenCalled(); const matrix = (instancedRef?.current?.setMatrixAt as jest.Mock) .mock.calls[0][1]; - expect(matrix.elements[12]).toBeCloseTo(1260); + expect(matrix.elements[12]).toBeCloseTo(1250); expect(matrix.elements[13]).toBeCloseTo(460); }); diff --git a/frontend/three_d_garden/garden/__tests__/point_test.tsx b/frontend/three_d_garden/garden/__tests__/point_test.tsx index 45a701787..6c0cae870 100644 --- a/frontend/three_d_garden/garden/__tests__/point_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/point_test.tsx @@ -55,7 +55,7 @@ describe("", () => { p.point.body.x = 100; p.point.body.y = 200; const { container } = render(); - expect(container).toContainHTML("position=\"1260,460,400\""); + expect(container).toContainHTML("position=\"1250,460,400\""); }); it("renders: unsaved", () => { @@ -195,7 +195,7 @@ describe("", () => { const wrapper = createRenderer(); mountedWrappers.push(wrapper); const matrix = markerRef.current.setMatrixAt.mock.calls[0][1]; - expect(matrix.elements[12]).toBeCloseTo(1260); + expect(matrix.elements[12]).toBeCloseTo(1250); expect(matrix.elements[13]).toBeCloseTo(460); expect(matrix.elements[14]).toBeCloseTo(400); useRefSpy.mockRestore(); diff --git a/frontend/three_d_garden/garden/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx index ed8de1824..bb16125f6 100644 --- a/frontend/three_d_garden/garden/__tests__/weed_test.tsx +++ b/frontend/three_d_garden/garden/__tests__/weed_test.tsx @@ -57,7 +57,7 @@ describe("", () => { p.weed.body.x = 100; p.weed.body.y = 200; const { container } = render(); - expect(container).toContainHTML("position=\"1260,460,400\""); + expect(container).toContainHTML("position=\"1250,460,400\""); }); it("navigates to weed info", () => { diff --git a/frontend/three_d_garden/selection/resolve.ts b/frontend/three_d_garden/selection/resolve.ts index acd880f49..05da5ddcd 100644 --- a/frontend/three_d_garden/selection/resolve.ts +++ b/frontend/three_d_garden/selection/resolve.ts @@ -11,11 +11,14 @@ import { getToolRenderPosition, getToolSlotRenderPosition, } from "../bot/components/tool_slot_position"; import { - cameraMountOffset, getElectronicsBoxPosition, -} from "../bot/positioning"; + getElectronicsBoxPosition, +} from "../bot/components/electronics_box"; import { ThreeDLocationSelection, ThreeDObjectSelection, } from "../selection_types"; +import { + reduceToolName, ToolName, +} from "../../farm_designer/map/tool_graphics/all_tools"; import { TaggedPlant } from "../../farm_designer/map/interfaces"; import { SlotWithTool } from "../../resources/interfaces"; import { BotPosition } from "../../devices/interfaces"; @@ -25,7 +28,11 @@ const MIN_RING_RADIUS = 35; const POPUP_Z_PADDING = 25; const FIXED_POPUP_Z_OFFSET = 75; const SLOT_RING_RADIUS = 50; -const ELECTRONICS_RING_RADIUS = 120; +const SEED_TROUGH_RING_Y_OFFSET = 20; +const cameraMountOffset = { + x: 12, + y: 35, +}; export interface ResolvedThreeDObjectBase { selection: ThreeDObjectSelection; @@ -207,14 +214,21 @@ const resolveSlotObject = ( if (!slot) { return undefined; } const worldPosition = getToolSlotRenderPosition(props.config, props.configPosition, slot); + const ringYOffset = reduceToolName(slot.tool?.body.name) == ToolName.seedTrough + ? SEED_TROUGH_RING_Y_OFFSET + : 0; const slotBody = slot.toolSlot.body; return { kind: "slot", selection, slot, name: slot.tool?.body.name || t("Empty slot"), - worldPosition: [worldPosition.x, worldPosition.y, worldPosition.z], - popupPosition: [worldPosition.x, worldPosition.y, + worldPosition: [ + worldPosition.x, + worldPosition.y + ringYOffset, + worldPosition.z, + ], + popupPosition: [worldPosition.x, worldPosition.y + ringYOffset, worldPosition.z + FIXED_POPUP_Z_OFFSET], ringRadius: SLOT_RING_RADIUS, locationCoordinate: { @@ -265,7 +279,7 @@ const resolveElectronicsObject = ( worldPosition: [worldPosition.x, worldPosition.y, worldPosition.z], popupPosition: [worldPosition.x, worldPosition.y, worldPosition.z + FIXED_POPUP_Z_OFFSET], - ringRadius: ELECTRONICS_RING_RADIUS, + ringRadius: SLOT_RING_RADIUS, locationCoordinate: { x: props.configPosition.x, y: props.configPosition.y, From b6ffb39dbd8aeba751f4f0700ce0cc4ba4f2da08 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 9 Jun 2026 20:42:12 -0700 Subject: [PATCH 14/18] fix popup popover positions --- frontend/css/components/go_button.scss | 5 +++++ frontend/css/global/inputs.scss | 7 +++++++ frontend/farm_designer/move_to.tsx | 1 + frontend/plants/edit_plant_status.tsx | 2 ++ .../selection/popup_controls.tsx | 19 ++++++++++--------- frontend/tools/tool_slot_edit_components.tsx | 1 + frontend/ui/filter_search.tsx | 2 ++ frontend/ui/new_fb_select.tsx | 2 ++ 8 files changed, 30 insertions(+), 9 deletions(-) diff --git a/frontend/css/components/go_button.scss b/frontend/css/components/go_button.scss index 0de85ff24..745bf7b67 100644 --- a/frontend/css/components/go_button.scss +++ b/frontend/css/components/go_button.scss @@ -9,6 +9,11 @@ height: 3rem !important; padding: 0 1rem; } + .bp6-popover-content { + button { + font-size: 1.1rem; + } + } .go-button-axes-text { border-top-right-radius: 0; border-bottom-right-radius: 0; diff --git a/frontend/css/global/inputs.scss b/frontend/css/global/inputs.scss index dc345cb0b..67670203f 100644 --- a/frontend/css/global/inputs.scss +++ b/frontend/css/global/inputs.scss @@ -179,6 +179,13 @@ select { .bp6-menu { padding-left: 0; padding-right: 0; + border-radius: 0; + a { + text-decoration: none !important; + } + } + .bp6-icon { + top: 0; } .bp6-input { height: auto !important; diff --git a/frontend/farm_designer/move_to.tsx b/frontend/farm_designer/move_to.tsx index 03dd9794f..9df7ab7a3 100644 --- a/frontend/farm_designer/move_to.tsx +++ b/frontend/farm_designer/move_to.tsx @@ -222,6 +222,7 @@ export class GoToThisLocationButton @@ -358,6 +359,7 @@ export interface EditWeedStatusProps { export const EditWeedStatus = (props: EditWeedStatusProps) => diff --git a/frontend/three_d_garden/selection/popup_controls.tsx b/frontend/three_d_garden/selection/popup_controls.tsx index 1bd8e9658..456392697 100644 --- a/frontend/three_d_garden/selection/popup_controls.tsx +++ b/frontend/three_d_garden/selection/popup_controls.tsx @@ -69,15 +69,15 @@ const isPlantPointer = (plant: TaggedPlant): plant is TaggedPlantPointer => const GoButton = (props: GoButtonProps) => props.dispatch && - ; + ; const PopupLocationRow = (props: PopupLocationRowProps) =>
@@ -405,6 +405,7 @@ const PopupBootSequenceSelector = (props: PopupBootSequenceSelectorProps) => { return
{ export const ToolSelection = (props: ToolSelectionProps) => !props.filterSelectedTool || tool.body.id != props.selectedTool?.body.id) diff --git a/frontend/ui/filter_search.tsx b/frontend/ui/filter_search.tsx index 1ce9190dd..76e619a64 100644 --- a/frontend/ui/filter_search.tsx +++ b/frontend/ui/filter_search.tsx @@ -12,6 +12,7 @@ export interface FilterSearchProps { onChange: (item: DropDownItem) => void; nullChoice: DropDownItem; itemListFilter?: (items: DropDownItem[], query: string) => DropDownItem[]; + usePortal?: boolean; } interface State { @@ -48,6 +49,7 @@ export class FilterSearch items.length < 4 ? "few-items" : "", ].join(" "), modifiers: { offset: { options: { offset: [0, 0] } } }, + usePortal: this.props.usePortal, }}>
; } From c1c233b5b749a6c29bdc9a03458c646ba0ac8f73 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 9 Jun 2026 21:27:13 -0700 Subject: [PATCH 15/18] fix filter search clear button --- frontend/css/_blueprint_overrides.scss | 7 +++++++ frontend/css/global/inputs.scss | 1 + 2 files changed, 8 insertions(+) diff --git a/frontend/css/_blueprint_overrides.scss b/frontend/css/_blueprint_overrides.scss index c3cbf07e3..250ab82f8 100644 --- a/frontend/css/_blueprint_overrides.scss +++ b/frontend/css/_blueprint_overrides.scss @@ -55,6 +55,12 @@ .bp6-icon { top: 0.6rem; } + .bp6-input-action { + width: 3rem; + .bp6-icon { + width: 1.75rem; + } + } } .bp6-popover.help { @@ -149,5 +155,6 @@ *[class*=bp6-] { &:focus { outline: none; + box-shadow: none; } } diff --git a/frontend/css/global/inputs.scss b/frontend/css/global/inputs.scss index 67670203f..819fb67fe 100644 --- a/frontend/css/global/inputs.scss +++ b/frontend/css/global/inputs.scss @@ -186,6 +186,7 @@ select { } .bp6-icon { top: 0; + width: 0; } .bp6-input { height: auto !important; From eb736660a06e7020248ea859ef2c5377a89bd5d1 Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Tue, 9 Jun 2026 22:19:32 -0700 Subject: [PATCH 16/18] add dropdown screenshot to render and fix issues --- frontend/css/global/colors.scss | 21 +++++++++- frontend/demo/demo_iframe.tsx | 1 + frontend/ui/filter_search.tsx | 2 + frontend/ui/new_fb_select.tsx | 4 +- scripts/ci/combine-render-images | 10 ++++- scripts/ci/render-url-records | 3 +- scripts/ci/render-urls.json | 31 +++++++++++++- scripts/ci/run-playwright-render | 55 +++++++++++++++++------- scripts/ci/test_python_scripts.py | 12 +++++- scripts/fps.js | 70 ++++++++++++++++++++++++++++--- 10 files changed, 180 insertions(+), 29 deletions(-) diff --git a/frontend/css/global/colors.scss b/frontend/css/global/colors.scss index 670cb208b..27c434fd9 100644 --- a/frontend/css/global/colors.scss +++ b/frontend/css/global/colors.scss @@ -82,7 +82,7 @@ body { --blur: blur(20px); } -body:has(.app.light) { +@mixin light-theme-vars { --main-bg: #{$light_bg}; --secondary-bg: rgba(255, 255, 255, 0.3); --text-color: #{$dark_gray}; @@ -91,7 +91,7 @@ body:has(.app.light) { --highlight: inset 0px 0px 5px 5px #{$yellow}; } -body:has(.app.dark) { +@mixin dark-theme-vars { --main-bg: #{$dark_bg}; --secondary-bg: rgba(255, 255, 255, 0.05); --text-color: #{$gray}; @@ -100,6 +100,23 @@ body:has(.app.dark) { --highlight: inset 0px 0px 5px 5px #{$yellow}; } +body, +.app.light { + @include light-theme-vars; +} + +body:has(.app.light) { + @include light-theme-vars; +} + +.app.dark { + @include dark-theme-vars; +} + +body:has(.app.dark) { + @include dark-theme-vars; +} + .dark-gray { background-color: $dark_gray !important; } diff --git a/frontend/demo/demo_iframe.tsx b/frontend/demo/demo_iframe.tsx index 5cb4be970..ac9437dcc 100644 --- a/frontend/demo/demo_iframe.tsx +++ b/frontend/demo/demo_iframe.tsx @@ -77,6 +77,7 @@ export abstract class DemoAccountBase

return x.value != "none")} itemListFilter={maybeShowStressSeedOptions} customNullLabel={t("Select a model")} diff --git a/frontend/ui/filter_search.tsx b/frontend/ui/filter_search.tsx index 76e619a64..6c29dcb37 100644 --- a/frontend/ui/filter_search.tsx +++ b/frontend/ui/filter_search.tsx @@ -13,6 +13,7 @@ export interface FilterSearchProps { nullChoice: DropDownItem; itemListFilter?: (items: DropDownItem[], query: string) => DropDownItem[]; usePortal?: boolean; + title?: string; } interface State { @@ -42,6 +43,7 @@ export class FilterSearch noResults={} onItemSelect={this.handleValueChange} onQueryChange={this.handleQueryChange} + popoverTargetProps={{ title: this.props.title }} popoverProps={{ popoverClassName: [ "filter-search-popover", diff --git a/frontend/ui/new_fb_select.tsx b/frontend/ui/new_fb_select.tsx index 8660270f0..e254028c4 100644 --- a/frontend/ui/new_fb_select.tsx +++ b/frontend/ui/new_fb_select.tsx @@ -19,6 +19,7 @@ export interface FBSelectProps { /** Optionally filter the list with access to the current search query. */ itemListFilter?: (items: DropDownItem[], query: string) => DropDownItem[]; usePortal?: boolean; + title?: string; } export class FBSelect extends React.Component { @@ -56,7 +57,8 @@ export class FBSelect extends React.Component { itemListFilter={this.props.itemListFilter} onChange={this.props.onChange} usePortal={this.props.usePortal} - nullChoice={this.NULL_CHOICE} /> + nullChoice={this.NULL_CHOICE} + title={this.props.title} />

; } } diff --git a/scripts/ci/combine-render-images b/scripts/ci/combine-render-images index 2696c7a7f..cc8b48cbd 100755 --- a/scripts/ci/combine-render-images +++ b/scripts/ci/combine-render-images @@ -32,6 +32,7 @@ const metricRow = name => [ [`${name} screenshot`, imagePath(`${screenshotName}_${name}`)], [`${name} fps`, imagePath(`${fpsSamplesName}_${name}`)], [`${name} metrics`, imagePath(`${sceneMetricsName}_${name}`)], + [], ]; const screenshotCell = name => [ name.replace(/_/g, ' '), @@ -46,11 +47,13 @@ const rows = [ screenshotCell('login'), screenshotCell('os'), screenshotCell('demo'), + ['fe coverage', imagePath(feCoverageName)], ], [ screenshotCell('password_reset'), screenshotCell('404'), - ['fe coverage', imagePath(feCoverageName)], + screenshotCell('dropdown'), + [], ], ]; @@ -60,6 +63,9 @@ const escapeHtml = value => String(value) .replace(/>/g, '>') .replace(/"/g, '"'); const cell = ([title, filePath]) => { + if (!title && !filePath) { + return '
'; + } if (!fs.existsSync(filePath)) { return `
@@ -91,7 +97,7 @@ const html = ` } .row { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; } diff --git a/scripts/ci/render-url-records b/scripts/ci/render-url-records index 0b5f4d999..7947eb0c5 100755 --- a/scripts/ci/render-url-records +++ b/scripts/ci/render-url-records @@ -6,10 +6,11 @@ with open(sys.argv[1], encoding="utf-8") as f: entries = json.load(f) for entry in entries: + actions = entry.get("actions", []) print("\034".join([ entry["name"], entry["mode"], entry["url"], - entry.get("click", ""), + json.dumps(actions, separators=(",", ":")) if actions else "", entry.get("state", ""), ])) diff --git a/scripts/ci/render-urls.json b/scripts/ci/render-urls.json index aa0924e64..e3e6427e3 100644 --- a/scripts/ci/render-urls.json +++ b/scripts/ci/render-urls.json @@ -19,6 +19,29 @@ "mode": "screenshot", "url": "http://localhost:3000/demo" }, + { + "name": "dropdown", + "mode": "screenshot", + "url": "http://localhost:3000/demo", + "actions": [ + { + "click": { + "title": "Select a model" + } + }, + { + "fill": { + "placeholder": "Filter...", + "value": "Genesis" + } + }, + { + "hover": { + "class": "bp6-icon-cross" + } + } + ] + }, { "name": "password_reset", "mode": "screenshot", @@ -43,7 +66,13 @@ "name": "water", "mode": "fps", "url": "http://localhost:3000/app/designer/sequences/Water_all", - "click": "Run", + "actions": [ + { + "click": { + "title": "Run" + } + } + ], "state": "app" } ] diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render index 204929440..273276964 100755 --- a/scripts/ci/run-playwright-render +++ b/scripts/ci/run-playwright-render @@ -12,6 +12,44 @@ if [ -z "${GITHUB_ACTIONS:-}" ]; then export OPEN_WINDOW="${OPEN_WINDOW:-1}" fi +summary_only=false +while [ "$#" -gt 0 ]; do + case "$1" in + --summary-only) + summary_only=true + ;; + -h|--help) + echo "Usage: scripts/ci/run-playwright-render [--summary-only]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac + shift +done + +render_summary() { + if [ -z "${GITHUB_ACTIONS:-}" ]; then + echo -e "\n\n\n" + echo "Plotting metrics..." + for csv in /tmp/*.csv; do + [ -f "$csv" ] || continue + bun scripts/metric_plot.js "$csv" + done + echo -e "\n\n\n" + echo "Combining render images..." + bun scripts/ci/combine-render-images + echo -e "\n\n\n" + fi +} + +if [ "$summary_only" = "true" ]; then + render_summary + exit 0 +fi + attempts="${ATTEMPTS:-2}" : "${CHOSEN_NAME:?CHOSEN_NAME is required}" : "${SCREENSHOT_NAME:?SCREENSHOT_NAME is required}" @@ -54,7 +92,7 @@ valid_scene_metrics() { [ -n "$row" ] && [ "$row" != "undefined" ] && [ "$row" != "null" ] } -while IFS=$'\034' read -r -u 3 name mode url click state; do +while IFS=$'\034' read -r -u 3 name mode url actions state; do screenshot_path="/tmp/${SCREENSHOT_NAME}_${name}.png" fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv" scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv" @@ -66,7 +104,7 @@ while IFS=$'\034' read -r -u 3 name mode url click state; do --screenshot-path "$screenshot_path" --fps-samples-path "$fps_samples_path" ) - [ -n "$click" ] && fps_args+=(--click "$click") + [ -n "$actions" ] && fps_args+=(--actions "$actions") [ -n "$state" ] && fps_args+=(--state "$state") echo -e "\n\n\n" @@ -123,15 +161,4 @@ if [ "$chosen_seen" != "true" ]; then exit 1 fi -if [ -z "${GITHUB_ACTIONS:-}" ]; then - echo -e "\n\n\n" - echo "Plotting metrics..." - for csv in /tmp/*.csv; do - [ -f "$csv" ] || continue - bun scripts/metric_plot.js "$csv" - done - echo -e "\n\n\n" - echo "Combining render images..." - bun scripts/ci/combine-render-images - echo -e "\n\n\n" -fi +render_summary diff --git a/scripts/ci/test_python_scripts.py b/scripts/ci/test_python_scripts.py index d8cd57620..3f1bc84a3 100644 --- a/scripts/ci/test_python_scripts.py +++ b/scripts/ci/test_python_scripts.py @@ -99,7 +99,11 @@ def test_render_url_records_outputs_field_separated_records(self): "name": "promo", "mode": "fps", "url": "http://localhost:3000/promo", - "click": "Run", + "actions": [ + {"click": {"title": "Run"}}, + {"fill": {"placeholder": "Filter...", "value": "Genesis"}}, + {"hover": {"classname": "item"}}, + ], "state": "app", }], file) file_path = file.name @@ -111,7 +115,11 @@ def test_render_url_records_outputs_field_separated_records(self): self.assertEqual(code, 0) self.assertEqual( - stdout, "promo\034fps\034http://localhost:3000/promo\034Run\034app\n") + stdout, + "promo\034fps\034http://localhost:3000/promo\034" + "[{\"click\":{\"title\":\"Run\"}}," + "{\"fill\":{\"placeholder\":\"Filter...\",\"value\":\"Genesis\"}}," + "{\"hover\":{\"classname\":\"item\"}}]\034app\n") self.assertEqual(stderr, "") def test_create_compare_link_uses_latest_deployment_sha(self): diff --git a/scripts/fps.js b/scripts/fps.js index 103dd9658..a3883e46f 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -15,7 +15,7 @@ function parseArgs(argv) { '--url': 'url', '--screenshot-path': 'screenshotPath', '--fps-samples-path': 'samplesCsvPath', - '--click': 'click', + '--actions': 'actions', '--state': 'state', }; @@ -50,11 +50,12 @@ const samplesCsvPath = options.samplesCsvPath; const maxLoadingSamples = 240; const postLoadSamples = 10; const sampleIntervalMs = 1000; +const defaultActionTimeoutMs = 5000; const ci = Boolean(process.env.CI); const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW); const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; const screenshotOnly = options.screenshotOnly; -const click = options.click; +const actions = options.actions ? JSON.parse(options.actions) : []; const state = options.state ? path.join('/tmp', `${options.state}.json`) : undefined; const saveState = path.join('/tmp', `${name}.json`); const commitSha = () => { @@ -105,7 +106,7 @@ function printUsage() { ' --screenshot-path Full-page screenshot PNG path. Default: /tmp/fps.png', ' --fps-samples-path FPS samples CSV path. Default: /tmp/fps_samples.csv', ' --screenshot-only Take a screenshot without FPS metrics.', - ' --click Click an element by title after page load.', + ' --actions <json> Perform ordered actions after page load.', ' --state <name> Load cookies and localStorage from /tmp/<name>.json.', '', 'Environment:', @@ -147,6 +148,64 @@ async function saveStorage(page) { console.log(`SAVE_STATE=${saveState}`); } +function actionValue(action, type, keys = []) { + const value = action[type]; + if (typeof value === 'object' && value !== null) { + for (const key of keys) { + if (value[key]) { return value[key]; } + } + } + return value; +} + +function stringActionValue(action, type, keys = []) { + const value = actionValue(action, type, keys); + if (typeof value !== 'string') { + throw new Error(`${type} action requires a string value: ${JSON.stringify(action)}`); + } + return value; +} + +function actionTimeout(action) { + const timeout = action.timeout || action.timeoutMs || defaultActionTimeoutMs; + const parsedTimeout = Number(timeout); + if (!Number.isFinite(parsedTimeout) || parsedTimeout <= 0) { + throw new Error(`Action timeout must be a positive number: ${JSON.stringify(action)}`); + } + return parsedTimeout; +} + +async function performAction(page, action) { + const timeout = actionTimeout(action); + if (action.click) { + const title = stringActionValue(action, 'click', ['title']); + await page.getByTitle(title).click({ timeout }); + console.log(`CLICK=${title}`); + return; + } + + if (action.fill) { + const placeholder = action.placeholder || stringActionValue(action, 'fill', ['placeholder']); + const value = action.value || action.text || stringActionValue(action, 'fill', ['value', 'text']); + await page.getByPlaceholder(placeholder).fill(value, { timeout }); + console.log(`FILL=${placeholder}`); + return; + } + + if (action.hover) { + const className = action.classname + || action.class + || action.className + || stringActionValue(action, 'hover', ['classname', 'class', 'className']); + const selector = className.startsWith('.') ? className : `.${className}`; + await page.locator(selector).first().hover({ timeout }); + console.log(`HOVER=${className}`); + return; + } + + throw new Error(`Unknown action: ${JSON.stringify(action)}`); +} + async function main() { console.log(`Launching Chromium with args:\n ${chromiumArgs.join('\n ')}\n`); if (executablePath) { @@ -168,9 +227,8 @@ async function main() { try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await prepareStressResources(page, url); - if (click) { - await page.getByTitle(click).click(); - console.log(`CLICK=${click}`); + for (const action of actions) { + await performAction(page, action); } if (screenshotOnly) { await page.waitForTimeout(1000); From 90d331e97d99cafdeff4de224e93ca53bd2eedfe Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:54:38 -0700 Subject: [PATCH 17/18] scope usePortal false to 3D popups --- frontend/farm_designer/move_to.tsx | 3 ++- frontend/plants/edit_plant_status.tsx | 5 +++-- frontend/plants/plant_panel.tsx | 1 + frontend/three_d_garden/selection/popup_controls.tsx | 5 ++++- frontend/tools/interfaces.ts | 1 + frontend/tools/tool_slot_edit_components.tsx | 2 +- 6 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/farm_designer/move_to.tsx b/frontend/farm_designer/move_to.tsx index 9df7ab7a3..7b65ebab8 100644 --- a/frontend/farm_designer/move_to.tsx +++ b/frontend/farm_designer/move_to.tsx @@ -167,6 +167,7 @@ export interface GoToThisLocationButtonProps { currentBotLocation: BotPosition; movementState: MovementState; noOptions?: boolean; + usePortal?: boolean; } interface GoToThisLocationButtonState { @@ -222,7 +223,7 @@ export class GoToThisLocationButton </button> <Popover position={Position.BOTTOM_RIGHT} isOpen={this.state.open} - usePortal={false} + usePortal={this.props.usePortal} className={"go-button-axes"} popoverClassName={"go-button-axes-popover"} target={<button diff --git a/frontend/plants/edit_plant_status.tsx b/frontend/plants/edit_plant_status.tsx index 930a89767..33f22ddcc 100644 --- a/frontend/plants/edit_plant_status.tsx +++ b/frontend/plants/edit_plant_status.tsx @@ -96,7 +96,7 @@ export function EditPlantStatus(props: EditPlantStatusProps) { return <div className="grid half-gap"> <label>{t("Status")}</label> <FBSelect - usePortal={false} + usePortal={props.usePortal} list={PLANT_STAGE_LIST()} selectedItem={PLANT_STAGE_DDI_LOOKUP()[plantStatus]} onChange={ddi => @@ -353,13 +353,14 @@ export const PlantSlugBulkUpdate = (props: PlantSlugBulkUpdateProps) => { export interface EditWeedStatusProps { weed: TaggedWeedPointer; updateWeed(update: Partial<TaggedWeedPointer["body"]>): void; + usePortal?: boolean; } /** Select a `plant_stage` for a weed. */ export const EditWeedStatus = (props: EditWeedStatusProps) => <FBSelect key={props.weed.uuid} - usePortal={false} + usePortal={props.usePortal} list={WEED_STAGE_LIST()} selectedItem={WEED_STAGE_DDI_LOOKUP()[props.weed.body.plant_stage]} onChange={ddi => diff --git a/frontend/plants/plant_panel.tsx b/frontend/plants/plant_panel.tsx index 1053c746f..16138d9ba 100644 --- a/frontend/plants/plant_panel.tsx +++ b/frontend/plants/plant_panel.tsx @@ -54,6 +54,7 @@ interface EditPlantProperty { export interface EditPlantStatusProps extends EditPlantProperty { plantStatus: PlantStage; + usePortal?: boolean; } export interface EditDatePlantedProps extends EditPlantProperty { diff --git a/frontend/three_d_garden/selection/popup_controls.tsx b/frontend/three_d_garden/selection/popup_controls.tsx index 456392697..d74e71866 100644 --- a/frontend/three_d_garden/selection/popup_controls.tsx +++ b/frontend/three_d_garden/selection/popup_controls.tsx @@ -70,6 +70,7 @@ const isPlantPointer = (plant: TaggedPlant): plant is TaggedPlantPointer => const GoButton = (props: GoButtonProps) => props.dispatch && <GoToThisLocationButton + usePortal={false} dispatch={props.dispatch} locationCoordinate={props.locationCoordinate} botOnline={props.botOnline} @@ -193,6 +194,7 @@ const PlantPopupControls = (props: PopupControlProps) => { </div> <EditPlantStatus {...commonProps} + usePortal={false} plantStatus={plant.body.plant_stage} /> </div>} <div className={"row grid-2-col"}> @@ -231,7 +233,7 @@ const WeedPopupControls = (props: PopupControlProps) => { <div className={"row grid-2-col"}> <div className={"grid half-gap"}> <label>{t("Status")}</label> - <EditWeedStatus weed={weed} updateWeed={update} /> + <EditWeedStatus weed={weed} updateWeed={update} usePortal={false} /> </div> <EditPointRadius radius={weed.body.radius} @@ -268,6 +270,7 @@ const UtmPopupControls = (props: PopupControlProps) => { <div className={"object-popup-mounted-tool-row row grid-2-col"}> <label>{t("Mounted Tool")}</label> <ToolSelection + usePortal={false} tools={props.tools} selectedTool={mountedTool} onChange={({ tool_id }) => { diff --git a/frontend/tools/interfaces.ts b/frontend/tools/interfaces.ts index 3d7d24b11..270ab42cf 100644 --- a/frontend/tools/interfaces.ts +++ b/frontend/tools/interfaces.ts @@ -142,6 +142,7 @@ interface ToolInputPropsBase { export interface ToolSelectionProps extends ToolInputPropsBase { filterSelectedTool: boolean; filterActiveTools: boolean; + usePortal?: boolean; } export interface ToolInputRowProps extends ToolInputPropsBase { diff --git a/frontend/tools/tool_slot_edit_components.tsx b/frontend/tools/tool_slot_edit_components.tsx index 95984a1eb..fe1dc7c79 100644 --- a/frontend/tools/tool_slot_edit_components.tsx +++ b/frontend/tools/tool_slot_edit_components.tsx @@ -70,7 +70,7 @@ export const SlotDirectionInputRow = (props: SlotDirectionInputRowProps) => { export const ToolSelection = (props: ToolSelectionProps) => <FBSelect - usePortal={false} + usePortal={props.usePortal} list={([NULL_CHOICE] as DropDownItem[]).concat(props.tools .filter(tool => !props.filterSelectedTool || tool.body.id != props.selectedTool?.body.id) From 9bd0d5fccfb023da573605af2a1fa95d2b18b83f Mon Sep 17 00:00:00 2001 From: gabrielburnworth <gabrielburnworth@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:55:21 -0700 Subject: [PATCH 18/18] improve ci action renders --- scripts/ci/combine-render-images | 56 ++++++++++++++++---- scripts/ci/render-env | 2 +- scripts/ci/render-url-records | 2 + scripts/ci/render-urls.json | 17 +++++- scripts/ci/run-playwright-render | 3 +- scripts/ci/test_python_scripts.py | 24 ++++++++- scripts/fps.js | 88 ++++++++++++++++++++++++------- 7 files changed, 159 insertions(+), 33 deletions(-) diff --git a/scripts/ci/combine-render-images b/scripts/ci/combine-render-images index cc8b48cbd..5f9f69675 100755 --- a/scripts/ci/combine-render-images +++ b/scripts/ci/combine-render-images @@ -1,4 +1,5 @@ #!/usr/bin/env bun +// @ts-nocheck const fs = require('fs'); const path = require('path'); const { chromium } = require('playwright'); @@ -17,7 +18,7 @@ const screenshotName = requiredEnv('SCREENSHOT_NAME'); const fpsSamplesName = requiredEnv('FPS_SAMPLES_NAME'); const sceneMetricsName = requiredEnv('SCENE_METRICS_NAME'); const feCoverageName = requiredEnv('FE_COVERAGE_NAME'); -const renderImageScale = Number(process.env.COMBINED_RENDER_IMAGE_SCALE || 2); +const renderImageScale = Number(process.env.COMBINED_RENDER_IMAGE_SCALE || 3); if (!Number.isFinite(renderImageScale) || renderImageScale <= 0) { console.error('COMBINED_RENDER_IMAGE_SCALE must be a positive number'); process.exit(1); @@ -34,10 +35,27 @@ const metricRow = name => [ [`${name} metrics`, imagePath(`${sceneMetricsName}_${name}`)], [], ]; -const screenshotCell = name => [ - name.replace(/_/g, ' '), - imagePath(`${screenshotName}_${name}`), -]; +const actionScreenshotPaths = name => { + const prefix = `${screenshotName}_${name}_action_`; + if (!fs.existsSync('/tmp')) { return []; } + return fs.readdirSync('/tmp') + .filter(file => file.startsWith(prefix) && file.endsWith('.png')) + .sort((a, b) => { + const aIndex = Number(a.slice(prefix.length, -4)); + const bIndex = Number(b.slice(prefix.length, -4)); + return aIndex - bIndex; + }) + .map(file => path.join('/tmp', file)); +}; +const screenshotCell = name => { + const actionPaths = actionScreenshotPaths(name); + return [ + name.replace(/_/g, ' '), + actionPaths.length > 0 + ? actionPaths + : imagePath(`${screenshotName}_${name}`), + ]; +}; const rows = [ metricRow('promo_xl'), metricRow('app'), @@ -62,21 +80,26 @@ const escapeHtml = value => String(value) .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); -const cell = ([title, filePath]) => { - if (!title && !filePath) { +const imageTag = filePath => `<img src="${imageData(filePath)}" />`; +const cell = ([title, filePaths]) => { + if (!title && !filePaths) { return '<div class="empty"></div>'; } - if (!fs.existsSync(filePath)) { + const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; + const existingPaths = paths.filter(filePath => filePath && fs.existsSync(filePath)); + if (existingPaths.length === 0) { + const missingPath = paths.find(Boolean); return ` <figure class="missing"> <figcaption>${escapeHtml(title)}</figcaption> - <div>Missing ${escapeHtml(path.basename(filePath))}</div> + <div>Missing ${escapeHtml(path.basename(missingPath))}</div> </figure>`; } + const imageClass = existingPaths.length > 1 ? 'images multi-images' : 'images'; return ` <figure> <figcaption>${escapeHtml(title)}</figcaption> - <img src="${imageData(filePath)}" /> + <div class="${imageClass}">${existingPaths.map(imageTag).join('')}</div> </figure>`; }; const rowHtml = rows @@ -105,6 +128,8 @@ const html = `<!doctype html> figure { margin: 0; padding: 12px; + min-width: 0; + overflow: auto; background: #ffffff; border: 1px solid #d0d7de; border-radius: 6px; @@ -122,6 +147,17 @@ const html = `<!doctype html> height: auto; background: #ffffff; } + .images { + display: grid; + gap: 8px; + } + .multi-images { + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + } + .multi-images img { + object-fit: contain; + } .missing div { min-height: 160px; display: grid; diff --git a/scripts/ci/render-env b/scripts/ci/render-env index b2708370d..0aa7424b4 100644 --- a/scripts/ci/render-env +++ b/scripts/ci/render-env @@ -6,7 +6,7 @@ : "${SCENE_METRICS_NAME:=scene_metrics}" : "${CHOSEN_METRICS:=${SCENE_METRICS_NAME}_${CHOSEN_NAME}}" : "${COMBINED_RENDER_IMAGE:=render_summary}" -: "${COMBINED_RENDER_IMAGE_SCALE:=2}" +: "${COMBINED_RENDER_IMAGE_SCALE:=3}" : "${FE_COVERAGE_NAME:=fe_coverage}" : "${SCENE_METRICS_HEADER:=epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes, commit sha}" : "${ARTIFACT_BRANCH:=ci-artifacts}" diff --git a/scripts/ci/render-url-records b/scripts/ci/render-url-records index 7947eb0c5..de881e6ef 100755 --- a/scripts/ci/render-url-records +++ b/scripts/ci/render-url-records @@ -7,10 +7,12 @@ with open(sys.argv[1], encoding="utf-8") as f: for entry in entries: actions = entry.get("actions", []) + roi = entry.get("roi", {}) print("\034".join([ entry["name"], entry["mode"], entry["url"], json.dumps(actions, separators=(",", ":")) if actions else "", entry.get("state", ""), + json.dumps(roi, separators=(",", ":")) if roi else "", ])) diff --git a/scripts/ci/render-urls.json b/scripts/ci/render-urls.json index e3e6427e3..eb62da1cb 100644 --- a/scripts/ci/render-urls.json +++ b/scripts/ci/render-urls.json @@ -32,7 +32,13 @@ { "fill": { "placeholder": "Filter...", - "value": "Genesis" + "value": "v1.8" + } + }, + { + "fill": { + "placeholder": "Filter...", + "value": "v0" } }, { @@ -40,7 +46,14 @@ "class": "bp6-icon-cross" } } - ] + ], + "roi": { + "x": 575, + "y": 430, + "width": 275, + "height": 400, + "scale": 2 + } }, { "name": "password_reset", diff --git a/scripts/ci/run-playwright-render b/scripts/ci/run-playwright-render index 273276964..5ed953b88 100755 --- a/scripts/ci/run-playwright-render +++ b/scripts/ci/run-playwright-render @@ -92,7 +92,7 @@ valid_scene_metrics() { [ -n "$row" ] && [ "$row" != "undefined" ] && [ "$row" != "null" ] } -while IFS=$'\034' read -r -u 3 name mode url actions state; do +while IFS=$'\034' read -r -u 3 name mode url actions state roi; do screenshot_path="/tmp/${SCREENSHOT_NAME}_${name}.png" fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv" scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv" @@ -106,6 +106,7 @@ while IFS=$'\034' read -r -u 3 name mode url actions state; do ) [ -n "$actions" ] && fps_args+=(--actions "$actions") [ -n "$state" ] && fps_args+=(--state "$state") + [ -n "$roi" ] && fps_args+=(--roi "$roi") echo -e "\n\n\n" if [ "$mode" = "screenshot" ]; then diff --git a/scripts/ci/test_python_scripts.py b/scripts/ci/test_python_scripts.py index 3f1bc84a3..f601fa702 100644 --- a/scripts/ci/test_python_scripts.py +++ b/scripts/ci/test_python_scripts.py @@ -119,7 +119,29 @@ def test_render_url_records_outputs_field_separated_records(self): "promo\034fps\034http://localhost:3000/promo\034" "[{\"click\":{\"title\":\"Run\"}}," "{\"fill\":{\"placeholder\":\"Filter...\",\"value\":\"Genesis\"}}," - "{\"hover\":{\"classname\":\"item\"}}]\034app\n") + "{\"hover\":{\"classname\":\"item\"}}]\034app\034\n") + self.assertEqual(stderr, "") + + def test_render_url_records_outputs_roi_when_present(self): + with tempfile.NamedTemporaryFile("w", delete=False) as file: + json.dump([{ + "name": "dropdown", + "mode": "screenshot", + "url": "http://localhost:3000/demo", + "roi": {"x": 1, "y": 2, "width": 3, "height": 4}, + }], file) + file_path = file.name + try: + code, stdout, stderr = run_script( + "render-url-records", [file_path]) + finally: + Path(file_path).unlink(missing_ok=True) + + self.assertEqual(code, 0) + self.assertEqual( + stdout, + "dropdown\034screenshot\034http://localhost:3000/demo\034\034\034" + "{\"x\":1,\"y\":2,\"width\":3,\"height\":4}\n") self.assertEqual(stderr, "") def test_create_compare_link_uses_latest_deployment_sha(self): diff --git a/scripts/fps.js b/scripts/fps.js index a3883e46f..25dc1327a 100644 --- a/scripts/fps.js +++ b/scripts/fps.js @@ -17,6 +17,7 @@ function parseArgs(argv) { '--fps-samples-path': 'samplesCsvPath', '--actions': 'actions', '--state': 'state', + '--roi': 'roi', }; for (let i = 0; i < argv.length; i++) { @@ -56,6 +57,15 @@ const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW); const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; const screenshotOnly = options.screenshotOnly; const actions = options.actions ? JSON.parse(options.actions) : []; +const roi = options.roi ? JSON.parse(options.roi) : undefined; +const roiScale = (() => { + if (!roi) { return undefined; } + const value = Number(roi.scale || 2); + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`ROI scale must be a positive number: ${JSON.stringify(roi)}`); + } + return value; +})(); const state = options.state ? path.join('/tmp', `${options.state}.json`) : undefined; const saveState = path.join('/tmp', `${name}.json`); const commitSha = () => { @@ -108,6 +118,7 @@ function printUsage() { ' --screenshot-only Take a screenshot without FPS metrics.', ' --actions <json> Perform ordered actions after page load.', ' --state <name> Load cookies and localStorage from /tmp/<name>.json.', + ' --roi <json> Crop screenshots to {x,y,width,height}.', '', 'Environment:', ' CI Use CI browser launch behavior.', @@ -206,6 +217,50 @@ async function performAction(page, action) { throw new Error(`Unknown action: ${JSON.stringify(action)}`); } +function screenshotOptions(destination) { + const screenshot = { + path: destination, + fullPage: true, + timeout: 60_000, + }; + if (!roi) { return screenshot; } + + const clip = {}; + for (const key of ['x', 'y', 'width', 'height']) { + const value = Number(roi[key]); + if (!Number.isFinite(value)) { + throw new Error(`ROI ${key} must be a finite number: ${JSON.stringify(roi)}`); + } + clip[key] = value; + } + if (clip.width <= 0 || clip.height <= 0 || clip.x < 0 || clip.y < 0) { + throw new Error(`ROI must have non-negative x/y and positive width/height: ${JSON.stringify(roi)}`); + } + delete screenshot.fullPage; + screenshot.clip = clip; + return screenshot; +} + +async function saveScreenshot(page, destination, label) { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + await page.screenshot(screenshotOptions(destination)); + console.log(`${label}=${destination}`); +} + +function actionScreenshotPath(index) { + const extension = path.extname(screenshotPath); + const basePath = extension + ? screenshotPath.slice(0, -extension.length) + : screenshotPath; + return `${basePath}_action_${index + 1}${extension}`; +} + +async function performScreenshotAction(page, action, destination) { + await performAction(page, action); + await page.waitForTimeout(1000); + await saveScreenshot(page, destination, 'ACTION_SCREENSHOT'); +} + async function main() { console.log(`Launching Chromium with args:\n ${chromiumArgs.join('\n ')}\n`); if (executablePath) { @@ -216,7 +271,10 @@ async function main() { executablePath, args: chromiumArgs, }); - const contextOptions = state ? { storageState: state } : {}; + const contextOptions = { + ...(state ? { storageState: state } : {}), + ...(roiScale ? { deviceScaleFactor: roiScale } : {}), + }; if (state) { console.log(`STATE=${state}`); } @@ -227,18 +285,18 @@ async function main() { try { await page.goto(url, { waitUntil: 'domcontentloaded' }); await prepareStressResources(page, url); - for (const action of actions) { - await performAction(page, action); + for (const [index, action] of actions.entries()) { + if (screenshotOnly) { + await performScreenshotAction(page, action, actionScreenshotPath(index)); + } else { + await performAction(page, action); + } } if (screenshotOnly) { - await page.waitForTimeout(1000); - fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); - await page.screenshot({ - path: screenshotPath, - fullPage: true, - timeout: 60_000, - }); - console.log(`SCREENSHOT=${screenshotPath}`); + if (actions.length === 0) { + await page.waitForTimeout(1000); + await saveScreenshot(page, screenshotPath, 'SCREENSHOT'); + } return; } const renderer = await webglRenderer(page); @@ -307,13 +365,7 @@ async function main() { throw new Error('window.__scene_metrics was not available'); } console.log(`SCENE_METRICS=${data}`); - fs.mkdirSync(path.dirname(screenshotPath), { recursive: true }); - await page.screenshot({ - path: screenshotPath, - fullPage: true, - timeout: 60_000, - }); - console.log(`FPS_SCREENSHOT=${screenshotPath}`); + await saveScreenshot(page, screenshotPath, 'FPS_SCREENSHOT'); saveFpsSamplesCsv(sampleValues, samplesCsvPath); console.log(`FPS_SAMPLES_CSV=${samplesCsvPath}`); await saveStorage(page);