diff --git a/Gemfile.lock b/Gemfile.lock
index 7a023ed79c..72b2295aec 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 4e2c707aa7..011ec55a1d 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/frontend/constants.ts b/frontend/constants.ts
index 9b32a1cbcb..b8689c11e7 100644
--- a/frontend/constants.ts
+++ b/frontend/constants.ts
@@ -2307,7 +2307,7 @@ export enum DeviceSetting {
showReadingsMapLayer = `Show Readings Map Layer`,
showMoisture = `Moisture`,
showMoistureInterpolationMapLayer = `Show Moisture Interpolation Map Layer`,
- show3DMap = `3D Map beta`,
+ show3DMap = `3D beta`,
// Controls
invertJogButtonXAxis = `X Axis`,
diff --git a/frontend/css/_blueprint_overrides.scss b/frontend/css/_blueprint_overrides.scss
index c3cbf07e3f..250ab82f8d 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/components/go_button.scss b/frontend/css/components/go_button.scss
index 0de85ff24a..745bf7b671 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/farm_designer/farm_designer.scss b/frontend/css/farm_designer/farm_designer.scss
index 1582593e8f..89d28b5c23 100644
--- a/frontend/css/farm_designer/farm_designer.scss
+++ b/frontend/css/farm_designer/farm_designer.scss
@@ -617,8 +617,7 @@
}
}
}
- .toggle-buttons,
- .z-display-toggle {
+ .toggle-buttons {
fieldset {
display: flex;
align-items: center;
@@ -631,6 +630,9 @@
button {
margin: 0;
}
+ .fb-layer-toggle {
+ width: 4rem;
+ }
}
.move-to-mode {
display: none;
@@ -807,28 +809,3 @@
transform: translateX(-22.5rem);
}
}
-
-.three-d-map-toggle-menu {
- position: fixed;
- bottom: 0;
- right: 0;
- padding: 1rem;
- button {
- height: 3.5rem;
- width: 3.5rem;
- i {
- font-size: 1.5rem;
- }
- &.active {
- background-color: $blue !important;
- }
- }
- .three-d-map-toggle {
- padding: 0 1.5rem;
- background-color: var(--main-bg);
- backdrop-filter: var(--blur);
- border-radius: 0.5rem;
- box-shadow: var(--box-shadow);
- height: 3.5rem;
- }
-}
diff --git a/frontend/css/farm_designer/three_d_garden.scss b/frontend/css/farm_designer/three_d_garden.scss
index 1edb7937bc..17a0cf5eab 100644
--- a/frontend/css/farm_designer/three_d_garden.scss
+++ b/frontend/css/farm_designer/three_d_garden.scss
@@ -53,15 +53,148 @@
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-laser-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/css/global/colors.scss b/frontend/css/global/colors.scss
index 670cb208b1..27c434fd91 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/css/global/inputs.scss b/frontend/css/global/inputs.scss
index dc345cb0bd..819fb67fef 100644
--- a/frontend/css/global/inputs.scss
+++ b/frontend/css/global/inputs.scss
@@ -179,6 +179,14 @@ select {
.bp6-menu {
padding-left: 0;
padding-right: 0;
+ border-radius: 0;
+ a {
+ text-decoration: none !important;
+ }
+ }
+ .bp6-icon {
+ top: 0;
+ width: 0;
}
.bp6-input {
height: auto !important;
diff --git a/frontend/demo/demo_iframe.tsx b/frontend/demo/demo_iframe.tsx
index 5cb4be9707..ac9437dcca 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/farm_designer/__tests__/index_test.tsx b/frontend/farm_designer/__tests__/index_test.tsx
index bc15cfb2f7..3a49345593 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 b97ae7d41c..7945d8cb92 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 cea3dcfaf0..172ce3405f 100755
--- a/frontend/farm_designer/index.tsx
+++ b/frontend/farm_designer/index.tsx
@@ -26,10 +26,11 @@ 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";
-import { ThreeDGardenToggle } from "../three_d_garden";
export const getDefaultAxisLength =
(getConfigValue: GetWebAppConfigValue): Record => {
@@ -170,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
@@ -192,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}
@@ -218,8 +224,11 @@ export class RawFarmDesigner
? }
-
-
;
}
}
diff --git a/frontend/farm_designer/interfaces.ts b/frontend/farm_designer/interfaces.ts
index e5b0a9dba4..bafd012d89 100644
--- a/frontend/farm_designer/interfaces.ts
+++ b/frontend/farm_designer/interfaces.ts
@@ -21,11 +21,13 @@ import type {
TaggedPlantTemplate,
TaggedPlantPointer,
TaggedCurve,
+ TaggedFbosConfig,
PlantStage,
+ TaggedDevice,
} from "farmbot";
import type { SlotWithTool, ResourceIndex, UUID } from "../resources/interfaces";
import type {
- BotPosition, BotLocationData, SourceFbosConfig,
+ BotPosition, BotLocationData, BotState, SourceFbosConfig, UserEnv,
} from "../devices/interfaces";
import { isNumber } from "lodash";
import type {
@@ -86,6 +88,8 @@ export interface MountedToolInfo {
export interface FarmDesignerProps {
dispatch: Function;
device: DeviceAccountSettings;
+ deviceAccount?: TaggedDevice;
+ bot?: BotState;
selectedPlant: TaggedPlant | undefined;
designer: DesignerState;
hoveredPlant: TaggedPlant | undefined;
@@ -96,6 +100,8 @@ export interface FarmDesignerProps {
tools: TaggedTool[];
toolSlots: SlotWithTool[];
crops: TaggedCrop[];
+ sequences?: TaggedSequence[];
+ fbosConfig?: TaggedFbosConfig;
botLocationData: BotLocationData;
botMcuParams: McuParams;
botSize: BotSize;
@@ -104,6 +110,11 @@ export interface FarmDesignerProps {
latestImages: TaggedImage[];
cameraCalibrationData: CameraCalibrationData;
timeSettings: TimeSettings;
+ botOnline?: boolean;
+ arduinoBusy?: boolean;
+ currentBotLocation?: BotPosition;
+ movementState?: MovementState;
+ defaultAxes?: string;
getConfigValue: GetWebAppConfigValue;
sensorReadings: TaggedSensorReading[];
sensors: TaggedSensor[];
@@ -113,6 +124,7 @@ export interface FarmDesignerProps {
logs: TaggedLog[];
deviceTarget: string;
sourceFbosConfig: SourceFbosConfig;
+ env: UserEnv;
farmwareEnvs: TaggedFarmwareEnv[];
children?: React.ReactNode;
curves: TaggedCurve[];
diff --git a/frontend/farm_designer/map/interfaces.ts b/frontend/farm_designer/map/interfaces.ts
index 64ff0e8b57..50b0cf20c1 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/__tests__/garden_map_legend_test.tsx b/frontend/farm_designer/map/legend/__tests__/garden_map_legend_test.tsx
index 0b714926cf..d75db3b045 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
@@ -2,7 +2,7 @@ let mockAtMax = false;
let mockAtMin = false;
import React from "react";
-import { fireEvent, render } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import {
GardenMapLegend, ZoomControls, PointsSubMenu, FarmbotSubMenu,
PlantsSubMenu, MapSettingsContent, SettingsSubMenuProps,
@@ -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("", () => {
@@ -103,7 +107,7 @@ describe("", () => {
const beforeHasZDisplay =
!!container.querySelector(".z-display") || container.innerHTML.includes("-100");
expect(beforeHasZDisplay).toBeFalsy();
- const toggle = container.querySelector("button[title='show z display']");
+ const toggle = container.querySelector("button[title='show Z info']");
if (!toggle) {
expect(container.querySelectorAll("button").length > 0).toBeTruthy();
return;
@@ -114,6 +118,99 @@ describe("", () => {
const mockToggleOnly = !!container.querySelector(".mock-toggle-button");
expect(afterHasZDisplay || mockToggleOnly).toBeTruthy();
});
+
+ it("renders 3D controls off", () => {
+ const { container } = render();
+ expect(container.textContent).toContain("3D beta");
+ expect(container.textContent).not.toContain("Top down");
+ expect(container.textContent).not.toContain("Amplify Z");
+ });
+
+ it("toggles 3D view", () => {
+ const { container } = render();
+ const toggle = container.querySelector("button[title='show 3D beta']");
+ if (!toggle) { throw new Error("Missing 3D beta toggle"); }
+ fireEvent.click(toggle);
+ expect(setWebAppConfigValueSpy).toHaveBeenCalledWith(
+ BooleanSetting.three_d_garden, true);
+ });
+
+ it("disables top down view", () => {
+ const p = fakeProps();
+ p.getConfigValue = key => key == BooleanSetting.three_d_garden;
+ p.designer.threeDTopDownView = true;
+ const { container } = render();
+ const toggle = container.querySelector("button[title='hide Top down']");
+ if (!toggle) { throw new Error("Missing top down toggle"); }
+ fireEvent.click(toggle);
+ expect(p.dispatch).toHaveBeenCalledWith({
+ type: Actions.TOGGLE_3D_TOP_DOWN_VIEW,
+ payload: false,
+ });
+ });
+
+ it("uses saved top down setting", () => {
+ const p = fakeProps();
+ p.getConfigValue = key =>
+ key == BooleanSetting.three_d_garden || key == BooleanSetting.top_down_view;
+ const { container } = render();
+ const toggle = container.querySelector("button[title='hide Top down']");
+ if (!toggle) { throw new Error("Missing top down toggle"); }
+ fireEvent.click(toggle);
+ expect(p.dispatch).toHaveBeenCalledWith({
+ type: Actions.TOGGLE_3D_TOP_DOWN_VIEW,
+ payload: false,
+ });
+ });
+
+ it("enables top down view", () => {
+ const p = fakeProps();
+ p.getConfigValue = key => key == BooleanSetting.three_d_garden;
+ const { container } = render();
+ const toggle = container.querySelector("button[title='show Top down']");
+ if (!toggle) { throw new Error("Missing top down toggle"); }
+ fireEvent.click(toggle);
+ expect(p.dispatch).toHaveBeenCalledWith({
+ type: Actions.TOGGLE_3D_TOP_DOWN_VIEW,
+ payload: true,
+ });
+ });
+
+ it("disables exaggerated z", () => {
+ const p = fakeProps();
+ p.getConfigValue = key => key == BooleanSetting.three_d_garden;
+ p.designer.threeDExaggeratedZ = true;
+ const { container } = render();
+ const toggle = container.querySelector("button[title='hide Amplify Z']");
+ if (!toggle) { throw new Error("Missing amplify z toggle"); }
+ fireEvent.click(toggle);
+ expect(p.dispatch).toHaveBeenCalledWith({
+ type: Actions.TOGGLE_3D_EXAGGERATED_Z,
+ payload: false,
+ });
+ });
+
+ it("enables exaggerated z", () => {
+ const p = fakeProps();
+ p.getConfigValue = key => key == BooleanSetting.three_d_garden;
+ const { container } = render();
+ const toggle = container.querySelector("button[title='show Amplify Z']");
+ if (!toggle) { throw new Error("Missing amplify z toggle"); }
+ fireEvent.click(toggle);
+ expect(p.dispatch).toHaveBeenCalledWith({
+ type: Actions.TOGGLE_3D_EXAGGERATED_Z,
+ payload: true,
+ });
+ });
+
+ it("shows 3D controls help", () => {
+ const p = fakeProps();
+ p.getConfigValue = key => key == BooleanSetting.three_d_garden;
+ render();
+ fireEvent.click(screen.getByLabelText("3D beta help"));
+ expect(screen.getByText("3D Controls")).toBeInTheDocument();
+ expect(screen.getByText("Scroll to zoom")).toBeInTheDocument();
+ });
});
describe("", () => {
diff --git a/frontend/farm_designer/map/legend/garden_map_legend.tsx b/frontend/farm_designer/map/legend/garden_map_legend.tsx
index 0dd69f5c8d..9e61e0cc86 100644
--- a/frontend/farm_designer/map/legend/garden_map_legend.tsx
+++ b/frontend/farm_designer/map/legend/garden_map_legend.tsx
@@ -13,7 +13,7 @@ import {
import { BooleanSetting } from "../../../session_keys";
import { t } from "../../../i18next_wrapper";
import { SelectModeLink } from "../../../plants/select_plants";
-import { DeviceSetting, Content } from "../../../constants";
+import { Actions, DeviceSetting, Content } from "../../../constants";
import { Help, Popover, ToggleButton } from "../../../ui";
import {
BooleanConfigKey as WebAppBooleanConfigKey,
@@ -27,6 +27,9 @@ import {
} from "../../../settings/farm_designer_settings";
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;
@@ -91,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;
}
@@ -115,26 +120,83 @@ 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;
+ setZDisplayOpen(open: boolean): void;
+}
-interface LayerTogglesProps extends GardenMapLegendProps { }
+interface GardenMapLegendToggleProps {
+ label: string;
+ value: boolean;
+ onClick(): void;
+ settingName?: WebAppBooleanConfigKey;
+ labelClassName?: string;
+ children?: React.ReactNode;
+}
+
+const GardenMapLegendToggle = (props: GardenMapLegendToggleProps) => {
+ const classNames = [
+ "fb-button",
+ "fb-toggle-button",
+ "fb-layer-toggle",
+ props.value ? "green" : "red",
+ props.settingName ? getModifiedClassName(props.settingName) : "",
+ ].join(" ");
+ return
;
+};
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);
+ const exaggeratedZ = designer.threeDExaggeratedZ;
+ const description = (isMobile()
+ ? Content.SHOW_3D_VIEW_DESCRIPTION_MOBILE
+ : Content.SHOW_3D_VIEW_DESCRIPTION_DESKTOP)
+ .trim().replace(/\n\s+/g, "\n");
return
{
value={props.showMoistureInterpolationMap}
label={DeviceSetting.showMoisture}
onClick={toggle(BooleanSetting.show_moisture_interpolation_map)} />
+ dispatch(setWebAppConfigValue(
+ BooleanSetting.three_d_garden, !is3D))}>
+ {is3D &&
+ }
+
+ {is3D &&
+ dispatch({
+ type: Actions.TOGGLE_3D_TOP_DOWN_VIEW,
+ payload: !topDown,
+ })} />}
+ {is3D &&
+ dispatch({
+ type: Actions.TOGGLE_3D_EXAGGERATED_Z,
+ payload: !exaggeratedZ,
+ })} />}
+
;
};
@@ -277,7 +374,10 @@ export function GardenMapLegend(props: GardenMapLegendProps) {
{!is3D && }
-
+
-
{zDisplayOpen &&
-
-
- ;
+ ;
export interface ZDisplayProps {
allPoints: TaggedPoint[];
diff --git a/frontend/farm_designer/move_to.tsx b/frontend/farm_designer/move_to.tsx
index c291ffad0d..7b65ebab8e 100644
--- a/frontend/farm_designer/move_to.tsx
+++ b/frontend/farm_designer/move_to.tsx
@@ -166,6 +166,8 @@ export interface GoToThisLocationButtonProps {
dispatch: Function;
currentBotLocation: BotPosition;
movementState: MovementState;
+ noOptions?: boolean;
+ usePortal?: boolean;
}
interface GoToThisLocationButtonState {
@@ -221,6 +223,7 @@ export class GoToThisLocationButton
-
- {t("More options")}
-
-
+ {!this.props.noOptions &&
+
+ {t("More options")}
+
+ }
} />
;
}
diff --git a/frontend/farm_designer/state_to_props.ts b/frontend/farm_designer/state_to_props.ts
index ee36d0a93e..35aef80ef9 100644
--- a/frontend/farm_designer/state_to_props.ts
+++ b/frontend/farm_designer/state_to_props.ts
@@ -19,6 +19,7 @@ import {
maybeGetSequence,
selectAllLogs,
selectAllTools,
+ selectAllSequences,
selectAllFarmwareEnvs,
selectAllCurves,
} from "../resources/selectors";
@@ -44,6 +45,8 @@ import {
import { isToolFlipped } from "../tools/tool_slot_edit_components";
import { UserEnv } from "../devices/interfaces";
import { sourceFbosConfigValue } from "../settings/source_config_value";
+import { isBotOnlineFromState } from "../devices/must_be_online";
+import { validGoButtonAxes } from "./move_to";
const plantFinder = (plants: TaggedPlant[]) =>
(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 03acb1be90..1b19f963d8 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,12 +28,14 @@ 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;
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;
@@ -40,6 +45,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 +68,7 @@ export interface ThreeDGardenMapProps {
sensorReadings: TaggedSensorReading[];
sensors: TaggedSensor[];
cameraCalibrationData: CameraCalibrationData;
+ env: UserEnv;
farmwareEnvs: TaggedFarmwareEnv[];
logs: TaggedLog[];
}
@@ -373,15 +391,30 @@ 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}
+ set3DConfigValue={props.set3DConfigValue}
addPlantProps={addPlantProps} />;
};
diff --git a/frontend/plants/edit_plant_status.tsx b/frontend/plants/edit_plant_status.tsx
index d1be0fa213..33f22ddcc1 100644
--- a/frontend/plants/edit_plant_status.tsx
+++ b/frontend/plants/edit_plant_status.tsx
@@ -96,6 +96,7 @@ export function EditPlantStatus(props: EditPlantStatusProps) {
return
@@ -352,12 +353,14 @@ export const PlantSlugBulkUpdate = (props: PlantSlugBulkUpdateProps) => {
export interface EditWeedStatusProps {
weed: TaggedWeedPointer;
updateWeed(update: Partial): void;
+ usePortal?: boolean;
}
/** Select a `plant_stage` for a weed. */
export const EditWeedStatus = (props: EditWeedStatusProps) =>
diff --git a/frontend/plants/plant_panel.tsx b/frontend/plants/plant_panel.tsx
index 1053c746f3..16138d9bab 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/promo/__tests__/promo_test.tsx b/frontend/promo/__tests__/promo_test.tsx
index 2aa19e4012..d82823e414 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 500bb64f30..f2d7673689 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/settings/three_d_settings.tsx b/frontend/settings/three_d_settings.tsx
index 9ab25300cc..58089cecae 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__/garden_model_test.tsx b/frontend/three_d_garden/__tests__/garden_model_test.tsx
index 52f1390665..c3871b927e 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,
@@ -11,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";
@@ -27,6 +30,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 +42,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 +651,30 @@ 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("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();
@@ -735,6 +766,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 +865,476 @@ 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("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());
+ 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("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("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;
+ 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;
+ 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 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", () => {
+ 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/__tests__/group_order_visual_test.tsx b/frontend/three_d_garden/__tests__/group_order_visual_test.tsx
index eae4ffe5a9..1eaad73669 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/__tests__/index_test.tsx b/frontend/three_d_garden/__tests__/index_test.tsx
index 54c000bf48..cf5a627bc9 100644
--- a/frontend/three_d_garden/__tests__/index_test.tsx
+++ b/frontend/three_d_garden/__tests__/index_test.tsx
@@ -1,29 +1,15 @@
import React from "react";
-import { fireEvent, render, screen } from "@testing-library/react";
-import {
- ThreeDGardenProps, ThreeDGarden, ThreeDGardenToggle, ThreeDGardenToggleProps,
-} from "../index";
+import { render } from "@testing-library/react";
+import { ThreeDGardenProps, ThreeDGarden } from "../index";
import * as reactThreeFiber from "@react-three/fiber";
import { INITIAL, INITIAL_POSITION } from "../config";
import { clone } from "lodash";
import { fakeAddPlantProps } from "../../__test_support__/fake_props";
-import { fakeDesignerState } from "../../__test_support__/fake_designer_state";
-import { Path } from "../../internal_urls";
-import { Actions } from "../../constants";
-import * as configStorageActions from "../../config_storage/actions";
-import { BooleanSetting } from "../../session_keys";
-import { fakeDevice } from "../../__test_support__/resource_index_builder";
-import * as screenSize from "../../screen_size";
beforeEach(() => {
console.log = jest.fn();
window.localStorage.clear();
delete window.__fbPerf;
- jest.spyOn(screenSize, "isMobile").mockImplementation(() => false);
- jest.spyOn(configStorageActions, "getWebAppConfigValue")
- .mockImplementation(() => () => false);
- jest.spyOn(configStorageActions, "setWebAppConfigValue")
- .mockImplementation(jest.fn());
});
afterEach(() => {
@@ -73,108 +59,3 @@ describe("", () => {
expect(window.__fbPerf?.counts["render.ThreeDGarden"]).toEqual(1);
});
});
-
-describe("", () => {
- const fakeProps = (): ThreeDGardenToggleProps => ({
- navigate: jest.fn(),
- dispatch: jest.fn(),
- device: fakeDevice().body,
- designer: fakeDesignerState(),
- threeDGarden: true,
- getConfigValue: jest.fn(),
- });
-
- it("renders off", () => {
- const p = fakeProps();
- p.threeDGarden = false;
- render();
- const settingsButton = screen.queryByTitle("3D Settings");
- const toggle = screen.queryByTitle("show");
- expect(settingsButton).not.toBeInTheDocument();
- expect(toggle).toBeInTheDocument();
- });
-
- it("navigates to settings", () => {
- const p = fakeProps();
- render();
- const settingsButton = screen.getByTitle("3D Settings");
- fireEvent.click(settingsButton);
- expect(p.navigate).toHaveBeenCalledWith(Path.settings("3d_garden"));
- });
-
- it("disables top down view", () => {
- const p = fakeProps();
- p.designer.threeDTopDownView = true;
- render();
- const isoViewButton = screen.getByTitle("3D View");
- fireEvent.click(isoViewButton);
- expect(p.dispatch).toHaveBeenCalledWith({
- type: Actions.TOGGLE_3D_TOP_DOWN_VIEW,
- payload: false,
- });
- });
-
- it("uses saved top down setting", () => {
- const p = fakeProps();
- p.getConfigValue = () => true;
- render();
- const isoViewButton = screen.getByTitle("3D View");
- fireEvent.click(isoViewButton);
- expect(p.dispatch).toHaveBeenCalledWith({
- type: Actions.TOGGLE_3D_TOP_DOWN_VIEW,
- payload: false,
- });
- });
-
- it("enables top down view", () => {
- const p = fakeProps();
- render();
- const topDownViewButton = screen.getByTitle("Top down View");
- fireEvent.click(topDownViewButton);
- expect(p.dispatch).toHaveBeenCalledWith({
- type: Actions.TOGGLE_3D_TOP_DOWN_VIEW,
- payload: true,
- });
- });
-
- it("disables exaggerated z", () => {
- const p = fakeProps();
- p.designer.threeDExaggeratedZ = true;
- render();
- const isoViewButton = screen.getByTitle("normal z");
- fireEvent.click(isoViewButton);
- expect(p.dispatch).toHaveBeenCalledWith({
- type: Actions.TOGGLE_3D_EXAGGERATED_Z,
- payload: false,
- });
- });
-
- it("enables exaggerated z", () => {
- const p = fakeProps();
- render();
- const topDownViewButton = screen.getByTitle("exaggerated z");
- fireEvent.click(topDownViewButton);
- expect(p.dispatch).toHaveBeenCalledWith({
- type: Actions.TOGGLE_3D_EXAGGERATED_Z,
- payload: true,
- });
- });
-
- it("toggles 3D view", () => {
- const p = fakeProps();
- render();
- const toggle = screen.getByTitle("hide");
- fireEvent.click(toggle);
- expect(configStorageActions.setWebAppConfigValue).toHaveBeenCalledWith(
- BooleanSetting.three_d_garden,
- false);
- });
-
- it("shows 3D controls help", () => {
- const p = fakeProps();
- render();
- fireEvent.click(screen.getByLabelText("3D Map beta help"));
- expect(screen.getByText("3D Controls")).toBeInTheDocument();
- expect(screen.getByText("Scroll to zoom")).toBeInTheDocument();
- });
-});
diff --git a/frontend/three_d_garden/__tests__/triangles_test.ts b/frontend/three_d_garden/__tests__/triangles_test.ts
index a4b9089745..203f4e30c5 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 594a316ed1..ae8936df40 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 992427fb73..511ff2ed57 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/bed.tsx b/frontend/three_d_garden/bed/bed.tsx
index 18c363c53f..7af3f046b3 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,
@@ -794,19 +817,21 @@ const BedBase = (props: BedProps) => {
side={DoubleSide} />
>}
- {props.addPlantProps &&
- }
+
+ {props.addPlantProps &&
+ }
+
{props.config.lowDetail
?
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 41162141eb..0ca571aab4 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/__tests__/bot_test.tsx b/frontend/three_d_garden/bot/__tests__/bot_test.tsx
index 7604e96e3e..821dcc7836 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 56da51e696..9443bbd46a 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,22 @@ import { WateringAnimations } from "./components/watering_animations";
import { FocusVisibilityGroup } from "../focus_transition";
import { useTextureVariant } from "../texture_variants";
import { WaterFlowTextureProvider } from "./components/water_stream";
+import {
+ ThreeDObjectHoverHandler, ThreeDObjectHoverLabelHandler,
+ ThreeDObjectSelectionHandler,
+} from "../selection_types";
+import { clickWasDragged } from "../click_event";
+import { Mode } from "../../farm_designer/map/interfaces";
+import { getMode } from "../../farm_designer/map/util";
-export const extrusionWidth = 20;
+const xTrackPadding = 280;
+const extrusionWidth = 20;
const utmRadius = 35;
-export const utmHeight = 35;
-export const cameraMountOffset = {
- x: extrusionWidth + 3,
+const cameraMountOffset = {
+ x: extrusionWidth - 8,
y: utmRadius,
};
-export const cameraMountToLensOffset = new THREE.Vector3(
- 0,
- extrusionWidth + 9,
- 0,
-);
-const xTrackPadding = 280;
-export const distinguishableBlack = "#333";
+const distinguishableBlack = "#333";
type LeftBracket = GLTF & {
nodes: { [PartName.leftBracket]: THREE.Mesh };
@@ -121,6 +123,10 @@ export interface FarmbotModelProps {
toolSlots?: SlotWithTool[];
mountedToolName?: string | undefined;
dispatch?: Function;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onToolSlotHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?: ThreeDObjectHoverLabelHandler;
}
interface RequestedShapes {
@@ -246,7 +252,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => {
position={[
...botOuterXY(
props.config,
- x - extrusionWidth - 12,
+ x - extrusionWidth - 23,
outerY + bedColumnYOffset,
),
30,
@@ -261,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,
@@ -277,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,
@@ -290,7 +296,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => {
position={[
...botOuterXY(
props.config,
- x - 68,
+ x - 79,
outerY - (index == 0 ? 5 : -25) + bedColumnYOffset,
),
columnLength + 80,
@@ -306,7 +312,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => {
position={[
...botOuterXY(
props.config,
- x - 63,
+ x - 74,
outerY - (index == 0 ? 5 : -25) + bedColumnYOffset,
),
columnLength + 55,
@@ -324,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,
@@ -344,7 +350,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => {
position={[
...botOuterXY(
props.config,
- -132,
+ -143,
outerY + 10 + bedColumnYOffset,
),
2 + (index == 0 ? 0 : 5),
@@ -362,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),
@@ -380,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,
@@ -394,7 +400,7 @@ const BotFrameSubassembliesBase = (props: BotFrameSubassembliesProps) => {
{props.config.cableCarriers &&
}
{props.config.cableCarriers &&
@@ -405,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]}
@@ -496,7 +502,7 @@ const BotGantrySubassembliesBase = (props: BotGantrySubassembliesProps) => {
configPosition={props.configPosition} />}
{
@@ -517,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,
@@ -535,14 +541,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 +571,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 +610,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;
@@ -624,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] => {
@@ -642,20 +667,40 @@ 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(
...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) {
+ props.onSelectObject({ kind: "utm", id: 0 }) !== false &&
+ event.stopPropagation?.();
+ }
+ };
+ const selectCamera = (event: ThreeEvent) => {
+ if (clickWasDragged(event)) { return; }
+ if ([...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) {
+ return;
+ }
+ if (props.onSelectObject) {
+ props.onSelectObject({ kind: "camera", id: 0 }) !== false &&
+ event.stopPropagation?.();
+ }
+ };
const utmComponent =
@@ -663,6 +708,9 @@ const BotVerticalToolheadSubassemblyBase =
geometry={utm.nodes.M5_Barb.geometry}
material={utm.materials.PaletteMaterial001}
position={[0.015, 0.009, 0.036]}
+ onClick={selectUtm}
+ onPointerOver={() => props.onHoverObject?.(true)}
+ onPointerOut={() => props.onHoverObject?.(false)}
rotation={[0, 0, 2.094]} />
;
@@ -674,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]}>
@@ -683,7 +731,7 @@ const BotVerticalToolheadSubassemblyBase =
@@ -722,7 +770,7 @@ const BotVerticalToolheadSubassemblyBase =
@@ -743,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]} />
@@ -752,10 +800,11 @@ const BotVerticalToolheadSubassemblyBase =
config={config}
configPosition={props.configPosition} />}
{config.cableCarriers &&
- }
+ }
props.onHoverObject?.(true)}
+ onPointerOut={() => props.onHoverObject?.(false)}
rotation={[Math.PI, 0, 0]}
position={cameraMountPosition}>
+ distanceToSoil={-props.getZ(x - 11, y) - zDir * z} />
{props.trailReady && trail
? {
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 93bfe6d824..95a9f508f1 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,13 +1,18 @@
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 {
+ ElectronicsBox, ElectronicsBoxProps, getElectronicsBoxPosition,
+} from "../electronics_box";
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;
+let getModeSpy: jest.SpyInstance;
interface ReactPropsElement extends Element {
[key: string]: unknown;
@@ -32,6 +37,11 @@ const electronicsBoxPosition = (container: HTMLElement) => {
beforeEach(() => {
useGltfMock.mockClear();
+ getModeSpy = jest.spyOn(mapUtil, "getMode").mockReturnValue(Mode.none);
+});
+
+afterEach(() => {
+ getModeSpy.mockRestore();
});
describe("", () => {
@@ -45,6 +55,39 @@ 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", () => {
+ 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();
+ });
+
+ 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 f2df2111be..31405a156a 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=\"1250,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=\"1050,-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(550);
});
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=\"-1050,-680,391\"");
});
it("renders vacuum animation when not in toolbay and vacuum", () => {
@@ -477,6 +499,86 @@ 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.onHoverLabel = jest.fn();
+ p.toolSlots = configuredUserTools();
+ p.toolSlots.forEach((slot, index) => {
+ slot.toolSlot.body.id = index + 1;
+ });
+ const { container } = render();
+ 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);
+ container.querySelectorAll("group:not([name])").forEach(group => {
+ fireEvent.pointerOver(group);
+ fireEvent.pointerOut(group);
+ });
+ 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", () => {
+ 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 32f90b9004..cca87bcd52 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 "../bot";
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 ledsPresent = (kitVersion: string) => {
switch (kitVersion) {
case "v1.7":
@@ -113,8 +123,28 @@ const LedIndicators = () => {
export interface ElectronicsBoxProps {
config: Config;
configPosition: PositionConfig;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
}
+export const getElectronicsBoxPosition = (
+ config: Config,
+ configPosition: PositionConfig,
+) => {
+ 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,
@@ -125,24 +155,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) {
+ onSelectObject({ kind: "electronics", id: 0 }) !== false &&
+ event.stopPropagation?.();
+ }
+ }, [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 +199,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 +212,9 @@ const ElectronicsBoxModelBase = (props: ElectronicsBoxModelProps) => {
useGLTF(ASSETS.models.farmduino, LIB_DIR) as unknown as Farmduino;
return <>
{
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
new file mode 100644
index 0000000000..ec7c3a827e
--- /dev/null
+++ b/frontend/three_d_garden/bot/components/tool_slot_position.ts
@@ -0,0 +1,74 @@
+import { Xyz } from "farmbot";
+import { SlotWithTool } from "../../../resources/interfaces";
+import { Config, PositionConfig } from "../../config";
+import {
+ get3DPositionFunc, get3DPositionNoMirrorFunc,
+ zDir as zDirFunc, zZero as zZeroFunc,
+} from "../../helpers";
+
+export interface ThreeDToolPositionInput {
+ x: number;
+ y: number;
+ z: number;
+ gantryMounted?: boolean;
+}
+
+export interface ToolPositionHelpers {
+ get3DPosition: ReturnType;
+ 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 : (35 / 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 53bf19f962..742f884e82 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,9 @@ import {
SeedTroughAssemblyFull, SeedTroughAssemblyModel,
SeedTroughHolderFull, SeedTroughHolderModel,
} from "../parts";
-import { Group, Mesh, MeshPhongMaterial } from "../../components";
-import { distinguishableBlack, utmHeight } from "../bot";
+import {
+ Group, Mesh, MeshPhongMaterial,
+} from "../../components";
import { SlotWithTool } from "../../../resources/interfaces";
import { isUndefined, sortBy } from "lodash";
import {
@@ -31,10 +26,22 @@ 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, ThreeDObjectHoverLabelHandler,
+ ThreeDObjectSelection,
+ ThreeDObjectSelectionHandler,
+} from "../../selection_types";
+import {
+ getToolPositionHelpers, getToolRenderPosition, ToolPositionHelpers,
+} from "./tool_slot_position";
+import { clickWasDragged } from "../../click_event";
+
+const distinguishableBlack = "#333";
type Toolbay3 = GLTF & {
nodes: {
@@ -82,6 +89,10 @@ export interface ToolsProps {
mountedToolName?: string | undefined;
dispatch?: Function;
getZ(x: number, y: number): number;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onToolSlotHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?: ThreeDObjectHoverLabelHandler;
}
export interface ThreeDTool {
@@ -118,6 +129,10 @@ 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.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 &&
@@ -191,17 +206,16 @@ 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
{
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) =>
,
+ onSelectObject: ThreeDObjectSelectionHandler,
+ selection: ThreeDObjectSelection,
+) =>
+ onSelectObject(selection) !== false && event.stopPropagation?.();
+
+const useToolSlotClick = (props: ToolbaySlotProps) => {
+ 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())) {
+ if (props.onSelectObject) {
+ const selection: ThreeDObjectSelection = props.id
+ ? { kind: "slot", id: props.id }
+ : { kind: "utm", id: 0 };
+ stopPropagationForSelectedSlot(event, props.onSelectObject, selection);
+ return;
+ }
+ event.stopPropagation?.();
+ if (props.id) {
+ props.dispatch?.(setPanelOpen3D(true));
+ navigate(Path.toolSlots(props.id));
+ } else {
+ props.dispatch?.(setPanelOpen3D(true));
+ navigate(Path.tools());
+ }
+ }
+ };
+};
+
+const TOOLBAY_SLOT_Z_OFFSET = -9;
+const SEED_TROUGH_SLOT_Z_OFFSET = -40;
+
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,
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={() => {
+ if (!selectable) { return; }
+ props.onHoverObject?.(true);
+ props.onHoverLabel?.(selection);
+ }}
+ onPointerOut={() => {
+ if (!selectable) { return; }
+ props.onHoverObject?.(false);
+ props.onHoverLabel?.(undefined);
}}>
{rotationMultiplier &&
;
- get3DPositionNoMirror: ReturnType;
- zZero: number;
- zDir: number;
- };
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onToolSlotHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?: ThreeDObjectHoverLabelHandler;
+ positionHelpers: ToolPositionHelpers;
}
interface ToolModelProps {
@@ -369,8 +435,6 @@ interface ToolModelProps {
inToolbay: boolean;
}
-const TOOL_X = 5.5;
-
const RotaryToolModel = React.memo(
React.forwardRef((_props, ref) => {
const rotaryToolBase =
@@ -379,7 +443,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 +526,7 @@ const SeederToolModel = React.memo((props: ToolModelProps) => {
return <>
{
const weeder = useGLTF(ASSETS.models.weeder, LIB_DIR) as unknown as Weeder;
return {
{
const seedTrough = useGLTF(ASSETS.models.seedTrough, LIB_DIR) as unknown as SeedTrough;
return
?
: );
+interface SeedTroughToolSlotProps extends ToolbaySlotProps {
+ firstTrough?: boolean;
+}
+
+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 {
+ if (!selectable) { return; }
+ props.onHoverObject?.(true);
+ props.onHoverLabel?.(selection);
+ }}
+ onPointerOut={() => {
+ if (!selectable) { return; }
+ props.onHoverObject?.(false);
+ props.onHoverLabel?.(undefined);
+ }}>
+
+ ;
+};
+
interface ActiveRotaryToolSlotProps extends ToolbaySlotProps {
rotary: number;
}
@@ -562,23 +658,16 @@ 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 onHoverObject = inToolbay
+ ? props.onToolSlotHoverObject || props.onHoverObject
+ : props.onHoverObject;
const common: ToolbaySlotProps = {
mounted, position, toolPulloutDirection, id, inToolbay, config, dispatch,
+ onSelectObject: props.onSelectObject,
+ onHoverObject,
+ onHoverLabel: props.onHoverLabel,
};
switch (props.toolName) {
case ToolName.rotaryTool:
@@ -614,15 +703,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 807d7820c6..575f4713d4 100644
--- a/frontend/three_d_garden/bot/components/watering_animations.tsx
+++ b/frontend/three_d_garden/bot/components/watering_animations.tsx
@@ -10,7 +10,6 @@ import {
easyCubicBezierCurve3, get3DPositionNoMirrorFunc, zDir, zZero,
} from "../../helpers";
import { Config, PositionConfig } from "../../config";
-import { utmHeight } from "../bot";
import { Texture } from "three";
export interface WateringAnimationsProps {
@@ -71,7 +70,7 @@ const WateringAnimationsContent = (props: WateringAnimationsContentProps) => {
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 4706b9c501..1c8a905bcb 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/config.ts b/frontend/three_d_garden/config.ts
index da3ecd1373..c96db3676f 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/elements/text.tsx b/frontend/three_d_garden/elements/text.tsx
index a94040dd49..b16a88ff2a 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__/plant_instances_test.tsx b/frontend/three_d_garden/garden/__tests__/plant_instances_test.tsx
index 62e5a4344f..08e569d02e 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();
@@ -479,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);
});
@@ -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 262ae3b67d..7d2546c217 100644
--- a/frontend/three_d_garden/garden/__tests__/plants_test.tsx
+++ b/frontend/three_d_garden/garden/__tests__/plants_test.tsx
@@ -13,16 +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";
@@ -286,20 +283,21 @@ describe("", () => {
expect(container.querySelectorAll("instancedmesh").length).toBe(1);
});
- it("handles click on spread part", () => {
- setMockInstanceId(0);
+ it("keeps spread spheres inert", () => {
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"));
+ p.dispatch = mockDispatch(jest.fn());
+ p.onSelectObject = jest.fn();
+ p.onHoverObject = jest.fn();
+ const wrapper = createRenderer();
+ const mesh = wrapper.root.findAll(node =>
+ (node.type as string) == "instancedMesh")[0];
+ expect(mesh.props.raycast()).toBeUndefined();
+ expect(p.onSelectObject).not.toHaveBeenCalled();
+ expect(p.onHoverObject).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ unmountRenderer(wrapper);
});
it("doesn't navigate after orbiting over a spread sphere", () => {
@@ -311,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();
@@ -331,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();
@@ -399,6 +351,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 1a68dc61d4..6c0cae870e 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", () => {
@@ -80,6 +80,33 @@ 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.onHoverLabel = 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);
+ expect(p.onHoverLabel).toHaveBeenCalledWith({ kind: "point", id: 1 });
+ expect(p.onHoverLabel).toHaveBeenCalledWith(undefined);
+ });
+
it("doesn't navigate after orbiting over a point", () => {
const p = fakeProps();
const dispatch = jest.fn();
@@ -168,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();
@@ -262,6 +289,42 @@ 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();
+ p.onHoverLabel = jest.fn();
+ p.points[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"
+ && ["marker", "marker-radius"].includes(name);
+ });
+ meshes.forEach(mesh => {
+ 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", () => {
const p = fakeInstanceProps();
const dispatch = jest.fn();
@@ -321,7 +384,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\"");
@@ -335,7 +398,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/__tests__/weed_test.tsx b/frontend/three_d_garden/garden/__tests__/weed_test.tsx
index 610ae0ea00..bb16125f65 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", () => {
@@ -74,6 +74,33 @@ 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.onHoverLabel = 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);
+ expect(p.onHoverLabel).toHaveBeenCalledWith({ kind: "weed", id: 1 });
+ expect(p.onHoverLabel).toHaveBeenCalledWith(undefined);
+ });
+
it("doesn't navigate after orbiting over a weed", () => {
const p = fakeProps();
const dispatch = jest.fn();
@@ -189,27 +216,75 @@ 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("navigates from a weed radius instance", () => {
+ it("selects a weed icon instead of navigating when handler is present", () => {
const p = fakeInstanceProps();
- const dispatch = jest.fn();
- p.dispatch = mockDispatch(dispatch);
+ 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("keeps weed radius instances inert", () => {
+ const p = fakeInstanceProps();
+ p.dispatch = mockDispatch(jest.fn());
+ p.onSelectObject = jest.fn();
+ 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(dispatch).toHaveBeenCalledWith({
- type: Actions.SET_PANEL_OPEN, payload: true,
- });
- expect(mockNavigate).toHaveBeenCalledWith(Path.weeds("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 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", () => {
@@ -221,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/plant_instances.tsx b/frontend/three_d_garden/garden/plant_instances.tsx
index 2edb796e70..e52e38d53d 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";
@@ -34,6 +35,9 @@ import {
getSeasonAnimationElapsed,
} from "./sun";
import { clickWasDragged } from "../click_event";
+import {
+ ThreeDObjectHoverHandler, ThreeDObjectSelectionHandler,
+} from "../selection_types";
export interface PlantInstancesProps {
plants: ThreeDGardenPlant[];
@@ -44,6 +48,8 @@ export interface PlantInstancesProps {
dispatch?: Function;
iconCapacities?: Record;
plantIconAtlas?: PlantIconAtlas;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
}
interface PlantIconInstancesProps extends PlantInstancesProps {
@@ -105,14 +111,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 +127,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);
@@ -130,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,
@@ -266,6 +284,7 @@ const usePlantIconClick = (
plants: ThreeDGardenPlant[],
dispatch: Function | undefined,
visible: boolean | undefined,
+ onSelectObject: ThreeDObjectSelectionHandler | undefined,
) => {
const navigate = useNavigate();
return (event: ThreeEvent) => {
@@ -273,9 +292,15 @@ 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));
+ if (onSelectObject) {
+ onSelectObject({ kind: "plant", id: plant.id }) !== false &&
+ event.stopPropagation?.();
+ return;
+ }
+ event.stopPropagation?.();
+ dispatch?.(setPanelOpen3D(true));
navigate(Path.plants(plant.id));
}
};
@@ -346,7 +371,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 +492,8 @@ const VisiblePlantInstances = (props: PlantInstancesProps) => {
capacity: 0,
startTimeRef: props.startTimeRef,
visible: props.visible,
+ onSelectObject: props.onSelectObject,
+ onHoverObject: props.onHoverObject,
};
}
});
@@ -509,6 +544,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 db9dc7d63c..43c8fe96cc 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,8 +12,6 @@ import {
Matrix4,
Quaternion,
InstancedBufferAttribute,
- type Raycaster,
- type Intersection,
} from "three";
import {
getGardenPositionFunc,
@@ -22,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,
@@ -36,7 +31,9 @@ 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";
const spreadLayerSpringConfig = {
tension: 240,
@@ -161,6 +158,8 @@ export interface PlantSpreadInstancesProps {
spreadVisible: boolean;
instanceCapacity?: number;
routeKey?: string;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
}
interface PlantSpreadUpdateState {
@@ -168,14 +167,7 @@ interface PlantSpreadUpdateState {
lastUpdateKey: string;
}
-const plantSpreadRaycast = function (
- this: ThreeInstancedMesh,
- raycaster: Raycaster,
- intersects: Intersection[],
-) {
- if (HOVER_OBJECT_MODES.includes(getMode())) { return; }
- ThreeInstancedMesh.prototype.raycast.call(this, raycaster, intersects);
-};
+const noRaycast = () => undefined;
interface StaticPlantSpreadInstance {
id?: number;
@@ -210,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(), []);
@@ -456,18 +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 && visible &&
- ![...HOVER_OBJECT_MODES, Mode.cameraSelection].includes(getMode())) {
- dispatch(setPanelOpen3D(true));
- navigate(Path.plants(plant.id));
- }
- };
-
if (!spreadInstancesVisible) { return <>>; }
return {
count={plants.length}
userData={{ plantIndexes }}
visible={visible}
- raycast={plantSpreadRaycast}
- onClick={onClick}>
+ raycast={noRaycast}>
,
+ onSelectObject: ThreeDObjectSelectionHandler,
+ selection: ThreeDObjectSelection,
+) =>
+ onSelectObject(selection) !== false && event.stopPropagation?.();
+
const makePointMarkerGeometry = () => {
const pinGeometry = new CylinderGeometry(
POINT_PIN_RADIUS,
@@ -93,12 +105,16 @@ export interface PointProps {
dispatch?: Function;
visible: boolean;
getZ(x: number, y: number): number;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?: ThreeDObjectHoverLabelHandler;
}
export const Point = (props: PointProps) => {
const { point, config } = props;
const navigate = useNavigate();
const unsaved = point.specialStatus !== SpecialStatus.SAVED;
+ const pointId = point.body.id;
return {
}}
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())) {
+ if (props.onSelectObject) {
+ stopPropagationForSelectedPoint(event, props.onSelectObject, {
+ kind: "point", id: point.body.id,
+ });
+ return;
+ }
+ event.stopPropagation?.();
+ props.dispatch?.(setPanelOpen3D(true));
navigate(Path.points(point.body.id));
}
}}
config={config}
color={point.body.meta.color}
radius={point.body.radius}
+ onHoverObject={props.onHoverObject}
+ onHoverLabel={pointId
+ ? hovered => props.onHoverLabel?.(hovered
+ ? { kind: "point", id: pointId }
+ : undefined)
+ : undefined}
/>;
};
@@ -140,6 +170,9 @@ export interface PointInstancesProps {
dispatch?: Function;
visible: boolean;
getZ(x: number, y: number): number;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?: ThreeDObjectHoverLabelHandler;
}
const pointAlpha = (point: TaggedGenericPointer) =>
@@ -245,12 +278,30 @@ 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())) {
+ if (props.onSelectObject) {
+ stopPropagationForSelectedPoint(event, props.onSelectObject, {
+ kind: "point", id: point.body.id,
+ });
+ return;
+ }
+ event.stopPropagation?.();
+ dispatch?.(setPanelOpen3D(true));
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={onHover(group.points, true)}
+ onPointerOut={onHover(group.points, false)}
renderOrder={RenderOrder.points}>
{
dispose={null}
visible={visible}
onClick={onClick(group.ringPoints)}
+ onPointerOver={onHover(group.ringPoints, true)}
+ onPointerOut={onHover(group.ringPoints, false)}
renderOrder={RenderOrder.points}>
{
@@ -411,6 +469,7 @@ interface PointBaseProps {
pointName: string;
position?: Record;
onClick?: (event: ThreeEvent) => void;
+ onHoverObject?: ThreeDObjectHoverHandler;
color: string | undefined;
radius: number;
alpha: number;
@@ -418,6 +477,7 @@ interface PointBaseProps {
torusRef?: TorusRef;
billboardRef?: BillboardRef;
imageRef?: ImageRef;
+ onHoverLabel?(hovered: boolean): void;
}
const PointBase = (props: PointBaseProps) => {
@@ -431,7 +491,15 @@ const PointBase = (props: PointBaseProps) => {
rotation={[Math.PI / 2, 0, 0]}
position={position
? getWorldPosition(position)
- : [0, 0, 0]}>
+ : [0, 0, 0]}
+ onPointerOver={() => {
+ props.onHoverObject?.(true);
+ props.onHoverLabel?.(true);
+ }}
+ onPointerOut={() => {
+ props.onHoverObject?.(false);
+ props.onHoverLabel?.(false);
+ }}>
undefined;
+
+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 = () => {
@@ -45,19 +61,30 @@ export interface WeedProps {
dispatch?: Function;
visible: boolean;
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 {
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())) {
+ if (props.onSelectObject) {
+ props.onSelectObject({ kind: "weed", id: weed.body.id }) !== false &&
+ event.stopPropagation?.();
+ return;
+ }
+ event.stopPropagation?.();
+ props.dispatch?.(setPanelOpen3D(true));
navigate(Path.weeds(weed.body.id));
}
}}
@@ -68,7 +95,13 @@ export const Weed = (props: WeedProps) => {
}}
config={config}
color={weed.body.meta.color}
- radius={weed.body.radius} />;
+ radius={weed.body.radius}
+ onHoverLabel={weedId
+ ? hovered => props.onHoverLabel?.(hovered
+ ? { kind: "weed", id: weedId }
+ : undefined)
+ : undefined}
+ onHoverObject={props.onHoverObject} />;
};
interface WeedBaseProps {
@@ -82,6 +115,8 @@ interface WeedBaseProps {
radiusRef?: RadiusRef;
billboardRef?: BillboardRef;
imageRef?: ImageRef;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?(hovered: boolean): void;
}
export const WeedBase = (props: WeedBaseProps) => {
@@ -92,30 +127,46 @@ 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
+ onClick={onClick}
+ onPointerOver={() => {
+ props.onHoverObject?.(true);
+ props.onHoverLabel?.(true);
+ }}
+ onPointerOut={() => {
+ props.onHoverObject?.(false);
+ props.onHoverLabel?.(false);
+ }}>
-
+ position={[0, 0, 0]}>
+
+
+
({
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) {
+ return onSelectObject({ kind: "weed", id: weed.body.id }) !== false;
+ }
+ const dispatchPanelOpen = dispatch as Function;
+ dispatchPanelOpen(setPanelOpen3D(true));
+ navigate(Path.weeds(weed.body.id));
+ return true;
};
};
@@ -237,14 +298,9 @@ interface WeedIconInstancesProps extends WeedInstancesProps {
const WeedIconInstances = (props: WeedIconInstancesProps) => {
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 navigateToWeed = useNavigateToWeed(dispatch, visible);
+ const texture = useWeedIconTexture(props.plantIconAtlas || PLANT_ICON_ATLAS);
+ const navigateToWeed =
+ useNavigateToWeed(dispatch, visible, props.onSelectObject);
// eslint-disable-next-line no-null/no-null
const instancedRef = React.useRef(null);
const updateStateRef =
@@ -291,8 +347,23 @@ 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?.();
+ }
};
+ 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={onHover(true)}
+ onPointerOut={onHover(false)}
renderOrder={RenderOrder.weedImages}>
{
- const { bucket, dispatch, visible } = props;
- const navigateToWeed = useNavigateToWeed(dispatch, visible);
+ const { bucket, visible } = props;
// eslint-disable-next-line no-null/no-null
const instancedRef = React.useRef(null);
const radiusGeometry = getWeedRadiusGeometry();
@@ -345,13 +417,6 @@ const WeedRadiusInstances = (props: WeedRadiusInstancesProps) => {
tempScale,
]);
- const onClick = (event: ThreeEvent) => {
- if (clickWasDragged(event)) { return; }
- const instanceId = event.instanceId;
- if (isUndefined(instanceId)) { return; }
- navigateToWeed(bucket.weeds[instanceId]?.weed);
- };
-
return {
// eslint-disable-next-line no-null/no-null
dispose={null}
visible={visible}
- onClick={onClick}
+ 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(() =>
@@ -74,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;
@@ -89,7 +257,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 +275,7 @@ const ZoomBeaconsLoadIn = (props: ZoomBeaconsLoadInProps) => {
interface SceneBoundaryProps {
markName?: string;
loadProgress?: ThreeDLoadProgress;
+ markStep?: ThreeDLoadProgress["markStep"];
loadStep?: ThreeDLoadStepId;
reveal?: boolean;
markReadyOnMount?: boolean;
@@ -116,15 +285,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 +305,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 +329,8 @@ export interface GardenModelProps {
images?: TaggedImage[];
sensorReadings?: TaggedSensorReading[];
sensors?: TaggedSensor[];
+ env?: UserEnv;
+ set3DConfigValue?(key: keyof Config, value: string): void;
smoothFocusTransitions?: boolean;
smoothConfigTransitions?: boolean;
plantIconCapacities?: Record;
@@ -154,6 +339,7 @@ export interface GardenModelProps {
seasonResetKey?: number;
preloadEnvironmentScenes?: boolean;
showFarmbotLayerLoadProgress?: boolean;
+ promo?: boolean;
onDetailsRevealStart?(): void;
onLoadComplete?(): void;
}
@@ -162,9 +348,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 +489,7 @@ function getGardenLayerVisibility(
interface StaticGardenLayersProps {
config: Config;
- loadProgress: ThreeDLoadProgress;
+ markStep: ThreeDLoadProgress["markStep"];
environmentReveal: boolean;
bedReveal: boolean;
gridReveal: boolean;
@@ -327,12 +524,19 @@ interface StaticGardenLayersProps {
showWeeds: boolean;
weeds: TaggedWeedPointer[];
showPoints: boolean;
+ plantsSelectable: boolean;
+ pointsSelectable: boolean;
+ weedsSelectable: boolean;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?(selection: ThreeDObjectSelection | undefined): void;
+ 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 +544,8 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
plantIconAtlas, setHover, threeDPlants, plantIconCapacities, startTimeRef,
dispatch, showSpread,
plantInstanceCapacity, routeKey, seasonResetKey, showWeeds, weeds,
- showPoints,
+ showPoints, plantsSelectable, pointsSelectable, weedsSelectable,
+ onSelectObject, onHoverObject, onHoverLabel, onPlantHoverChange,
} = props;
const seasonLayerKey = `${config.plants}-${seasonResetKey || 0}`;
const gridVisible = config.grid && activeFocus != "Planter bed";
@@ -352,11 +557,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 +592,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
@@ -383,7 +600,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
loadProgress.markStep("bed")}
+ onRest={() => markStep("bed")}
distance={config.bedHeight + config.bedZOffset}>
{
@@ -410,7 +627,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
loadProgress.markStep("grid")}>
+ onRest={() => markStep("grid")}>
{
@@ -428,7 +645,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 +657,9 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
+ onPointerEnter={plantsSelectable ? handlePlantPointerEnter : undefined}
+ onPointerMove={plantsSelectable ? handlePlantPointerMove : undefined}
+ onPointerLeave={plantsSelectable ? handlePlantPointerLeave : undefined}>
{
iconCapacities={plantIconCapacities}
plantIconAtlas={plantIconAtlas}
startTimeRef={startTimeRef}
- dispatch={dispatch} />
+ onSelectObject={plantsSelectable ? onSelectObject : undefined}
+ onHoverObject={plantsSelectable ? onPlantHoverChange : undefined}
+ dispatch={plantsSelectable ? dispatch : undefined} />
{
activePositionRef={activePositionRef}
routeKey={routeKey}
getZ={getZ}
- dispatch={dispatch} />
+ onSelectObject={plantsSelectable ? onSelectObject : undefined}
+ onHoverObject={plantsSelectable ? onPlantHoverChange : undefined}
+ dispatch={plantsSelectable ? dispatch : undefined} />
}
@@ -475,7 +696,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
loadProgress.markStep("weeds")}
+ onRest={() => markStep("weeds")}
distance={200}
animateExit={true}
hideAfterExit={true}>
@@ -487,13 +708,16 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
config={config}
getZ={getZ}
plantIconAtlas={plantIconAtlas}
- dispatch={dispatch} />
+ onSelectObject={weedsSelectable ? onSelectObject : undefined}
+ onHoverObject={weedsSelectable ? onHoverObject : undefined}
+ onHoverLabel={weedsSelectable ? onHoverLabel : undefined}
+ dispatch={weedsSelectable ? dispatch : undefined} />
}
@@ -501,7 +725,7 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
loadProgress.markStep("points")}
+ onRest={() => markStep("points")}
distance={200}
animateExit={true}
hideAfterExit={true}>
@@ -512,14 +736,35 @@ const StaticGardenLayersBase = (props: StaticGardenLayersProps) => {
visible={true}
config={config}
getZ={getZ}
- dispatch={dispatch} />
+ onSelectObject={pointsSelectable ? onSelectObject : undefined}
+ onHoverObject={pointsSelectable ? onHoverObject : undefined}
+ onHoverLabel={pointsSelectable ? onHoverLabel : undefined}
+ dispatch={pointsSelectable ? dispatch : undefined} />
}
>;
};
-const StaticGardenLayers = React.memo(StaticGardenLayersBase);
+const isStaticGardenLayerIgnoredProp = (
+ key: keyof StaticGardenLayersProps,
+) =>
+ key == "markStep"
+ || key == "onSelectObject"
+ || key == "onHoverObject"
+ || key == "onHoverLabel"
+ || 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 +826,7 @@ const EnvironmentScenePreloader = (props: EnvironmentScenePreloaderProps) => {
};
const ignoredLoadStep = (_step: ThreeDLoadStepId) => undefined;
+const allowLoadStep = () => true;
const farmbotLayerLoadProgress: ThreeDLoadProgress = {
readyStepTimes: {},
@@ -588,7 +834,7 @@ const farmbotLayerLoadProgress: ThreeDLoadProgress = {
progress: 6 / THREE_D_LOAD_STEPS.length * 100,
complete: false,
markStep: ignoredLoadStep,
- isStepAllowed: () => true,
+ isStepAllowed: allowLoadStep,
};
interface FarmbotLoadInProps {
@@ -604,6 +850,10 @@ interface FarmbotLoadInProps {
onLoadInComplete(): void;
reveal: boolean;
toolSlots: SlotWithTool[] | undefined;
+ onSelectObject?: ThreeDObjectSelectionHandler;
+ onHoverObject?: ThreeDObjectHoverHandler;
+ onToolSlotHoverObject?: ThreeDObjectHoverHandler;
+ onHoverLabel?(selection: ThreeDObjectSelection | undefined): void;
}
const FarmbotLoadIn = (props: FarmbotLoadInProps) =>
@@ -626,6 +876,10 @@ const FarmbotLoadIn = (props: FarmbotLoadInProps) =>
trailReady={props.reveal && props.detailsReveal && props.loadInComplete}
activeFocus={props.activeFocus}
mountedToolName={props.mountedToolName}
+ onSelectObject={props.onSelectObject}
+ onHoverObject={props.onHoverObject}
+ onToolSlotHoverObject={props.onToolSlotHoverObject}
+ onHoverLabel={props.onHoverLabel}
toolSlots={props.toolSlots} />
;
@@ -672,6 +926,10 @@ const FarmbotLayer = (props: FarmbotLayerProps) => {
mountedToolName={props.mountedToolName}
onExitRest={markFarmbotHidden}
onLoadInComplete={markFarmbotLoaded}
+ onHoverObject={props.onHoverObject}
+ onToolSlotHoverObject={props.onToolSlotHoverObject}
+ onSelectObject={props.onSelectObject}
+ onHoverLabel={props.onHoverLabel}
reveal={layerReveal}
toolSlots={props.toolSlots} />
@@ -712,6 +970,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");
@@ -723,21 +1173,91 @@ export const GardenModel = (props: GardenModelProps) => {
baseConfig,
props.smoothConfigTransitions,
);
+ const configPosition = props.configPosition;
const cameraConfig = props.smoothConfigTransitions
? 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;
const Camera = config.perspective ? PerspectiveCamera : OrthographicCamera;
+ 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"]) =>
+ !objectSelectionMode || 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 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);
+ 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 setObjectHoverLabel = React.useCallback(
+ (selection: ThreeDObjectSelection | undefined) =>
+ setHoveredObjectLabel(selection),
+ []);
+ const handleCameraDragStart = React.useCallback(() => {
+ setSelectableObjectHoverCount(0);
+ setHoveredObjectLabel(undefined);
+ setCameraDragging(true);
+ }, []);
+ const handleCameraDragEnd = React.useCallback(() => {
+ setCameraDragging(false);
+ }, []);
+ const handleScenePointerLeave = React.useCallback(() => {
+ setSelectableObjectHoverCount(0);
+ setPlantIntersected(false);
+ setHoveredObjectLabel(undefined);
+ setGridHoverPosition(undefined);
+ }, []);
+ const handleScenePointerMove = React.useCallback((event: ThreeEvent) => {
+ if (config.eventDebug) {
+ console.log(event.intersections.map(x => x.object.name));
+ }
+ const nextPlantIntersected =
+ plantsSelectable && hasPlantIntersection(event);
+ setPlantIntersected(current =>
+ current == nextPlantIntersected ? current : nextPlantIntersected);
+ }, [config.eventDebug, plantsSelectable]);
const getI = React.useCallback((e: ThreeEvent) => {
if (e.buttons) { return -1; }
@@ -756,7 +1276,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 +1360,152 @@ 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 routeSelection = React.useMemo(
+ () => routeSelectionFromPath(routeLocation.pathname),
+ [routeLocation.pathname]);
+ 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);
+ }, []);
+ 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)) {
+ dispatch?.(clickMapPlant(uuid));
+ setLocationSelection(undefined);
+ setPopupSelection(undefined);
+ return true;
+ }
+ return false;
+ }
+ 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,
+ openMultiSelectPanel,
+ props.promo,
+ selectionLookup,
+ selectionPointType,
+ ]);
+ 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,
+ ) => {
+ setLocationSelection(selection);
+ }, []);
+ 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 (!activePopupSelection && !activeLocationSelection) { return; }
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key == "Escape") { closePopup(); }
+ };
+ window.addEventListener("keydown", closeOnEscape);
+ return () => window.removeEventListener("keydown", closeOnEscape);
+ }, [activeLocationSelection, activePopupSelection, closePopup]);
+
React.useEffect(() => {
if (!detailsReveal || detailsRevealNotified.current) { return; }
detailsRevealNotified.current = true;
@@ -879,7 +1534,78 @@ export const GardenModel = (props: GardenModelProps) => {
showSpread, showMoistureMap,
showMoistureReadings, topDownAtStart,
} = layerVisibility;
- const routeKey = `${location.pathname}?${location.search}`;
+ 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 = selectionPanelOpen
+ ? addPlantProps?.designer.selectedPoints
+ : groupSelectedPoints?.map(point => point.uuid);
+ if (!selectedPoints) { return undefined; }
+ const selections: ThreeDObjectSelection[] = [];
+ selectedPoints.forEach(uuid => {
+ const selection = selectionForUuid(selectionLookup, uuid);
+ if (selection) { selections.push(selection); }
+ });
+ return selections;
+ }, [
+ addPlantProps?.designer.selectedPoints,
+ groupSelectedPoints,
+ selectionLookup,
+ selectionPanelOpen,
+ ]);
+ 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 =
+ activePopupSelection || hoverSelection || routeSelection;
+ const gridHoverEnabled =
+ !props.promo
+ && 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,
@@ -998,6 +1724,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) {
@@ -1013,11 +1759,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}>
+
{
maxAzimuthAngle={topDownCameraAngle}
enableRotate={config.rotate}
enableZoom={config.zoom}
+ zoomToCursor={true}
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 +1797,7 @@ export const GardenModel = (props: GardenModelProps) => {
complete={detailsReveal} />
{
seasonResetKey={props.seasonResetKey}
showWeeds={showWeeds}
weeds={weeds}
+ plantsSelectable={plantsSelectable}
+ pointsSelectable={pointsSelectable}
+ weedsSelectable={weedsSelectable}
+ onSelectObject={onSelectObject}
+ onHoverObject={setSelectableObjectHover}
+ onHoverLabel={config.labelsOnHover ? setObjectHoverLabel : undefined}
+ onPlantHoverChange={setPlantIntersected}
showPoints={showPoints} />
+ {objectHoverLabelNode}
+ {gridHoverEnabled &&
+ }
+ {showGridHoverCrosshairs && activeGridHoverPosition &&
+ }
{
reveal={farmbotReveal}
showLoadProgress={props.showFarmbotLayerLoadProgress !== false}
toolSlots={props.toolSlots}
+ onSelectObject={slotsSelectable ? onSelectObject : undefined}
+ onHoverObject={objectSelectionMode ? undefined : setSelectableObjectHover}
+ onToolSlotHoverObject={objectSelectionMode && slotsSelectable
+ ? setSelectableObjectHover
+ : undefined}
+ onHoverLabel={config.labelsOnHover && slotsSelectable
+ ? setObjectHoverLabel
+ : undefined}
visible={farmbotVisible} />
+
{
- // 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; }
@@ -170,31 +112,37 @@ const GroupOrder = (props: GroupOrderProps) => {
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
-
{positions.map((p, i) =>
{
diff --git a/frontend/three_d_garden/index.tsx b/frontend/three_d_garden/index.tsx
index f50861120e..9338c9369f 100644
--- a/frontend/three_d_garden/index.tsx
+++ b/frontend/three_d_garden/index.tsx
@@ -8,39 +8,48 @@ import {
TaggedGenericPointer, TaggedImage, TaggedPoint, TaggedPointGroup,
TaggedSensor,
TaggedSensorReading,
+ TaggedDevice,
+ TaggedFbosConfig,
+ TaggedSequence,
+ TaggedTool,
TaggedWeedPointer,
} from "farmbot";
import { SlotWithTool } from "../resources/interfaces";
-import { NavigateFunction } from "react-router";
-import { Path } from "../internal_urls";
-import { t } from "../i18next_wrapper";
-import { Actions, Content, DeviceSetting } from "../constants";
-import { isMobile } from "../screen_size";
-import { BooleanSetting } from "../session_keys";
-import {
- GetWebAppConfigValue, setWebAppConfigValue,
-} from "../config_storage/actions";
-import { DesignerState } from "../farm_designer/interfaces";
+import { TaggedPlant } from "../farm_designer/map/interfaces";
import { ThreeDGardenPlant } from "./garden";
-import { DeviceAccountSettings } from "farmbot/dist/resources/api_resources";
-import { isTopDown } from "./helpers";
import { perfMark, usePerfRenderCount } from "../performance/perf";
-import { setPanelOpen3D } from "./panel_actions";
+import { BotPosition, BotState, UserEnv } from "../devices/interfaces";
+import { MovementState, TimeSettings } from "../interfaces";
export interface ThreeDGardenProps {
config: Config;
configPosition: PositionConfig;
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;
allPoints?: TaggedPoint[];
groups?: TaggedPointGroup[];
images?: TaggedImage[];
sensorReadings?: TaggedSensorReading[];
sensors?: TaggedSensor[];
+ env?: UserEnv;
+ set3DConfigValue?(key: keyof Config, value: string): void;
}
export const ThreeDGarden = React.memo((props: ThreeDGardenProps) => {
@@ -60,17 +69,32 @@ 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}
+ set3DConfigValue={props.set3DConfigValue}
addPlantProps={props.addPlantProps} />
@@ -78,123 +102,3 @@ export const ThreeDGarden = React.memo((props: ThreeDGardenProps) => {
});
ThreeDGarden.displayName = "ThreeDGarden";
-
-export interface ThreeDGardenToggleProps {
- navigate: NavigateFunction;
- dispatch: Function;
- designer: DesignerState;
- threeDGarden: boolean;
- device: DeviceAccountSettings;
- getConfigValue: GetWebAppConfigValue;
-}
-
-interface ThreeDControlsHelpProps {
- text: string;
- ariaLabel: string;
-}
-
-const ThreeDControlsHelp = (props: ThreeDControlsHelpProps) => {
- const [open, setOpen] = React.useState(false);
- const lines = props.text.trim().split("\n").map(line => line.trim());
- const title = lines[0].replace(/\*/g, "");
- const items = lines.slice(1).map(line => line.replace(/^-\s*/, ""));
- return
- setOpen(!open)} />
- {open &&
-
-
{title}
-
- {items.map(item => - {item}
)}
-
-
}
- ;
-};
-
-interface ThreeDLayerToggleProps {
- value: boolean;
- getConfigValue: GetWebAppConfigValue;
- onClick(): void;
-}
-
-const ThreeDLayerToggle = (props: ThreeDLayerToggleProps) => {
- const label = DeviceSetting.axisHeadingLabels;
- const classNames = [
- "fb-button",
- "fb-toggle-button",
- "fb-layer-toggle",
- props.value ? "green" : "red",
- props.value && props.getConfigValue(BooleanSetting.highlight_modified_settings)
- ? "modified"
- : "",
- ].join(" ");
- return ;
-};
-
-// eslint-disable-next-line complexity
-export const ThreeDGardenToggle = (props: ThreeDGardenToggleProps) => {
- const { navigate, dispatch, threeDGarden } = props;
- const topDown = isTopDown(props.designer, props.getConfigValue);
- const exaggeratedZ = props.designer.threeDExaggeratedZ;
- const description = isMobile()
- ? Content.SHOW_3D_VIEW_DESCRIPTION_MOBILE
- : Content.SHOW_3D_VIEW_DESCRIPTION_DESKTOP;
- return
- {threeDGarden &&
-
}
- {threeDGarden &&
-
}
- {threeDGarden &&
-
}
-
-
-
- {threeDGarden &&
- }
-
-
dispatch(setWebAppConfigValue(
- BooleanSetting.three_d_garden, !threeDGarden))} />
-
-
;
-};
diff --git a/frontend/three_d_garden/selection.tsx b/frontend/three_d_garden/selection.tsx
new file mode 100644
index 0000000000..4ef4943b97
--- /dev/null
+++ b/frontend/three_d_garden/selection.tsx
@@ -0,0 +1,10 @@
+export { ThreeDObjectSelectionLayer } from "./selection/layer";
+export {
+ createSelectionLookup,
+ hoverSelectionFromDesigner, pathForThreeDSelection,
+ pointTypeForSelectionKind,
+ routeLocationSelectionFromPath, routeSelectionFromPath,
+ selectionForUuid, selectionKindAllowed,
+ uuidForSelection
+} 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 0000000000..088e8eda2b
--- /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