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 && -
-
- - props.setOpen(!props.open)} /> -
-
; +
+ +
; 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 + +
+ + props.dispatch?.(setWebAppConfigValue( + BooleanSetting.show_camera_view_area, !props.config.cameraView))} + disabled={!props.dispatch} + title={`${t("toggle")} ${t(DeviceSetting.cameraView)}`} + customText={{ textFalse: t("off"), textTrue: t("on") }} /> +
+ ; +}; + +interface ElectronicsPopupButtonRowProps { + botOnline: boolean; + label: DeviceSetting; + description: string; + buttonText: string; + color: string; + action(): void; +} + +const ElectronicsPopupButtonRow = (props: ElectronicsPopupButtonRowProps) => +
+
+ + +
+ +
; + +const sequence2DropdownItem = ( + sequence: TaggedSequence, +): DropDownItem | undefined => { + const emptyScope = (sequence.body.args.locals.body || []).length == 0; + if (emptyScope && sequence.body.id) { + return { label: sequence.body.name, value: sequence.body.id }; + } +}; + +interface PopupBootSequenceSelectorProps { + dispatch: Function | undefined; + fbosConfig: TaggedFbosConfig | undefined; + sequences: TaggedSequence[]; +} + +const disabledBootSequenceRow = () => +
+ + +
; + +const PopupBootSequenceSelector = (props: PopupBootSequenceSelectorProps) => { + const { dispatch, fbosConfig, sequences } = props; + if (!dispatch || !fbosConfig) { return disabledBootSequenceRow(); } + const list = betterCompact(sequences.map(sequence2DropdownItem)); + const bootSequenceId = fbosConfig.body.boot_sequence_id; + const selectedSequence = sequences.filter(sequence => + sequence.body.id == bootSequenceId)[0]; + const selectedItem = selectedSequence + ? sequence2DropdownItem(selectedSequence) + : undefined; + const firmwareHardware: FirmwareHardware | undefined = + getFwHardwareValue(fbosConfig); + return
+ + { + const boot_sequence_id = selected.isNull + ? undefined + : selected.value as number; + dispatch(edit(fbosConfig, { boot_sequence_id })); + dispatch(save(fbosConfig.uuid)); + }} /> +
; +}; + +const ElectronicsPopupControls = (props: PopupControlProps) => { + if (props.object.kind != "electronics") { return undefined; } + return
+ + + +
; +}; + +export const ObjectPopupControls = (props: PopupControlProps) => { + switch (props.object.kind) { + case "plant": return ; + case "point": return ; + case "weed": return ; + case "slot": return ; + case "utm": return ; + case "electronics": return ; + case "camera": return ; + } +}; + +export const ObjectPopupHeaderColor = (props: PopupControlProps) => { + if (!props.dispatch) { return undefined; } + const point = (() => { + switch (props.object.kind) { + case "point": return props.object.point; + case "weed": return props.object.weed; + default: return undefined; + } + })(); + if (!point) { return undefined; } + const update = updatePoint(point, props.dispatch); + return ; +}; + +type DeletableResolvedThreeDObject = Exclude< + ResolvedThreeDObject, + { kind: "utm" } | { kind: "electronics" } | { kind: "camera" } +>; + +const objectUuid = (object: DeletableResolvedThreeDObject) => { + switch (object.kind) { + case "plant": return object.plant.uuid; + case "point": return object.point.uuid; + case "weed": return object.weed.uuid; + case "slot": return object.slot.toolSlot.uuid; + } +}; + +export const ObjectPopupDeleteButton = (props: PopupControlProps) => { + const object = props.object; + if (!props.dispatch + || object.kind == "utm" + || object.kind == "electronics" + || object.kind == "camera") { + return undefined; + } + return