接入ai
This commit is contained in:
@@ -6,3 +6,5 @@ backend/.pytest_cache/
|
||||
backend/output/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
agent-server/node_modules/
|
||||
llm.config.yaml
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
defaultProvider: deepseek
|
||||
defaultModel: deepseek:deepseek-chat
|
||||
|
||||
providers:
|
||||
openai:
|
||||
type: openai
|
||||
apiKeyEnv: OPENAI_API_KEY
|
||||
baseURL: https://api.openai.com/v1
|
||||
models:
|
||||
default: gpt-5
|
||||
|
||||
deepseek:
|
||||
type: openai-compatible
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
baseURL: https://api.deepseek.com
|
||||
models:
|
||||
default: deepseek-chat
|
||||
reasoner: deepseek-reasoner
|
||||
|
||||
uploads:
|
||||
maxImageMB: 20
|
||||
maxStepMB: 200
|
||||
maxOtherMB: 50
|
||||
allowed:
|
||||
- image/png
|
||||
- image/jpeg
|
||||
- image/webp
|
||||
- .step
|
||||
- .stp
|
||||
|
||||
cad:
|
||||
taskRoot: ./data/tasks
|
||||
viewer:
|
||||
mode: iframe
|
||||
allowedOrigins:
|
||||
- http://127.0.0.1
|
||||
- http://localhost
|
||||
Generated
+730
@@ -0,0 +1,730 @@
|
||||
{
|
||||
"name": "orbit-agent-server",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "orbit-agent-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "4.0.20",
|
||||
"@ai-sdk/openai-compatible": "3.0.14",
|
||||
"ai": "7.0.37",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/gateway": {
|
||||
"version": "4.0.28",
|
||||
"resolved": "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-4.0.28.tgz",
|
||||
"integrity": "sha512-ee9TsNO3mkgHDWmTmJ8Fvltr6PFh52zhrV2+FaKJY3F3iu7kWZi5tCRJCR1HVhhlpj6lHniUb28nLQdPHkA/1Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.3",
|
||||
"@ai-sdk/provider-utils": "5.0.12",
|
||||
"@vercel/oidc": "3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai": {
|
||||
"version": "4.0.20",
|
||||
"resolved": "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-4.0.20.tgz",
|
||||
"integrity": "sha512-/Hu+btLIO2zuYqCp//vBBAGdM/BeN4gds+NWrlv7Y9WrcTXwnEEwh6P3QZIWh2Tt1I1lswuDIpAJ4t/c/rws3Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.3",
|
||||
"@ai-sdk/provider-utils": "5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/openai-compatible": {
|
||||
"version": "3.0.14",
|
||||
"resolved": "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-3.0.14.tgz",
|
||||
"integrity": "sha512-nUl7dBHUDTqcnuWGnEbKMwDDqL94BxRfibLwYv8uWp+kZn8Zqdxy7tUBt4sv2hpHQi+S2P6bUyqW18xmbykErw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.3",
|
||||
"@ai-sdk/provider-utils": "5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-4.0.3.tgz",
|
||||
"integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/provider-utils": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-5.0.12.tgz",
|
||||
"integrity": "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "4.0.3",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@workflow/serde": "4.1.0",
|
||||
"eventsource-parser": "^3.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/oidc": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.2.0.tgz",
|
||||
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@workflow/serde": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@workflow/serde/-/serde-4.1.0.tgz",
|
||||
"integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ai": {
|
||||
"version": "7.0.37",
|
||||
"resolved": "https://registry.npmmirror.com/ai/-/ai-7.0.37.tgz",
|
||||
"integrity": "sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "4.0.28",
|
||||
"@ai-sdk/provider": "4.0.3",
|
||||
"@ai-sdk/provider-utils": "5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.76 || ^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
|
||||
"integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "orbit-agent-server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx src/server.ts",
|
||||
"test": "node --test --import tsx src/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "4.0.20",
|
||||
"@ai-sdk/openai-compatible": "3.0.14",
|
||||
"ai": "7.0.37",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseRequirementOverrides } from "./chat";
|
||||
|
||||
test("parses planetary reducer requirement overrides", () => {
|
||||
assert.deepEqual(
|
||||
parseRequirementOverrides("生成双级斜齿行星减速器,传动比 9,模数 0.5,3 个行星轮,外径不超过 90 mm"),
|
||||
{
|
||||
topology_family: "simple_2k_h_cascade",
|
||||
tooth_form: "helical",
|
||||
target_ratio: 9,
|
||||
module_mm: 0.5,
|
||||
planet_count: 3,
|
||||
max_outer_diameter_mm: 90,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("does not invent requirement overrides for ordinary text", () => {
|
||||
assert.deepEqual(parseRequirementOverrides("解释一下行星减速器的工作原理"), {});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { streamText, type ModelMessage } from "ai";
|
||||
import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "./config";
|
||||
|
||||
type ChatMessage = {
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
};
|
||||
|
||||
function messageContent(value: unknown) {
|
||||
if (!value || typeof value !== "object") return "";
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.content === "string") return record.content;
|
||||
if (typeof record.text === "string") return record.text;
|
||||
if (Array.isArray(record.parts)) {
|
||||
return record.parts.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const partRecord = part as Record<string, unknown>;
|
||||
if (partRecord.type === "text") return String(partRecord.text || "");
|
||||
if (typeof partRecord.text === "string") return partRecord.text;
|
||||
return "";
|
||||
}).join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizedMessages(messages: unknown[]): ChatMessage[] {
|
||||
return messages.map((message) => {
|
||||
const record = (message && typeof message === "object" ? message : {}) as Record<string, unknown>;
|
||||
const role: ChatMessage["role"] = record.role === "assistant" || record.role === "system" ? record.role : "user";
|
||||
return {
|
||||
role,
|
||||
content: messageContent(record),
|
||||
};
|
||||
}).filter((message) => message.content.trim() || message.role !== "user");
|
||||
}
|
||||
|
||||
function hostnameForUrl(value: string) {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isOfficialOpenAIBaseURL(value: string) {
|
||||
const hostname = hostnameForUrl(value);
|
||||
return hostname === "api.openai.com";
|
||||
}
|
||||
|
||||
function buildLanguageModel({
|
||||
apiKey,
|
||||
provider,
|
||||
providerConfig,
|
||||
model,
|
||||
}: {
|
||||
apiKey: string;
|
||||
provider: string;
|
||||
providerConfig: LlmConfig["providers"][string];
|
||||
model: string;
|
||||
}) {
|
||||
if (providerConfig.type === "openai" && isOfficialOpenAIBaseURL(providerConfig.baseURL)) {
|
||||
return createOpenAI({ apiKey, baseURL: providerConfig.baseURL })(model);
|
||||
}
|
||||
return createOpenAICompatible({
|
||||
name: provider,
|
||||
apiKey,
|
||||
baseURL: providerConfig.baseURL,
|
||||
})(model);
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown) {
|
||||
if (!error || typeof error !== "object") {
|
||||
return "";
|
||||
}
|
||||
const record = error as Record<string, unknown>;
|
||||
const statusCode = record.statusCode || record.status || record.responseStatus;
|
||||
const data = record.data || record.responseBody || record.body;
|
||||
const parts = [];
|
||||
if (statusCode) {
|
||||
parts.push(`status=${String(statusCode)}`);
|
||||
}
|
||||
if (typeof data === "string") {
|
||||
parts.push(data.slice(0, 500));
|
||||
} else if (data && typeof data === "object") {
|
||||
const maybeError = (data as { error?: { message?: unknown } }).error;
|
||||
const message = maybeError?.message || (data as { message?: unknown }).message;
|
||||
if (message) {
|
||||
parts.push(String(message));
|
||||
}
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function formatModelError({
|
||||
error,
|
||||
provider,
|
||||
model,
|
||||
providerConfig,
|
||||
}: {
|
||||
error: unknown;
|
||||
provider: string;
|
||||
model: string;
|
||||
providerConfig: LlmConfig["providers"][string];
|
||||
}) {
|
||||
const baseHost = hostnameForUrl(providerConfig.baseURL) || "unknown-host";
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
const detail = errorDetail(error);
|
||||
return [
|
||||
`provider: ${provider}`,
|
||||
`model: ${model}`,
|
||||
`baseURL host: ${baseHost}`,
|
||||
detail ? `${message} (${detail})` : message,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function compactJson(value: unknown, maxLength: number) {
|
||||
const source = JSON.stringify(value, null, 2);
|
||||
return source.length > maxLength ? `${source.slice(0, maxLength)}\n...<truncated>` : source;
|
||||
}
|
||||
|
||||
function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[]) {
|
||||
return [
|
||||
"You are the CAD Agent Studio assistant inside Orbit Joint Studio.",
|
||||
"Follow the CAD Agent Studio Agent Rules from cadSet.",
|
||||
"You may answer ordinary questions, clarify requirements, and explain engineering tradeoffs.",
|
||||
"Strict honesty rule: never claim that a CAD file, model, STEP, URDF, or preview artifact was generated unless a real server-side CAD generation tool has succeeded and returned artifact URLs.",
|
||||
"This Orbit integration exposes real server-side CAD generation, task storage, artifact download, and upload endpoints. The UI has a separate explicit Generate action; ordinary chat must remain ordinary chat.",
|
||||
"If the user asks you to generate or modify CAD inside chat, clarify the requirements and tell them to use the explicit Generate action when they are ready. Do not invent files, task ids, generated variants, or viewer state.",
|
||||
"Do not rely on hardcoded templates, canned examples, mock data, fake filenames, or imaginary viewer state.",
|
||||
"普通聊天、确认、说明、提问不得自动生成 CAD。只有真实 CAD 工具接通并成功写出 artifact 后,才允许说已经生成。",
|
||||
`Viewer context count: ${viewerContext.length}`,
|
||||
viewerContext.length ? compactJson(viewerContext, 10000) : "",
|
||||
`Attachment count: ${attachments.length}`,
|
||||
attachments.length ? compactJson(attachments, 10000) : "",
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function lastUserText(messages: unknown[]) {
|
||||
return [...messages].reverse().find((message) => {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
return (message as Record<string, unknown>).role === "user";
|
||||
}) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
function messageText(message: Record<string, unknown> | undefined) {
|
||||
if (!message) return "";
|
||||
if (typeof message.content === "string") return message.content;
|
||||
if (typeof message.text === "string") return message.text;
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasCadIntent(text: string) {
|
||||
return /(生成|创建|设计|建模|重建|导出|修改|调整|改成).*(减速器|行星|关节|模组|cad|step|urdf|mjcf|模数|传动比|行星轮|齿宽|压力角)|\b(generate|create|design|reconstruct|export|modify|edit)\b.*\b(cad|step|urdf|mjcf|reducer|joint|planetary|module|ratio)\b/i.test(text);
|
||||
}
|
||||
|
||||
export type RequirementOverrides = {
|
||||
topology_family?: "simple_2k_h" | "simple_2k_h_cascade" | "ferguson_wolfrom";
|
||||
target_ratio?: number;
|
||||
tooth_form?: "spur" | "helical";
|
||||
module_mm?: number;
|
||||
planet_count?: number;
|
||||
helix_angle_deg?: number;
|
||||
pressure_angle_deg?: number;
|
||||
face_width_mm?: number;
|
||||
backlash_mm?: number;
|
||||
ring_rim_thickness_mm?: number;
|
||||
max_outer_diameter_mm?: number;
|
||||
};
|
||||
|
||||
function firstNumber(text: string, patterns: RegExp[], integer = false) {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
const value = Number(match[1]);
|
||||
if (Number.isFinite(value)) return integer ? Math.trunc(value) : value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseRequirementOverrides(text: string): RequirementOverrides {
|
||||
const overrides: RequirementOverrides = {};
|
||||
if (/(双级|两级|二级|串联|cascade|two[- ]stage)/i.test(text)) {
|
||||
overrides.topology_family = "simple_2k_h_cascade";
|
||||
} else if (/(ferguson|wolfrom|沃尔夫罗姆|弗格森)/i.test(text)) {
|
||||
overrides.topology_family = "ferguson_wolfrom";
|
||||
} else if (/(单级|simple[_ -]?2k[_ -]?h)/i.test(text)) {
|
||||
overrides.topology_family = "simple_2k_h";
|
||||
}
|
||||
|
||||
const toothForm = text.match(/(斜齿|helical|直齿|spur)/i);
|
||||
if (toothForm) {
|
||||
overrides.tooth_form = /(斜齿|helical)/i.test(toothForm[1]) ? "helical" : "spur";
|
||||
}
|
||||
|
||||
const numericPatterns: Array<[
|
||||
keyof Omit<RequirementOverrides, "topology_family" | "tooth_form">,
|
||||
RegExp[],
|
||||
boolean?,
|
||||
]> = [
|
||||
["target_ratio", [
|
||||
/(?:传动比|减速比|速比|ratio)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i,
|
||||
/([0-9]+(?:\.[0-9]+)?)\s*(?::|:)\s*1\s*(?:传动比|减速比)?/i,
|
||||
]],
|
||||
["module_mm", [
|
||||
/(?:齿轮)?模数\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i,
|
||||
/module(?:_mm)?\s*(?:=|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i,
|
||||
]],
|
||||
["planet_count", [
|
||||
/(?:行星轮数量|行星轮数|行星轮)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+)/i,
|
||||
/([0-9]+)\s*(?:个|颗|只)?\s*行星轮/i,
|
||||
/planet_count\s*(?:=|:|to)?\s*([0-9]+)/i,
|
||||
], true],
|
||||
["helix_angle_deg", [/(?:螺旋角|helix_angle(?:_deg)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i]],
|
||||
["pressure_angle_deg", [/(?:压力角|pressure_angle(?:_deg)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i]],
|
||||
["face_width_mm", [/(?:齿宽|face_width(?:_mm)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i]],
|
||||
["backlash_mm", [/(?:齿侧间隙|侧隙|backlash(?:_mm)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i]],
|
||||
["ring_rim_thickness_mm", [/(?:齿圈轮缘厚度|轮缘厚度|ring_rim_thickness(?:_mm)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)/i]],
|
||||
["max_outer_diameter_mm", [
|
||||
/(?:最大外径|外径)\s*(?:小于|不超过|限制为|为|是|=|:|:|to|≤|<=)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:mm|毫米)?/i,
|
||||
/max_outer_diameter(?:_mm)?\s*(?:=|:|to|<=)?\s*([0-9]+(?:\.[0-9]+)?)/i,
|
||||
]],
|
||||
];
|
||||
for (const [name, patterns, integer] of numericPatterns) {
|
||||
const value = firstNumber(text, patterns, integer);
|
||||
if (value !== undefined) overrides[name] = value;
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
function backendOrigin() {
|
||||
return String(process.env.TOOLCHAIN_URL || "http://127.0.0.1:8000").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function backendJson(pathname: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${backendOrigin()}${pathname}`, init);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(String(payload?.error || `CAD backend request failed (${response.status})`));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function runExplicitCadAction({
|
||||
text,
|
||||
selectedTaskId,
|
||||
}: {
|
||||
text: string;
|
||||
selectedTaskId?: string;
|
||||
}) {
|
||||
if (/(重建|reconstruct).*(step|stp|上传)/i.test(text) || /(step|stp|上传).*(重建|reconstruct)/i.test(text)) {
|
||||
if (!selectedTaskId) throw new Error("请先选择包含 STEP 上传文件的任务。");
|
||||
return backendJson(`/api/tasks/${encodeURIComponent(selectedTaskId)}/reconstruct`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request: text }),
|
||||
});
|
||||
}
|
||||
const parameterPatterns: Array<[string, RegExp]> = [
|
||||
["module_mm", /(?:模数|module(?:_mm)?)\s*(?:改成|调整为|设为|=|to)?\s*([0-9]+(?:\.[0-9]+)?)/i],
|
||||
["target_ratio", /(?:传动比|减速比|ratio)\s*(?:改成|调整为|设为|=|to)?\s*([0-9]+(?:\.[0-9]+)?)/i],
|
||||
["planet_count", /(?:行星轮数量|行星轮数|planet_count)\s*(?:改成|调整为|设为|=|to)?\s*([0-9]+)/i],
|
||||
["face_width_mm", /(?:齿宽|face_width(?:_mm)?)\s*(?:改成|调整为|设为|=|to)?\s*([0-9]+(?:\.[0-9]+)?)/i],
|
||||
["pressure_angle_deg", /(?:压力角|pressure_angle(?:_deg)?)\s*(?:改成|调整为|设为|=|to)?\s*([0-9]+(?:\.[0-9]+)?)/i],
|
||||
];
|
||||
for (const [parameter, pattern] of parameterPatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (!match) continue;
|
||||
if (!selectedTaskId) throw new Error("请先选择一个已生成的 CAD 任务,再修改参数。");
|
||||
return backendJson(`/api/tasks/${encodeURIComponent(selectedTaskId)}/parameters`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parameter, value: Number(match[1]) }),
|
||||
});
|
||||
}
|
||||
if (/(导出|export).*(mjcf|mujoco)/i.test(text)) {
|
||||
if (!selectedTaskId) throw new Error("请先在右侧选择一个已生成任务,再导出 MJCF。");
|
||||
return backendJson(`/api/tasks/${encodeURIComponent(selectedTaskId)}/exports/mjcf`, { method: "POST" });
|
||||
}
|
||||
if (/(导出|export).*(urdf|机器人描述|机器人模型)/i.test(text)) {
|
||||
if (!selectedTaskId) throw new Error("请先在右侧选择一个已生成任务,再导出 URDF。");
|
||||
return backendJson(`/api/tasks/${encodeURIComponent(selectedTaskId)}/exports/urdf`, { method: "POST" });
|
||||
}
|
||||
const kind = /(关节|模组|joint|actuator)/i.test(text) ? "joint_module" : "reducer";
|
||||
return backendJson("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kind,
|
||||
request: text,
|
||||
mode: "auto",
|
||||
requirementOverrides: parseRequirementOverrides(text),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function textResponse(text: string) {
|
||||
return new Response(text, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function streamAgentText({
|
||||
messages,
|
||||
attachments,
|
||||
viewerContext,
|
||||
provider,
|
||||
model,
|
||||
selectedTaskId,
|
||||
}: {
|
||||
messages: unknown[];
|
||||
attachments: unknown[];
|
||||
viewerContext: unknown[];
|
||||
provider?: string;
|
||||
model?: string;
|
||||
selectedTaskId?: string;
|
||||
}) {
|
||||
const userText = messageText(lastUserText(messages));
|
||||
if (hasCadIntent(userText)) {
|
||||
const result = await runExplicitCadAction({ text: userText, selectedTaskId });
|
||||
const artifact = result?.robotExport?.packageUrl || result?.artifacts?.find((item: Record<string, unknown>) => item.role === "primary")?.url;
|
||||
const suffix = artifact ? `\n产物:${artifact}` : "";
|
||||
return textResponse(`${result?.summary || "CAD 工具链执行完成。"}\n任务 ID:${result?.taskId || selectedTaskId || "-"}${suffix}`);
|
||||
}
|
||||
const config = loadLlmConfig();
|
||||
const selected = selectedModelId(provider, model, config);
|
||||
const apiKey = resolveProviderApiKey(selected.providerConfig);
|
||||
if (!apiKey) {
|
||||
throw new Error(`Provider ${selected.provider} is not configured with an API key.`);
|
||||
}
|
||||
|
||||
const languageModel = buildLanguageModel({
|
||||
apiKey,
|
||||
provider: selected.provider,
|
||||
providerConfig: selected.providerConfig,
|
||||
model: selected.model,
|
||||
});
|
||||
|
||||
const modelMessages = normalizedMessages(messages).map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})) as ModelMessage[];
|
||||
|
||||
if (!modelMessages.some((message) => message.role === "user")) {
|
||||
throw new Error("A user message is required.");
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: languageModel,
|
||||
system: buildSystemPrompt(viewerContext, attachments),
|
||||
messages: modelMessages,
|
||||
onError: (error) => {
|
||||
throw new Error(formatModelError({
|
||||
error,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
providerConfig: selected.providerConfig,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse({
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { loadLlmConfig, publicConfig } from "./config";
|
||||
|
||||
test("loads llm config and redacts credentials in public config", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "orbit-agent-config-"));
|
||||
const configPath = path.join(dir, "llm.config.yaml");
|
||||
await fs.writeFile(configPath, [
|
||||
"defaultProvider: openai",
|
||||
"defaultModel: openai:gpt-5",
|
||||
"providers:",
|
||||
" openai:",
|
||||
" type: openai",
|
||||
" apiKey: secret-key",
|
||||
" baseURL: https://api.openai.com/v1",
|
||||
" models:",
|
||||
" default: gpt-5",
|
||||
"uploads:",
|
||||
" maxImageMB: 20",
|
||||
" maxStepMB: 200",
|
||||
" maxOtherMB: 50",
|
||||
" allowed:",
|
||||
" - image/png",
|
||||
"cad:",
|
||||
" taskRoot: ./data/tasks",
|
||||
" viewer:",
|
||||
" mode: iframe",
|
||||
" allowedOrigins:",
|
||||
" - http://127.0.0.1",
|
||||
].join("\n"));
|
||||
|
||||
const previous = process.env.CAD_AGENT_STUDIO_CONFIG;
|
||||
process.env.CAD_AGENT_STUDIO_CONFIG = configPath;
|
||||
try {
|
||||
const config = loadLlmConfig();
|
||||
assert.equal(config.defaultProvider, "openai");
|
||||
assert.equal(config.providers.openai.apiKey, "secret-key");
|
||||
|
||||
const exposed = publicConfig(config);
|
||||
assert.equal(exposed.providers.openai.configured, true);
|
||||
assert.equal(Object.hasOwn(exposed.providers.openai, "apiKey"), false);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.CAD_AGENT_STUDIO_CONFIG;
|
||||
} else {
|
||||
process.env.CAD_AGENT_STUDIO_CONFIG = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
const modelMapSchema = z.record(z.string(), z.string());
|
||||
|
||||
const providerSchema = z.object({
|
||||
type: z.enum(["openai", "openai-compatible"]),
|
||||
apiKey: z.string().min(1).optional(),
|
||||
apiKeyEnv: z.string().min(1).optional(),
|
||||
baseURL: z.string().min(1),
|
||||
models: modelMapSchema,
|
||||
}).refine((provider) => Boolean(provider.apiKey || provider.apiKeyEnv), {
|
||||
message: "provider requires apiKey or apiKeyEnv",
|
||||
});
|
||||
|
||||
const llmConfigSchema = z.object({
|
||||
defaultProvider: z.string().min(1),
|
||||
defaultModel: z.string().min(1),
|
||||
providers: z.record(z.string(), providerSchema),
|
||||
uploads: z.object({
|
||||
maxImageMB: z.number().positive(),
|
||||
maxStepMB: z.number().positive(),
|
||||
maxOtherMB: z.number().positive().default(50),
|
||||
allowed: z.array(z.string().min(1)),
|
||||
}),
|
||||
cad: z.object({
|
||||
taskRoot: z.string().min(1),
|
||||
viewer: z.object({
|
||||
mode: z.literal("iframe"),
|
||||
allowedOrigins: z.array(z.string().min(1)).default([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type LlmConfig = z.infer<typeof llmConfigSchema>;
|
||||
|
||||
export function repoRoot() {
|
||||
return path.resolve(process.env.ORBIT_PROJECT_ROOT || path.join(import.meta.dirname, "..", ".."));
|
||||
}
|
||||
|
||||
function appPath(...segments: string[]) {
|
||||
return path.join(path.resolve(import.meta.dirname, ".."), ...segments);
|
||||
}
|
||||
|
||||
function expandEnvTemplate(value: string) {
|
||||
return value.replace(/\$\{([A-Z0-9_]+)(?::-([^}]*))?\}/gi, (_match, name: string, fallback = "") => {
|
||||
const envValue = process.env[name];
|
||||
return envValue === undefined || envValue === "" ? fallback : envValue;
|
||||
});
|
||||
}
|
||||
|
||||
function expandConfig(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return expandEnvTemplate(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(expandConfig);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, nextValue]) => [key, expandConfig(nextValue)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function loadLlmConfig(): LlmConfig {
|
||||
const candidates = [
|
||||
process.env.CAD_AGENT_STUDIO_CONFIG
|
||||
? path.resolve(process.env.CAD_AGENT_STUDIO_CONFIG)
|
||||
: null,
|
||||
path.join(repoRoot(), "llm.config.yaml"),
|
||||
appPath("config", "llm.config.yaml"),
|
||||
appPath("config", "llm.config.yaml.example"),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
|
||||
const configPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!configPath) {
|
||||
throw new Error("No LLM configuration file was found.");
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(configPath, "utf8");
|
||||
return llmConfigSchema.parse(expandConfig(YAML.parse(source)));
|
||||
}
|
||||
|
||||
export function resolveProviderApiKey(provider: LlmConfig["providers"][string]) {
|
||||
const directKey = String(provider.apiKey || "").trim();
|
||||
if (directKey) {
|
||||
return directKey;
|
||||
}
|
||||
const envName = String(provider.apiKeyEnv || "").trim();
|
||||
if (!envName) {
|
||||
return "";
|
||||
}
|
||||
return process.env[envName] || "";
|
||||
}
|
||||
|
||||
export function selectedModelId(providerId: string | undefined, modelId: string | undefined, config = loadLlmConfig()) {
|
||||
const provider = providerId && config.providers[providerId] ? providerId : config.defaultProvider;
|
||||
const providerConfig = config.providers[provider];
|
||||
const candidate = String(modelId || "").trim();
|
||||
const model = candidate || providerConfig.models.default || config.defaultModel.split(":").pop() || "";
|
||||
return { provider, model, providerConfig };
|
||||
}
|
||||
|
||||
export function publicConfig(config = loadLlmConfig()) {
|
||||
return {
|
||||
defaultProvider: config.defaultProvider,
|
||||
defaultModel: config.defaultModel,
|
||||
providers: Object.fromEntries(Object.entries(config.providers).map(([id, provider]) => [
|
||||
id,
|
||||
{
|
||||
type: provider.type,
|
||||
baseURL: provider.baseURL,
|
||||
models: provider.models,
|
||||
configured: Boolean(resolveProviderApiKey(provider)),
|
||||
},
|
||||
])),
|
||||
uploads: config.uploads,
|
||||
cad: {
|
||||
viewer: config.cad.viewer,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import http from "node:http";
|
||||
import { publicConfig } from "./config";
|
||||
import { streamAgentText } from "./chat";
|
||||
|
||||
const host = process.env.AGENT_HOST || "127.0.0.1";
|
||||
const port = Number(process.env.AGENT_PORT || 8010);
|
||||
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(response: http.ServerResponse, status: number, payload: unknown) {
|
||||
const body = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
response.writeHead(status, {
|
||||
...corsHeaders(),
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": body.byteLength,
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function readJson(request: http.IncomingMessage) {
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk) => {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
if (Buffer.concat(chunks).byteLength > 2 * 1024 * 1024) {
|
||||
reject(new Error("Request body is too large."));
|
||||
request.destroy();
|
||||
}
|
||||
});
|
||||
request.on("end", () => {
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
if (!text.trim()) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(text));
|
||||
} catch {
|
||||
reject(new Error("Request body must be valid JSON."));
|
||||
}
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function writeWebResponse(nodeResponse: http.ServerResponse, webResponse: Response) {
|
||||
const headers: Record<string, string> = {
|
||||
...corsHeaders(),
|
||||
};
|
||||
webResponse.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
nodeResponse.writeHead(webResponse.status, headers);
|
||||
if (!webResponse.body) {
|
||||
nodeResponse.end();
|
||||
return;
|
||||
}
|
||||
const reader = webResponse.body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
nodeResponse.write(Buffer.from(value));
|
||||
}
|
||||
nodeResponse.end();
|
||||
} catch (error) {
|
||||
nodeResponse.destroy(error instanceof Error ? error : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url || "/", `http://${request.headers.host || `${host}:${port}`}`);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
response.writeHead(204, corsHeaders());
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/health") {
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/config") {
|
||||
try {
|
||||
sendJson(response, 200, publicConfig());
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
error: error instanceof Error ? error.message : "Failed to load agent config.",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/api/chat") {
|
||||
try {
|
||||
const body = await readJson(request);
|
||||
const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
|
||||
const streamResponse = await streamAgentText({
|
||||
messages: Array.isArray(record.messages) ? record.messages : [],
|
||||
attachments: Array.isArray(record.attachments) ? record.attachments : [],
|
||||
viewerContext: Array.isArray(record.viewerContext) ? record.viewerContext : [],
|
||||
provider: typeof record.provider === "string" ? record.provider : undefined,
|
||||
model: typeof record.model === "string" ? record.model : undefined,
|
||||
selectedTaskId: typeof record.selectedTaskId === "string" ? record.selectedTaskId : undefined,
|
||||
});
|
||||
await writeWebResponse(response, streamResponse);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
error: error instanceof Error ? error.message : "Failed to stream chat response.",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: "not_found" });
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`agent server: http://${host}:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ def scale_from_motor(
|
||||
reducer_axial_allowance_mm: float = 42.0,
|
||||
min_radial_scale: float = 0.45,
|
||||
max_radial_scale: float = 1.50,
|
||||
min_axial_scale: float = 0.55,
|
||||
min_axial_scale: float = 0.42,
|
||||
max_axial_scale: float = 1.80,
|
||||
) -> HousingScale:
|
||||
target_inner_radius = motor_radial_diameter_mm / 2.0 + radial_clearance_mm
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
import cadquery as cq
|
||||
|
||||
|
||||
AXIS = cq.Vector(0.0, 1.0, 0.0)
|
||||
|
||||
|
||||
def _point_on_xz(radius: float, angle_degrees: float, y: float) -> cq.Vector:
|
||||
angle = math.radians(angle_degrees)
|
||||
return cq.Vector(radius * math.cos(angle), y, radius * math.sin(angle))
|
||||
|
||||
|
||||
def _cylinder(radius: float, y0: float, length: float, x: float = 0.0, z: float = 0.0):
|
||||
return cq.Solid.makeCylinder(radius, length, cq.Vector(x, y0, z), AXIS)
|
||||
|
||||
|
||||
def _ring(outer_radius: float, inner_radius: float, y0: float, length: float):
|
||||
return _cylinder(outer_radius, y0, length).cut(_cylinder(inner_radius, y0, length))
|
||||
|
||||
|
||||
def _conical_ring(
|
||||
outer_start: float,
|
||||
outer_end: float,
|
||||
inner_start: float,
|
||||
inner_end: float,
|
||||
y0: float,
|
||||
length: float,
|
||||
):
|
||||
outer = cq.Solid.makeCone(outer_start, outer_end, length, cq.Vector(0.0, y0, 0.0), AXIS)
|
||||
inner = cq.Solid.makeCone(inner_start, inner_end, length, cq.Vector(0.0, y0, 0.0), AXIS)
|
||||
return outer.cut(inner)
|
||||
|
||||
|
||||
def _sector_cut(angle_center: float, angle_width: float, y0: float, length: float, radius: float = 100.0):
|
||||
start = math.radians(angle_center - angle_width / 2.0)
|
||||
end = math.radians(angle_center + angle_width / 2.0)
|
||||
points = [
|
||||
(0.0, 0.0),
|
||||
(radius * math.cos(start), radius * math.sin(start)),
|
||||
(radius * math.cos(end), radius * math.sin(end)),
|
||||
]
|
||||
plane = cq.Plane(origin=(0.0, y0, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, 1.0, 0.0))
|
||||
return cq.Workplane(plane).polyline(points).close().extrude(length).val()
|
||||
|
||||
|
||||
def _annular_sector_cut(
|
||||
angle_center: float,
|
||||
angle_width: float,
|
||||
inner_radius: float,
|
||||
outer_radius: float,
|
||||
y0: float,
|
||||
length: float,
|
||||
profile_fillet_radius: float = 0.0,
|
||||
axial_fillet_radius: float = 0.0,
|
||||
):
|
||||
start = math.radians(angle_center - angle_width / 2.0)
|
||||
middle = math.radians(angle_center)
|
||||
end = math.radians(angle_center + angle_width / 2.0)
|
||||
inner_start = (inner_radius * math.cos(start), inner_radius * math.sin(start))
|
||||
inner_middle = (inner_radius * math.cos(middle), inner_radius * math.sin(middle))
|
||||
inner_end = (inner_radius * math.cos(end), inner_radius * math.sin(end))
|
||||
outer_start = (outer_radius * math.cos(start), outer_radius * math.sin(start))
|
||||
outer_middle = (outer_radius * math.cos(middle), outer_radius * math.sin(middle))
|
||||
outer_end = (outer_radius * math.cos(end), outer_radius * math.sin(end))
|
||||
plane = cq.Plane(origin=(0.0, y0, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, 1.0, 0.0))
|
||||
cutter = (
|
||||
cq.Workplane(plane)
|
||||
.moveTo(*inner_start)
|
||||
.lineTo(*outer_start)
|
||||
.threePointArc(outer_middle, outer_end)
|
||||
.lineTo(*inner_end)
|
||||
.threePointArc(inner_middle, inner_start)
|
||||
.close()
|
||||
.extrude(length)
|
||||
)
|
||||
if profile_fillet_radius > 0.0:
|
||||
# Round the corners of the annular profile (fillet axes parallel to Y).
|
||||
cutter = cutter.edges("|Y").fillet(profile_fillet_radius)
|
||||
if axial_fillet_radius > 0.0:
|
||||
# Round only the straight radial edges at the two axial ends. This
|
||||
# produces the R10/R5/R2 window run-outs present in the source STEP.
|
||||
end_edges = [
|
||||
edge
|
||||
for edge in cutter.edges("%LINE").vals()
|
||||
if abs(edge.Center().y - y0) < 1.0e-6
|
||||
or abs(edge.Center().y - (y0 + length)) < 1.0e-6
|
||||
]
|
||||
cutter = cutter.newObject(end_edges).fillet(axial_fillet_radius)
|
||||
return cutter.val()
|
||||
|
||||
|
||||
def _radial_bar(angle_degrees: float, y0: float, length: float, inner_radius: float, outer_radius: float, width: float):
|
||||
radial_length = outer_radius - inner_radius
|
||||
bar = (
|
||||
cq.Workplane("XY")
|
||||
.box(radial_length, length, width)
|
||||
.translate(((inner_radius + outer_radius) / 2.0, y0 + length / 2.0, 0.0))
|
||||
)
|
||||
return bar.rotate((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), -angle_degrees).val()
|
||||
|
||||
|
||||
def _rounded_radial_slot(angle_degrees: float, y0: float, length: float):
|
||||
"""A 40 mm wide radial window with R2 corners in its axial section."""
|
||||
plane = cq.Plane(
|
||||
origin=(40.0, y0 + length / 2.0, 0.0),
|
||||
xDir=(0.0, 1.0, 0.0),
|
||||
normal=(1.0, 0.0, 0.0),
|
||||
)
|
||||
slot = (
|
||||
cq.Workplane(plane)
|
||||
.rect(length - 4.0, 36.0)
|
||||
.offset2D(2.0)
|
||||
.extrude(42.0)
|
||||
.rotate((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), -angle_degrees)
|
||||
)
|
||||
return slot.val()
|
||||
|
||||
|
||||
def _support_blend(angle_degrees: float, mirrored: bool):
|
||||
"""One of the six R2 blends between the R64 and R29.2 support arcs."""
|
||||
sharp = (-62.80715975198, 12.298808230444)
|
||||
inner_tangent = (-62.1855700706093, 15.131254904777)
|
||||
lobe_tangent = (-63.6707499640005, 16.8512614004542)
|
||||
inner_center = (0.0, 0.0)
|
||||
blend_center = (-61.6824120316291, 17.0669284278203)
|
||||
lobe_center = (-34.6410161514, 20.0)
|
||||
|
||||
def transform(point):
|
||||
x, z = point
|
||||
if mirrored:
|
||||
axis = math.radians(150.0)
|
||||
ux, uz = math.cos(axis), math.sin(axis)
|
||||
projection = x * ux + z * uz
|
||||
x, z = 2.0 * projection * ux - x, 2.0 * projection * uz - z
|
||||
angle = math.radians(angle_degrees)
|
||||
return (
|
||||
x * math.cos(angle) - z * math.sin(angle),
|
||||
x * math.sin(angle) + z * math.cos(angle),
|
||||
)
|
||||
|
||||
def arc_middle(center, start, end, radius):
|
||||
ux = (start[0] - center[0]) / radius + (end[0] - center[0]) / radius
|
||||
uz = (start[1] - center[1]) / radius + (end[1] - center[1]) / radius
|
||||
scale = radius / math.hypot(ux, uz)
|
||||
return center[0] + ux * scale, center[1] + uz * scale
|
||||
|
||||
points = [transform(point) for point in (sharp, inner_tangent, lobe_tangent)]
|
||||
centers = [transform(point) for point in (inner_center, blend_center, lobe_center)]
|
||||
plane = cq.Plane(origin=(0.0, 90.2, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, -1.0, 0.0))
|
||||
return (
|
||||
cq.Workplane(plane)
|
||||
.moveTo(*points[0])
|
||||
.threePointArc(arc_middle(centers[0], points[0], points[1], 64.0), points[1])
|
||||
.threePointArc(arc_middle(centers[1], points[1], points[2], 2.0), points[2])
|
||||
.threePointArc(arc_middle(centers[2], points[2], points[0], 29.2), points[0])
|
||||
.close()
|
||||
.extrude(10.0)
|
||||
.val()
|
||||
)
|
||||
|
||||
|
||||
def build():
|
||||
# Front cylindrical shell and its smaller continuation.
|
||||
part = cq.Workplane(obj=_ring(79.4, 74.4, -10.0, 22.0))
|
||||
part = part.union(_ring(78.4, 74.4, 12.0, 43.2))
|
||||
|
||||
# Nine radial R2 holes through the front lip, spaced every 40 degrees.
|
||||
for angle in (90.0, 50.0, 10.0, -30.0, -70.0, -110.0, -150.0, -190.0, -230.0):
|
||||
start = _point_on_xz(72.0, angle, -5.0)
|
||||
direction = _point_on_xz(1.0, angle, 0.0)
|
||||
direction = cq.Vector(direction.x, 0.0, direction.z)
|
||||
part = part.cut(cq.Solid.makeCylinder(2.0, 10.0, start, direction))
|
||||
|
||||
# Six long ventilation windows, 40-degree openings with 20-degree ribs.
|
||||
for center in (-110.0, -50.0, 10.0, 70.0, 130.0, 190.0):
|
||||
part = part.cut(
|
||||
_annular_sector_cut(center, 40.0, 70.0, 82.0, 12.0, 40.0, axial_fillet_radius=10.0)
|
||||
)
|
||||
|
||||
# Taper from the large front shell to the narrower middle shell.
|
||||
part = part.union(_conical_ring(78.4, 73.2, 74.4, 69.2, 55.2, 20.0))
|
||||
part = part.union(_ring(73.2, 60.0, 75.2, 5.0))
|
||||
|
||||
# Three small axial holes in the first shoulder ring.
|
||||
for angle in (30.0, 150.0, 270.0):
|
||||
p = _point_on_xz(64.0, angle, 75.2)
|
||||
part = part.cut(cq.Solid.makeCylinder(2.0, 5.0, p, AXIS))
|
||||
|
||||
# Three-lobed support plate: a disk with three large circular openings.
|
||||
support = cq.Workplane(obj=_ring(73.2, 64.0, 80.2, 10.0))
|
||||
for x, z in ((34.6410161514, 20.0), (-34.6410161514, 20.0), (0.0, -40.0)):
|
||||
support = support.cut(_cylinder(29.2, 80.2, 10.0, x, z))
|
||||
part = part.union(support)
|
||||
|
||||
# Thin middle shell and its second, angularly shifted six-window pattern.
|
||||
part = part.union(_ring(73.2, 69.2, 90.2, 30.0))
|
||||
for center in (-150.0, -90.0, -30.0, 30.0, 90.0, 150.0):
|
||||
part = part.cut(
|
||||
_annular_sector_cut(center, 40.0, 65.0, 76.0, 90.2, 20.0, axial_fillet_radius=5.0)
|
||||
)
|
||||
|
||||
# Rear shell is one continuous 2:7 taper. Its inner cavity changes from
|
||||
# a steep R69.2 -> R63.6 cone to the matching shallow taper at Y=125.8.
|
||||
taper_outer = cq.Solid.makeCone(
|
||||
73.2, 64.91428571428571, 29.0, cq.Vector(0.0, 120.2, 0.0), AXIS
|
||||
)
|
||||
taper_inner_a = cq.Solid.makeCone(69.2, 63.6, 5.6, cq.Vector(0.0, 120.2, 0.0), AXIS)
|
||||
taper_inner_b = cq.Solid.makeCone(63.6, 59.2, 15.4, cq.Vector(0.0, 125.8, 0.0), AXIS)
|
||||
part = part.union(taper_outer.cut(taper_inner_a).cut(taper_inner_b))
|
||||
hub = cq.Workplane(obj=_ring(38.0, 26.0, 129.2, 4.0)).union(
|
||||
_ring(38.0, 30.0, 133.2, 16.0)
|
||||
)
|
||||
part = part.union(hub)
|
||||
|
||||
# Stop 0.0001 mm before the coincident rear-plate face so OpenCascade
|
||||
# keeps the six ribs in one valid solid.
|
||||
for center in (30.0, 90.0, 150.0, 210.0, 270.0, 330.0):
|
||||
part = part.cut(_rounded_radial_slot(center, 131.2, 9.9999))
|
||||
|
||||
# Central bore through the solid rear plate, then its six fan windows.
|
||||
part = part.cut(_cylinder(30.0, 141.2, 8.0))
|
||||
for center in (45.0, 105.0, 165.0, 225.0, 285.0, 345.0):
|
||||
cutter = _annular_sector_cut(
|
||||
center, 30.0, 38.0, 59.2, 141.2, 8.0, profile_fillet_radius=2.0
|
||||
)
|
||||
part = part.cut(cutter)
|
||||
|
||||
# These holes pass through both the end plate and the previously added hub.
|
||||
for angle in (-90.0, -30.0, 30.0, 90.0, 150.0, 210.0):
|
||||
p = _point_on_xz(34.0, angle, 141.2)
|
||||
part = part.cut(cq.Solid.makeCylinder(1.81198, 8.0, p, AXIS))
|
||||
|
||||
# Add the small support blends last so they cannot interfere with fusing
|
||||
# the face-connected rear shell and end plate.
|
||||
for mirrored in (False, True):
|
||||
for angle in (0.0, 120.0, 240.0):
|
||||
part = part.union(_support_blend(angle, mirrored))
|
||||
|
||||
# Remove only the six 0.0001 mm run-out residuals after the complete part
|
||||
# is connected. This reaches the exact Y=141.2 boundary without causing
|
||||
# the earlier coincident-face split into six solids.
|
||||
for center in (30.0, 90.0, 150.0, 210.0, 270.0, 330.0):
|
||||
full_slot = _rounded_radial_slot(center, 131.2, 10.0)
|
||||
stable_slot = _rounded_radial_slot(center, 131.2, 9.9999)
|
||||
part = part.cut(full_slot.cut(stable_slot))
|
||||
|
||||
return part
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
output = Path(__file__).resolve().with_name("外壳框架.step")
|
||||
cq.exporters.export(build(), str(output))
|
||||
print(output)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platform_id": "actuator_v2_fixed_platform",
|
||||
"motor_step": "../motors/3500_motor_part2_standard.step",
|
||||
"housing_step": "../housings/actuator_v2_knee_abd_main_case_2.step",
|
||||
"reference_assembly_step": "../references/actuator_v2_knee_abd_reference.step"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"module_type": "planetary_joint_module",
|
||||
"motor_step": "../../assets/motors/3500_motor_part2.step",
|
||||
"housing_step": "../../assets/housings/generated_housing_baseline.step",
|
||||
"reducer_requirement": "../reducers/simple_2k_h/simple_2kh_industrial_ratio_7p5_spur.json",
|
||||
"platform_profile": "../../assets/platforms/actuator_v2_fixed_platform.json",
|
||||
"reducer_requirement": "../reducers/simple_2k_h/simple_2kh_industrial_ratio_7p5_spur_compact.json",
|
||||
"placement_policy": "infer_then_write_editable_json",
|
||||
"motor_to_reducer_relation": "motor_output_to_sun_keyed_input",
|
||||
"housing_relation": "ring_fixed_to_housing",
|
||||
"joint_output": "carrier_output_shaft",
|
||||
"output_extension_mm": 8.0,
|
||||
"coupling_clearance_mm": 1.0,
|
||||
"motor_placement_policy": "external_flush_input_end",
|
||||
"output_extension_mm": 2.0,
|
||||
"coupling_clearance_mm": 0.5,
|
||||
"input_shaft_insertion_depth_mm": 4.0,
|
||||
"motor_output_side": "axis_max",
|
||||
"axis_angle_tolerance_deg": 0.5,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"module_type": "planetary_joint_module",
|
||||
"motor_step": "../../assets/motors/3500_motor_part2.step",
|
||||
"housing_step": "../../assets/housings/generated_housing_baseline.step",
|
||||
"reducer_requirement": "../reducers/simple_2k_h_cascade/simple_2kh_cascade_ratio_9_spur.json",
|
||||
"platform_profile": "../../assets/platforms/actuator_v2_fixed_platform.json",
|
||||
"reducer_requirement": "../reducers/simple_2k_h_cascade/simple_2kh_cascade_ratio_9_spur_compact.json",
|
||||
"placement_policy": "infer_then_write_editable_json",
|
||||
"motor_to_reducer_relation": "motor_output_to_s1_sun_input",
|
||||
"housing_relation": "stage_rings_fixed_to_housing",
|
||||
"joint_output": "s2_carrier_output",
|
||||
"motor_placement_policy": "external_flush_input_end",
|
||||
"output_extension_mm": 2.0,
|
||||
"coupling_clearance_mm": 0.5,
|
||||
"input_shaft_insertion_depth_mm": 1.0,
|
||||
"input_shaft_insertion_depth_mm": 4.0,
|
||||
"motor_output_side": "axis_max",
|
||||
"axis_angle_tolerance_deg": 0.5,
|
||||
"axis_distance_tolerance_mm": 0.25,
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"target_ratio": 7.5,
|
||||
"ratio_tolerance": 0.01,
|
||||
"topology_family": "simple_2k_h",
|
||||
"detail_level": "industrial_core",
|
||||
"input": "sun",
|
||||
"fixed": "ring",
|
||||
"output": "carrier",
|
||||
"tooth_form": "spur",
|
||||
"planet_count_candidates": [3],
|
||||
"module_candidates_mm": [0.3],
|
||||
"pressure_angle_deg": 20.0,
|
||||
"face_width_mm": 6.0,
|
||||
"backlash_mm": 0.03,
|
||||
"addendum_coeff": 1.0,
|
||||
"clearance_coeff": 0.25,
|
||||
"ring_rim_thickness_mm": 1.5,
|
||||
"constraints": {
|
||||
"max_outer_diameter_mm": 60.0,
|
||||
"min_planet_neighbor_clearance_mm": 0.3,
|
||||
"min_sun_teeth": 20,
|
||||
"max_sun_teeth": 20,
|
||||
"min_planet_teeth": 55,
|
||||
"max_planet_teeth": 55
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"target_ratio": 9.0,
|
||||
"ratio_tolerance": 0.000001,
|
||||
"topology_family": "simple_2k_h_cascade",
|
||||
"tooth_form": "spur",
|
||||
"planet_count_candidates": [3],
|
||||
"module_candidates_mm": [0.5],
|
||||
"pressure_angle_deg": 20.0,
|
||||
"face_width_mm": 6.0,
|
||||
"backlash_mm": 0.03,
|
||||
"addendum_coeff": 1.0,
|
||||
"clearance_coeff": 0.25,
|
||||
"ring_rim_thickness_mm": 2.5,
|
||||
"constraints": {
|
||||
"max_outer_diameter_mm": 100.0,
|
||||
"min_planet_neighbor_clearance_mm": 0.3,
|
||||
"min_sun_teeth": 36,
|
||||
"max_sun_teeth": 36,
|
||||
"min_planet_teeth": 18,
|
||||
"max_planet_teeth": 18
|
||||
},
|
||||
"stages": [
|
||||
{
|
||||
"stage_id": "s1",
|
||||
"input": "sun",
|
||||
"fixed": "ring",
|
||||
"output": "carrier",
|
||||
"planet_count_candidates": [3],
|
||||
"module_candidates_mm": [0.5]
|
||||
},
|
||||
{
|
||||
"stage_id": "s2",
|
||||
"input": "sun",
|
||||
"fixed": "ring",
|
||||
"output": "carrier",
|
||||
"planet_count_candidates": [3],
|
||||
"module_candidates_mm": [0.5]
|
||||
}
|
||||
],
|
||||
"cascade": {
|
||||
"coupling_policy": "stage_output_to_next_stage_input",
|
||||
"stage_gap_mm": 2.0
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
from ..models import ReducerInstance
|
||||
@@ -13,6 +14,21 @@ def cref(scad: Any, *, component_id: str, connector_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _assembly_api(scad: Any, name: str):
|
||||
if hasattr(scad, name):
|
||||
return getattr(scad, name)
|
||||
operations = getattr(scad, "operations", None)
|
||||
if operations is not None and hasattr(operations, name):
|
||||
return getattr(operations, name)
|
||||
try:
|
||||
operations = importlib.import_module("simplecadapi.operations")
|
||||
except Exception: # noqa: BLE001
|
||||
operations = None
|
||||
if operations is not None and hasattr(operations, name):
|
||||
return getattr(operations, name)
|
||||
return None
|
||||
|
||||
|
||||
def add_forwarded_connector(
|
||||
scad: Any,
|
||||
*,
|
||||
@@ -23,7 +39,10 @@ def add_forwarded_connector(
|
||||
name: str | None = None,
|
||||
offset: Any | None = None,
|
||||
):
|
||||
return scad.forward_connector_rassembly(
|
||||
forward_connector = _assembly_api(scad, "forward_connector_rassembly")
|
||||
if forward_connector is None:
|
||||
raise RuntimeError("simplecadapi_missing_forward_connector_rassembly")
|
||||
return forward_connector(
|
||||
assembly=assembly,
|
||||
connector_id=connector_id,
|
||||
source_component_id=source_component_id,
|
||||
@@ -37,7 +56,6 @@ def require_assembly_api(scad: Any) -> None:
|
||||
required = [
|
||||
"add_component_rassembly",
|
||||
"add_connector_rassembly",
|
||||
"forward_connector_rassembly",
|
||||
"add_fixed_constraint_rassembly",
|
||||
"add_revolute_constraint_rassembly",
|
||||
"add_gear_constraint_rassembly",
|
||||
@@ -45,7 +63,9 @@ def require_assembly_api(scad: Any) -> None:
|
||||
"ground_component_rassembly",
|
||||
"solve_assembly_constraints_rassembly",
|
||||
]
|
||||
missing = [name for name in required if not hasattr(scad, name)]
|
||||
missing = [name for name in required if _assembly_api(scad, name) is None]
|
||||
if _assembly_api(scad, "forward_connector_rassembly") is None:
|
||||
missing.append("forward_connector_rassembly")
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"simplecadapi_missing_required_assembly_api: " + ", ".join(missing)
|
||||
|
||||
@@ -837,7 +837,7 @@ def _stage_parts(
|
||||
instance=instance,
|
||||
placement_plan=placement_plan,
|
||||
component_id="cascade_input_shaft",
|
||||
radius_mm=max(0.55, min(1.7, s1_bore_radius * 0.72)),
|
||||
radius_mm=max(0.55, min(1.70, s1_bore_radius * 0.72)),
|
||||
material=materials["carrier"],
|
||||
),
|
||||
"cascade_output_shaft": make_cascade_port_shaft(
|
||||
|
||||
@@ -61,13 +61,20 @@ def _clear_generation_dir(out_dir: Path) -> None:
|
||||
|
||||
def import_simplecadapi() -> Any:
|
||||
guard_no_gear_test()
|
||||
try:
|
||||
scad = importlib.import_module("simplecadapi")
|
||||
except ModuleNotFoundError as exc:
|
||||
scad = None
|
||||
import_errors: list[str] = []
|
||||
for module_name in ["src.simplecadapi", "simplecadapi"]:
|
||||
try:
|
||||
scad = importlib.import_module(module_name)
|
||||
break
|
||||
except ModuleNotFoundError as exc:
|
||||
import_errors.append(f"{module_name}: {exc}")
|
||||
if scad is None:
|
||||
raise CadGenerationError(
|
||||
"simplecadapi_unavailable: install simplecadapi or run with a PYTHONPATH "
|
||||
"pointing to SimpleCADAPI/src"
|
||||
) from exc
|
||||
+ (f" ({'; '.join(import_errors)})" if import_errors else "")
|
||||
)
|
||||
for name in ["std", "GraphSession", "export_step", "export_model_json", "export_session_json"]:
|
||||
if not hasattr(scad, name):
|
||||
raise CadGenerationError(f"simplecadapi_missing_required_api: {name}")
|
||||
|
||||
@@ -693,7 +693,7 @@ def make_industrial_parts(scad: Any, *, instance: ReducerInstance) -> dict[str,
|
||||
materials = make_materials(scad)
|
||||
shaft_material = materials["gear"]
|
||||
bearing_material = materials["ring"]
|
||||
output_shaft_extension = max(12.0, p.face_width_mm * 1.50)
|
||||
output_shaft_extension = max(24.0, p.face_width_mm * 2.50)
|
||||
parts: dict[str, Any] = {
|
||||
"sun": make_industrial_sun(scad, instance=instance, params=params, material=materials["gear"]),
|
||||
"planet": make_planet(scad, instance=instance, material=materials["gear"]),
|
||||
|
||||
+7
-5
@@ -18,7 +18,7 @@ from .cad.parametric_parts import (
|
||||
)
|
||||
from .cad.simplecad_generator import CadGenerationError, generate_simplecad, guard_no_gear_test
|
||||
from .kinematics import motion_samples, solve_instance
|
||||
from .joint_module.auto_design import run_auto_joint_design
|
||||
from .joint_module.auto_design import DEFAULT_PLATFORM_PROFILE, run_auto_joint_design
|
||||
from .joint_module.generator import run_joint_module
|
||||
from .joint_module.urdf_exporter import export_urdf
|
||||
from .models import (
|
||||
@@ -284,9 +284,10 @@ def cmd_auto_joint_run(args: argparse.Namespace) -> int:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
manifest = run_auto_joint_design(
|
||||
motor_step=Path(args.motor_step),
|
||||
housing_source=Path(args.housing_python),
|
||||
out_dir=out_dir,
|
||||
platform_profile=Path(args.platform_profile),
|
||||
motor_step=Path(args.motor_step) if args.motor_step else None,
|
||||
housing_source=Path(args.housing_python) if args.housing_python else None,
|
||||
tooth_form=args.tooth_form,
|
||||
radial_clearance_mm=float(args.radial_clearance_mm),
|
||||
reducer_axial_allowance_mm=float(args.reducer_axial_allowance_mm),
|
||||
@@ -454,8 +455,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
joint_run.set_defaults(func=cmd_joint_run)
|
||||
|
||||
auto_joint_run = sub.add_parser("auto-joint-run")
|
||||
auto_joint_run.add_argument("--motor-step", required=True)
|
||||
auto_joint_run.add_argument("--housing-python", required=True)
|
||||
auto_joint_run.add_argument("--platform-profile", default=str(DEFAULT_PLATFORM_PROFILE))
|
||||
auto_joint_run.add_argument("--motor-step")
|
||||
auto_joint_run.add_argument("--housing-python")
|
||||
auto_joint_run.add_argument("--output", required=True)
|
||||
auto_joint_run.add_argument("--tooth-form", choices=["spur", "helical"], default="spur")
|
||||
auto_joint_run.add_argument("--radial-clearance-mm", default="14.0")
|
||||
|
||||
@@ -1,84 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_RUN_NAME = "frontend_split_motor_7nm"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> object:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _project_summary(run_name: str) -> dict[str, object]:
|
||||
run = BACKEND_ROOT / "output" / "runs" / run_name
|
||||
formula = _read_json(run / "reducer" / "formula" / "formula_instance.json")
|
||||
kinematics = _read_json(run / "reducer" / "kinematics" / "kinematic_solution.json")
|
||||
reducer_validation = _read_json(run / "reducer" / "validation" / "validation_report.json")
|
||||
joint_validation = _read_json(run / "joint_module_validation_report.json")
|
||||
return {
|
||||
"source": f"output/runs/{run_name}",
|
||||
"runId": joint_validation["run_id"],
|
||||
"ratio": formula["derived"]["ratio"],
|
||||
"parameters": formula["parameters"],
|
||||
"derived": formula["derived"],
|
||||
"speeds": kinematics["speeds"],
|
||||
"reducerValidation": reducer_validation["summary"],
|
||||
"jointValidation": joint_validation["summary"],
|
||||
}
|
||||
|
||||
|
||||
class DevRequestHandler(SimpleHTTPRequestHandler):
|
||||
run_name = DEFAULT_RUN_NAME
|
||||
|
||||
def _send_json(self, status: int, payload: object) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_file(self, path: Path) -> None:
|
||||
if not path.exists() or not path.is_file():
|
||||
self._send_json(404, {"error": f"file_not_found: {path}"})
|
||||
return
|
||||
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
body = path.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/api/project-summary":
|
||||
try:
|
||||
self._send_json(200, _project_summary(self.run_name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._send_json(404, {"error": str(exc)})
|
||||
return
|
||||
|
||||
prefix = "/project-assets/urdf/"
|
||||
if parsed.path.startswith(prefix):
|
||||
relative_path = Path(unquote(parsed.path[len(prefix):]))
|
||||
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||
self._send_json(400, {"error": "invalid_asset_path"})
|
||||
return
|
||||
asset_root = BACKEND_ROOT / "output" / "runs" / self.run_name / "exports" / "urdf"
|
||||
self._send_file(asset_root / relative_path)
|
||||
return
|
||||
|
||||
self._send_json(404, {"error": "not_found"})
|
||||
from .toolchain.api import DEFAULT_RUN_NAME, create_toolchain_app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -88,8 +12,7 @@ def main() -> None:
|
||||
parser.add_argument("--run-name", default=DEFAULT_RUN_NAME)
|
||||
args = parser.parse_args()
|
||||
|
||||
DevRequestHandler.run_name = args.run_name
|
||||
server = ThreadingHTTPServer((args.host, args.port), DevRequestHandler)
|
||||
server = create_toolchain_app(args.host, args.port, run_name=args.run_name)
|
||||
print(f"backend dev server: http://{args.host}:{args.port} run={args.run_name}", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
@@ -12,14 +12,16 @@ from ..models import read_json, sha256_json, write_json
|
||||
from ..parameter_solver import load_requirement
|
||||
from .datum_inference import infer_step_datum_axis
|
||||
from .generator import JointModuleError, run_joint_module
|
||||
from .models import JointModuleManifest
|
||||
from .models import JointModuleManifest, JointPlatformProfile
|
||||
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_PLATFORM_PROFILE = (
|
||||
PACKAGE_ROOT / "input" / "assets" / "platforms" / "actuator_v2_fixed_platform.json"
|
||||
)
|
||||
AUTO_CONTROLLED_OUTPUTS = [
|
||||
"auto_design_input.json",
|
||||
"auto_design_report.json",
|
||||
"generated_housing.step",
|
||||
"generated_reducer_requirement.json",
|
||||
"generated_joint_requirement.json",
|
||||
"auto_generation_error.json",
|
||||
@@ -93,6 +95,27 @@ def _resolve_path(path_text: str, *, requirement_path: Path | None = None) -> Pa
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def load_platform_assets(platform_profile: Path) -> tuple[JointPlatformProfile, dict[str, Path]]:
|
||||
if not platform_profile.exists():
|
||||
raise AutoJointDesignError(f"missing_joint_platform_profile: {platform_profile}")
|
||||
profile = JointPlatformProfile.model_validate(read_json(platform_profile))
|
||||
paths = {
|
||||
"motor_step": _resolve_path(profile.motor_step, requirement_path=platform_profile),
|
||||
"housing_step": _resolve_path(profile.housing_step, requirement_path=platform_profile),
|
||||
}
|
||||
if profile.chip_step is not None:
|
||||
paths["chip_step"] = _resolve_path(profile.chip_step, requirement_path=platform_profile)
|
||||
if profile.reference_assembly_step is not None:
|
||||
paths["reference_assembly_step"] = _resolve_path(
|
||||
profile.reference_assembly_step,
|
||||
requirement_path=platform_profile,
|
||||
)
|
||||
missing = {name: str(path) for name, path in paths.items() if not path.exists()}
|
||||
if missing:
|
||||
raise AutoJointDesignError(f"fixed_platform_asset_missing: {missing}")
|
||||
return profile, paths
|
||||
|
||||
|
||||
def _axis_extent(metrics: Any) -> float:
|
||||
axis = metrics.inferred_axis
|
||||
if axis is None:
|
||||
@@ -195,6 +218,60 @@ def compute_auto_envelope(
|
||||
}, housing_module
|
||||
|
||||
|
||||
def compute_fixed_platform_envelope(
|
||||
*,
|
||||
platform_profile: Path,
|
||||
radial_clearance_mm: float,
|
||||
reducer_radial_clearance_mm: float,
|
||||
) -> tuple[AutoJointEnvelope, dict[str, Any], JointPlatformProfile]:
|
||||
profile, paths = load_platform_assets(platform_profile)
|
||||
motor_metrics = infer_step_datum_axis(
|
||||
component_id="motor",
|
||||
step_path=paths["motor_step"],
|
||||
role="motor",
|
||||
)
|
||||
housing_metrics = infer_step_datum_axis(
|
||||
component_id="housing",
|
||||
step_path=paths["housing_step"],
|
||||
role="housing",
|
||||
)
|
||||
motor_radial_diameter = _radial_diameter(motor_metrics)
|
||||
motor_axis_length = _axis_extent(motor_metrics)
|
||||
housing_radial_diameter = _radial_diameter(housing_metrics)
|
||||
housing_axis_length = _axis_extent(housing_metrics)
|
||||
housing_inner_diameter = max(24.0, housing_radial_diameter - 2.0 * radial_clearance_mm)
|
||||
reducer_max_outer = max(24.0, housing_inner_diameter - 2.0 * reducer_radial_clearance_mm)
|
||||
envelope = AutoJointEnvelope(
|
||||
motor_radial_diameter_mm=motor_radial_diameter,
|
||||
motor_axis_length_mm=motor_axis_length,
|
||||
housing_inner_diameter_mm=housing_inner_diameter,
|
||||
housing_outer_diameter_mm=housing_radial_diameter,
|
||||
housing_axis_length_mm=housing_axis_length,
|
||||
reducer_max_outer_diameter_mm=reducer_max_outer,
|
||||
target_ratio=0.0,
|
||||
module_candidates_mm=_module_candidates_for_outer_diameter(reducer_max_outer),
|
||||
planet_count_candidates=[3, 4],
|
||||
)
|
||||
envelope = AutoJointEnvelope(
|
||||
**{
|
||||
**envelope.__dict__,
|
||||
"target_ratio": _ratio_from_envelope(envelope),
|
||||
}
|
||||
)
|
||||
return envelope, {
|
||||
"platform_profile": str(platform_profile.resolve()),
|
||||
"platform_id": profile.platform_id,
|
||||
"asset_paths": {name: str(path.resolve()) for name, path in paths.items()},
|
||||
"motor_metrics": motor_metrics.model_dump(mode="json"),
|
||||
"housing_metrics": housing_metrics.model_dump(mode="json"),
|
||||
"housing_dimensions": {
|
||||
"estimated_inner_diameter_mm": housing_inner_diameter,
|
||||
"outer_diameter_mm": housing_radial_diameter,
|
||||
"axis_length_mm": housing_axis_length,
|
||||
},
|
||||
}, profile
|
||||
|
||||
|
||||
def export_generated_housing(
|
||||
*,
|
||||
housing_module: Any,
|
||||
@@ -214,7 +291,33 @@ def export_generated_housing(
|
||||
raise AutoJointDesignError(f"generated_housing_step_missing_or_empty: {out_path}")
|
||||
|
||||
|
||||
def build_reducer_requirement(envelope: AutoJointEnvelope, *, tooth_form: str = "spur") -> dict[str, Any]:
|
||||
def _cascade_requirement_template() -> dict[str, Any]:
|
||||
template = (
|
||||
PACKAGE_ROOT
|
||||
/ "input"
|
||||
/ "requirements"
|
||||
/ "reducers"
|
||||
/ "simple_2k_h_cascade"
|
||||
/ "simple_2kh_cascade_ratio_9_spur_compact.json"
|
||||
)
|
||||
return read_json(template)
|
||||
|
||||
|
||||
def build_reducer_requirement(
|
||||
envelope: AutoJointEnvelope,
|
||||
*,
|
||||
tooth_form: str = "spur",
|
||||
topology_family: str = "simple_2k_h",
|
||||
) -> dict[str, Any]:
|
||||
if topology_family == "simple_2k_h_cascade":
|
||||
payload = _cascade_requirement_template()
|
||||
payload["tooth_form"] = tooth_form
|
||||
payload.setdefault("constraints", {})["max_outer_diameter_mm"] = (
|
||||
envelope.reducer_max_outer_diameter_mm
|
||||
)
|
||||
return payload
|
||||
if topology_family != "simple_2k_h":
|
||||
raise AutoJointDesignError(f"unsupported_auto_joint_reducer_family: {topology_family}")
|
||||
return {
|
||||
"target_ratio": envelope.target_ratio,
|
||||
"ratio_tolerance": 0.000001,
|
||||
@@ -245,21 +348,29 @@ def build_reducer_requirement(envelope: AutoJointEnvelope, *, tooth_form: str =
|
||||
|
||||
def build_joint_requirement(
|
||||
*,
|
||||
motor_step: Path,
|
||||
housing_step: Path,
|
||||
platform_profile: Path,
|
||||
reducer_requirement: Path,
|
||||
topology_family: str = "simple_2k_h",
|
||||
) -> dict[str, Any]:
|
||||
if topology_family == "simple_2k_h_cascade":
|
||||
motor_relation = "motor_output_to_s1_sun_input"
|
||||
housing_relation = "stage_rings_fixed_to_housing"
|
||||
joint_output = "s2_carrier_output"
|
||||
else:
|
||||
motor_relation = "motor_output_to_sun_keyed_input"
|
||||
housing_relation = "ring_fixed_to_housing"
|
||||
joint_output = "carrier_output_shaft"
|
||||
return {
|
||||
"module_type": "planetary_joint_module",
|
||||
"motor_step": str(motor_step.resolve()),
|
||||
"housing_step": str(housing_step.resolve()),
|
||||
"platform_profile": str(platform_profile.resolve()),
|
||||
"reducer_requirement": str(reducer_requirement.resolve()),
|
||||
"placement_policy": "infer_then_write_editable_json",
|
||||
"motor_to_reducer_relation": "motor_output_to_sun_keyed_input",
|
||||
"housing_relation": "ring_fixed_to_housing",
|
||||
"joint_output": "carrier_output_shaft",
|
||||
"motor_to_reducer_relation": motor_relation,
|
||||
"housing_relation": housing_relation,
|
||||
"joint_output": joint_output,
|
||||
"motor_placement_policy": "external_flush_input_end",
|
||||
"input_shaft_insertion_depth_mm": 4.0,
|
||||
"output_extension_mm": 8.0,
|
||||
"output_extension_mm": 14.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -273,51 +384,71 @@ def prepare_auto_dir(out_dir: Path) -> None:
|
||||
|
||||
def run_auto_joint_design(
|
||||
*,
|
||||
motor_step: Path,
|
||||
housing_source: Path,
|
||||
out_dir: Path,
|
||||
platform_profile: Path = DEFAULT_PLATFORM_PROFILE,
|
||||
motor_step: Path | None = None,
|
||||
housing_source: Path | None = None,
|
||||
tooth_form: str = "spur",
|
||||
radial_clearance_mm: float = 14.0,
|
||||
reducer_axial_allowance_mm: float = 42.0,
|
||||
reducer_axial_allowance_mm: float = 28.0,
|
||||
reducer_radial_clearance_mm: float = 8.0,
|
||||
reducer_requirement_overrides: dict[str, Any] | None = None,
|
||||
) -> JointModuleManifest:
|
||||
prepare_auto_dir(out_dir)
|
||||
if not motor_step.exists():
|
||||
raise AutoJointDesignError(f"missing_motor_step: {motor_step}")
|
||||
if not housing_source.exists():
|
||||
raise AutoJointDesignError(f"missing_housing_source: {housing_source}")
|
||||
|
||||
envelope, evidence, housing_module = compute_auto_envelope(
|
||||
motor_step=motor_step,
|
||||
housing_source=housing_source,
|
||||
platform_profile = platform_profile.resolve()
|
||||
envelope, evidence, _profile = compute_fixed_platform_envelope(
|
||||
platform_profile=platform_profile,
|
||||
radial_clearance_mm=radial_clearance_mm,
|
||||
reducer_axial_allowance_mm=reducer_axial_allowance_mm,
|
||||
reducer_radial_clearance_mm=reducer_radial_clearance_mm,
|
||||
)
|
||||
housing_step = out_dir / "generated_housing.step"
|
||||
export_generated_housing(
|
||||
housing_module=housing_module,
|
||||
scale_payload=evidence["housing_scale"],
|
||||
out_path=housing_step,
|
||||
)
|
||||
reducer_requirement_path = out_dir / "generated_reducer_requirement.json"
|
||||
joint_requirement_path = out_dir / "generated_joint_requirement.json"
|
||||
reducer_requirement_payload = build_reducer_requirement(envelope, tooth_form=tooth_form)
|
||||
overrides = reducer_requirement_overrides or {}
|
||||
topology_family = str(overrides.get("topology_family") or "simple_2k_h")
|
||||
reducer_requirement_payload = build_reducer_requirement(
|
||||
envelope,
|
||||
tooth_form=tooth_form,
|
||||
topology_family=topology_family,
|
||||
)
|
||||
for name in {
|
||||
"target_ratio",
|
||||
"tooth_form",
|
||||
"helix_angle_deg",
|
||||
"pressure_angle_deg",
|
||||
"face_width_mm",
|
||||
"backlash_mm",
|
||||
"ring_rim_thickness_mm",
|
||||
}:
|
||||
if name in overrides:
|
||||
reducer_requirement_payload[name] = overrides[name]
|
||||
if "module_mm" in overrides:
|
||||
reducer_requirement_payload["module_candidates_mm"] = [float(overrides["module_mm"])]
|
||||
if "planet_count" in overrides:
|
||||
reducer_requirement_payload["planet_count_candidates"] = [int(overrides["planet_count"])]
|
||||
if "max_outer_diameter_mm" in overrides:
|
||||
reducer_requirement_payload["constraints"]["max_outer_diameter_mm"] = float(
|
||||
overrides["max_outer_diameter_mm"]
|
||||
)
|
||||
if reducer_requirement_payload.get("tooth_form") == "helical":
|
||||
reducer_requirement_payload.setdefault("helix_angle_deg", 15.0)
|
||||
else:
|
||||
reducer_requirement_payload.pop("helix_angle_deg", None)
|
||||
write_json(reducer_requirement_path, reducer_requirement_payload)
|
||||
# Validate before launching the heavy generation path.
|
||||
load_requirement(reducer_requirement_path)
|
||||
joint_requirement_payload = build_joint_requirement(
|
||||
motor_step=motor_step,
|
||||
housing_step=housing_step,
|
||||
platform_profile=platform_profile,
|
||||
reducer_requirement=reducer_requirement_path,
|
||||
topology_family=str(reducer_requirement_payload.get("topology_family") or topology_family),
|
||||
)
|
||||
write_json(joint_requirement_path, joint_requirement_payload)
|
||||
|
||||
write_json(
|
||||
out_dir / "auto_design_input.json",
|
||||
{
|
||||
"motor_step": str(motor_step.resolve()),
|
||||
"housing_source": str(housing_source.resolve()),
|
||||
"platform_profile": str(platform_profile.resolve()),
|
||||
"ignored_legacy_motor_step": str(motor_step.resolve()) if motor_step else None,
|
||||
"ignored_legacy_housing_source": str(housing_source.resolve()) if housing_source else None,
|
||||
"tooth_form": tooth_form,
|
||||
"radial_clearance_mm": radial_clearance_mm,
|
||||
"reducer_axial_allowance_mm": reducer_axial_allowance_mm,
|
||||
@@ -332,7 +463,6 @@ def run_auto_joint_design(
|
||||
"envelope": envelope.__dict__,
|
||||
"evidence": evidence,
|
||||
"generated_files": {
|
||||
"housing_step": str(housing_step.resolve()),
|
||||
"reducer_requirement": str(reducer_requirement_path.resolve()),
|
||||
"joint_requirement": str(joint_requirement_path.resolve()),
|
||||
},
|
||||
|
||||
@@ -33,10 +33,12 @@ from .models import (
|
||||
JointInterferenceFinding,
|
||||
JointModuleManifest,
|
||||
JointModuleRequirement,
|
||||
JointPlatformProfile,
|
||||
JointRelation,
|
||||
StepShapeMetrics,
|
||||
)
|
||||
from .reducer_adapters import JointAdapterError, ReducerJointAdapter, get_reducer_joint_adapter
|
||||
from .reference_assembly import ReferenceAssemblyError, infer_reference_placements
|
||||
from .step_geometry import (
|
||||
apply_placement_to_direction,
|
||||
apply_placement_to_point,
|
||||
@@ -46,6 +48,7 @@ from .step_geometry import (
|
||||
detect_interferences_from_placed_shapes,
|
||||
dot,
|
||||
metrics_for_shape,
|
||||
metrics_for_step,
|
||||
union_bbox,
|
||||
vector_add,
|
||||
vector_scale,
|
||||
@@ -89,10 +92,6 @@ class ReducerRunArtifacts:
|
||||
out_dir: Path
|
||||
|
||||
|
||||
def load_joint_requirement(path: Path) -> JointModuleRequirement:
|
||||
return JointModuleRequirement.model_validate(read_json(path))
|
||||
|
||||
|
||||
def _resolve_path(path_text: str, *, requirement_path: Path) -> Path:
|
||||
path = Path(path_text).expanduser()
|
||||
if path.is_absolute():
|
||||
@@ -106,6 +105,42 @@ def _resolve_path(path_text: str, *, requirement_path: Path) -> Path:
|
||||
return relative_to_requirement.resolve()
|
||||
|
||||
|
||||
def load_joint_requirement(path: Path) -> JointModuleRequirement:
|
||||
payload = read_json(path)
|
||||
profile_ref = payload.get("platform_profile")
|
||||
if profile_ref:
|
||||
profile_path = _resolve_path(str(profile_ref), requirement_path=path)
|
||||
if not profile_path.exists():
|
||||
raise JointModuleError(f"missing_joint_platform_profile: {profile_path}")
|
||||
profile = JointPlatformProfile.model_validate(read_json(profile_path))
|
||||
asset_fields = (
|
||||
"motor_step",
|
||||
"housing_step",
|
||||
"chip_step",
|
||||
"reference_assembly_step",
|
||||
)
|
||||
for field in asset_fields:
|
||||
if field in payload:
|
||||
raise JointModuleError(
|
||||
f"joint_platform_asset_override_not_allowed: {field}"
|
||||
)
|
||||
value = getattr(profile, field)
|
||||
if value is not None:
|
||||
payload[field] = str(
|
||||
_resolve_path(
|
||||
value,
|
||||
requirement_path=profile_path,
|
||||
)
|
||||
)
|
||||
payload["platform_id"] = profile.platform_id
|
||||
requirement = JointModuleRequirement.model_validate(payload)
|
||||
if requirement.platform_profile is None:
|
||||
raise JointModuleError(
|
||||
"joint_platform_profile_required: motor and housing platform assets are fixed"
|
||||
)
|
||||
return requirement
|
||||
|
||||
|
||||
def prepare_joint_dir(out_dir: Path) -> None:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in JOINT_CONTROLLED_OUTPUTS:
|
||||
@@ -248,6 +283,8 @@ def _motor_output_bore_face_axis_value(
|
||||
*,
|
||||
motor_metrics: StepShapeMetrics,
|
||||
motor_output_side: str,
|
||||
motor_rotation: ComponentPlacement,
|
||||
target_axis_direction: tuple[float, float, float],
|
||||
) -> float | None:
|
||||
axis = motor_metrics.inferred_axis
|
||||
if axis is None:
|
||||
@@ -255,13 +292,21 @@ def _motor_output_bore_face_axis_value(
|
||||
details = axis.details
|
||||
key = "motor_output_bore_axis_max_mm" if motor_output_side == "axis_max" else "motor_output_bore_axis_min_mm"
|
||||
value = details.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
fallback_key = "motor_output_face_axis_value_mm"
|
||||
fallback_value = details.get(fallback_key)
|
||||
if isinstance(fallback_value, (int, float)):
|
||||
return float(fallback_value)
|
||||
return None
|
||||
if not isinstance(value, (int, float)):
|
||||
return None
|
||||
|
||||
selected_axis_dimension = str(details.get("selected_axis_dimension", "z"))
|
||||
origin = axis.origin_mm
|
||||
origin_axis_value = {"x": origin[0], "y": origin[1], "z": origin[2]}.get(selected_axis_dimension)
|
||||
if origin_axis_value is None:
|
||||
return None
|
||||
delta = float(value) - float(origin_axis_value)
|
||||
raw_bore_face_point = vector_add(
|
||||
origin,
|
||||
vector_scale(axis.direction_xyz, delta),
|
||||
)
|
||||
rotated_bore_face_point = apply_placement_to_point(raw_bore_face_point, motor_rotation)
|
||||
return dot(rotated_bore_face_point, target_axis_direction)
|
||||
|
||||
|
||||
def _build_placements(
|
||||
@@ -273,6 +318,7 @@ def _build_placements(
|
||||
reducer_step_paths: dict[str, Path],
|
||||
artifacts: ReducerRunArtifacts,
|
||||
adapter: ReducerJointAdapter,
|
||||
reference_placements: dict[str, ComponentPlacement] | None = None,
|
||||
) -> dict[str, ComponentPlacement]:
|
||||
if motor_metrics.inferred_axis is None or housing_metrics.inferred_axis is None:
|
||||
raise JointModuleError("datum_inference_failed: motor and housing axes are required")
|
||||
@@ -375,6 +421,8 @@ def _build_placements(
|
||||
motor_output_face_axis_value = _motor_output_bore_face_axis_value(
|
||||
motor_metrics=motor_metrics,
|
||||
motor_output_side=motor_output_side,
|
||||
motor_rotation=motor_rotation_only,
|
||||
target_axis_direction=housing_axis_direction,
|
||||
)
|
||||
if motor_output_face_axis_value is None:
|
||||
motor_output_face_axis_value = motor_axis_max if output_points_to_axis_max else motor_axis_min
|
||||
@@ -408,8 +456,17 @@ def _build_placements(
|
||||
if motor_fit_policy == "external_input_side":
|
||||
motor_lower_bound = -float("inf")
|
||||
motor_upper_bound = float("inf")
|
||||
feasible_lower = max(core_lower_bound, motor_lower_bound)
|
||||
feasible_upper = min(core_upper_bound, motor_upper_bound)
|
||||
reference_motor = (
|
||||
(reference_placements or {}).get("motor")
|
||||
if requirement.motor_placement_policy == "reference_pose"
|
||||
else None
|
||||
)
|
||||
if reference_motor is not None:
|
||||
feasible_lower = core_lower_bound
|
||||
feasible_upper = core_upper_bound
|
||||
else:
|
||||
feasible_lower = max(core_lower_bound, motor_lower_bound)
|
||||
feasible_upper = min(core_upper_bound, motor_upper_bound)
|
||||
if feasible_lower > feasible_upper:
|
||||
raise JointModuleError(
|
||||
"joint_axial_stack_does_not_fit_housing: "
|
||||
@@ -418,11 +475,34 @@ def _build_placements(
|
||||
f"motor_interval=({motor_lower_bound:.6f}, {motor_upper_bound:.6f})"
|
||||
)
|
||||
|
||||
if output_side == "axis_min":
|
||||
output_extension_target = housing_min - requirement.output_extension_mm - output_min
|
||||
if reference_motor is not None:
|
||||
motor_output_face_target = _motor_output_bore_face_axis_value(
|
||||
motor_metrics=motor_metrics,
|
||||
motor_output_side=motor_output_side,
|
||||
motor_rotation=reference_motor,
|
||||
target_axis_direction=housing_axis_direction,
|
||||
)
|
||||
if motor_output_face_target is None:
|
||||
raise JointModuleError("reference_motor_output_bore_face_unavailable")
|
||||
input_attachment_target = (
|
||||
motor_output_face_target + input_side_sign * input_insertion_depth_mm
|
||||
)
|
||||
reference_translation = input_attachment_target - input_shaft_attachment
|
||||
reducer_axis_translation = _clamp(reference_translation, feasible_lower, feasible_upper)
|
||||
if reducer_axis_translation < core_lower_bound or reducer_axis_translation > core_upper_bound:
|
||||
raise JointModuleError(
|
||||
"reference_motor_position_does_not_leave_reducer_core_clearance: "
|
||||
f"selected_translation={reducer_axis_translation:.6f} "
|
||||
f"core_interval=({core_lower_bound:.6f}, {core_upper_bound:.6f})"
|
||||
)
|
||||
placement_policy = "reference_motor_fixed_then_fit_reducer_input_and_housing"
|
||||
else:
|
||||
output_extension_target = housing_max + requirement.output_extension_mm - output_max
|
||||
reducer_axis_translation = _clamp(output_extension_target, feasible_lower, feasible_upper)
|
||||
if output_side == "axis_min":
|
||||
output_extension_target = housing_min - requirement.output_extension_mm - output_min
|
||||
else:
|
||||
output_extension_target = housing_max + requirement.output_extension_mm - output_max
|
||||
reducer_axis_translation = _clamp(output_extension_target, feasible_lower, feasible_upper)
|
||||
placement_policy = "fit_motor_and_reducer_core_inside_housing_then_expose_output_shaft"
|
||||
desired_output_axis_value = (
|
||||
output_min + reducer_axis_translation if output_side == "axis_min" else output_max + reducer_axis_translation
|
||||
)
|
||||
@@ -439,7 +519,7 @@ def _build_placements(
|
||||
translation_mm=reducer_translation,
|
||||
rotation_axis_angle_deg=reducer_rotation,
|
||||
details={
|
||||
"policy": "fit_motor_and_reducer_core_inside_housing_then_expose_output_shaft",
|
||||
"policy": placement_policy,
|
||||
"housing_axis_min_mm": housing_min,
|
||||
"housing_axis_max_mm": housing_max,
|
||||
"inside_margin_mm": inside_margin,
|
||||
@@ -463,7 +543,21 @@ def _build_placements(
|
||||
"feasible_lower": _json_float(feasible_lower),
|
||||
"feasible_upper": _json_float(feasible_upper),
|
||||
"selected": _json_float(reducer_axis_translation),
|
||||
"output_extension_target": _json_float(output_extension_target),
|
||||
"output_extension_target": (
|
||||
_json_float(reference_translation)
|
||||
if reference_motor is not None
|
||||
else _json_float(output_extension_target)
|
||||
),
|
||||
**(
|
||||
{
|
||||
"reference_motor_target_translation": _json_float(reference_translation),
|
||||
"reference_motor_target_clamped": bool(
|
||||
reducer_axis_translation != reference_translation
|
||||
),
|
||||
}
|
||||
if reference_motor is not None
|
||||
else {}
|
||||
),
|
||||
"preferred_core_translation": _json_float(preferred_core_translation),
|
||||
},
|
||||
"housing_axis": housing_axis.model_dump(mode="json"),
|
||||
@@ -504,18 +598,38 @@ def _build_placements(
|
||||
motor_metrics.inferred_axis.origin_mm,
|
||||
motor_rotation_only,
|
||||
)
|
||||
motor_translation = _axis_align_translation(
|
||||
axis_origin_target=input_axis_origin_target,
|
||||
axis_direction_target=input_axis_direction_target,
|
||||
rotated_axis_origin=rotated_motor_axis_origin,
|
||||
rotated_bbox_axis_value=motor_output_face_axis_value,
|
||||
desired_bbox_axis_value=desired_motor_output_face_axis_value,
|
||||
)
|
||||
motor_placement = ComponentPlacement(
|
||||
translation_mm=motor_translation,
|
||||
rotation_axis_angle_deg=motor_rotation,
|
||||
details={
|
||||
"policy": "align_motor_output_bore_to_sun_input_shaft_with_insertion_depth",
|
||||
if reference_motor is not None:
|
||||
motor_placement = reference_motor.model_copy(
|
||||
update={
|
||||
"details": {
|
||||
**reference_motor.details,
|
||||
"policy": "preserve_reference_assembly_motor_pose",
|
||||
"motor_output_side": motor_output_side,
|
||||
"motor_output_face_axis_value_mm": desired_motor_output_face_axis_value,
|
||||
"motor_output_bore_face_axis_value_mm": desired_motor_output_face_axis_value,
|
||||
"input_axis_component_id": input_axis_component_id,
|
||||
"input_axis_component_axis_min_mm": shaft_axis_min,
|
||||
"input_axis_component_axis_max_mm": shaft_axis_max,
|
||||
"input_axis_origin_mm": input_axis_origin_target,
|
||||
"input_axis_direction_xyz": input_axis_direction_target,
|
||||
"input_shaft_insertion_depth_mm": input_insertion_depth_mm,
|
||||
"motor_axis": motor_metrics.inferred_axis.model_dump(mode="json"),
|
||||
}
|
||||
}
|
||||
)
|
||||
else:
|
||||
motor_translation = _axis_align_translation(
|
||||
axis_origin_target=input_axis_origin_target,
|
||||
axis_direction_target=input_axis_direction_target,
|
||||
rotated_axis_origin=rotated_motor_axis_origin,
|
||||
rotated_bbox_axis_value=motor_output_face_axis_value,
|
||||
desired_bbox_axis_value=desired_motor_output_face_axis_value,
|
||||
)
|
||||
motor_placement = ComponentPlacement(
|
||||
translation_mm=motor_translation,
|
||||
rotation_axis_angle_deg=motor_rotation,
|
||||
details={
|
||||
"policy": requirement.motor_placement_policy,
|
||||
"motor_output_side": motor_output_side,
|
||||
"motor_output_face_axis_value_mm": desired_motor_output_face_axis_value,
|
||||
"motor_output_bore_face_axis_value_mm": desired_motor_output_face_axis_value,
|
||||
@@ -527,13 +641,16 @@ def _build_placements(
|
||||
"input_axis_direction_xyz": input_axis_direction_target,
|
||||
"input_shaft_insertion_depth_mm": input_insertion_depth_mm,
|
||||
"motor_axis": motor_metrics.inferred_axis.model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
},
|
||||
)
|
||||
placements = {
|
||||
"housing": housing_placement,
|
||||
"reducer": reducer_placement,
|
||||
"motor": motor_placement,
|
||||
}
|
||||
if reference_placements and "chip" in reference_placements:
|
||||
placements["chip"] = reference_placements["chip"]
|
||||
return placements
|
||||
|
||||
|
||||
def run_joint_module(
|
||||
@@ -545,12 +662,26 @@ def run_joint_module(
|
||||
requirement = load_joint_requirement(requirement_path)
|
||||
motor_step = _resolve_path(requirement.motor_step, requirement_path=requirement_path)
|
||||
housing_step = _resolve_path(requirement.housing_step, requirement_path=requirement_path)
|
||||
chip_step = (
|
||||
_resolve_path(requirement.chip_step, requirement_path=requirement_path)
|
||||
if requirement.chip_step
|
||||
else None
|
||||
)
|
||||
reference_assembly_step = (
|
||||
_resolve_path(requirement.reference_assembly_step, requirement_path=requirement_path)
|
||||
if requirement.reference_assembly_step
|
||||
else None
|
||||
)
|
||||
reducer_requirement = _resolve_path(requirement.reducer_requirement, requirement_path=requirement_path)
|
||||
|
||||
if not motor_step.exists():
|
||||
raise JointModuleError(f"missing_motor_step: {motor_step}")
|
||||
if not housing_step.exists():
|
||||
raise JointModuleError(f"missing_housing_step: {housing_step}")
|
||||
if chip_step is not None and not chip_step.exists():
|
||||
raise JointModuleError(f"missing_chip_step: {chip_step}")
|
||||
if reference_assembly_step is not None and not reference_assembly_step.exists():
|
||||
raise JointModuleError(f"missing_reference_assembly_step: {reference_assembly_step}")
|
||||
if not reducer_requirement.exists():
|
||||
raise JointModuleError(f"missing_reducer_requirement: {reducer_requirement}")
|
||||
reducer_requirement_model = load_requirement(reducer_requirement)
|
||||
@@ -561,11 +692,46 @@ def run_joint_module(
|
||||
|
||||
motor_metrics = infer_step_datum_axis(component_id="motor", step_path=motor_step, role="motor")
|
||||
housing_metrics = infer_step_datum_axis(component_id="housing", step_path=housing_step, role="housing")
|
||||
chip_metrics = metrics_for_step("chip", chip_step) if chip_step is not None else None
|
||||
reference_placements: dict[str, ComponentPlacement] | None = None
|
||||
if reference_assembly_step is not None:
|
||||
reference_components = {
|
||||
"motor": motor_step,
|
||||
"housing": housing_step,
|
||||
}
|
||||
if chip_step is not None:
|
||||
reference_components["chip"] = chip_step
|
||||
try:
|
||||
reference_placements = infer_reference_placements(
|
||||
reference_step=reference_assembly_step,
|
||||
component_steps=reference_components,
|
||||
anchor_component_id="housing",
|
||||
)
|
||||
except ReferenceAssemblyError as exc:
|
||||
raise JointModuleError(str(exc)) from exc
|
||||
write_json(
|
||||
out_dir / "external_step_metrics.json",
|
||||
{
|
||||
"motor": motor_metrics.model_dump(mode="json"),
|
||||
"housing": housing_metrics.model_dump(mode="json"),
|
||||
**(
|
||||
{"chip": chip_metrics.model_dump(mode="json")}
|
||||
if chip_metrics is not None
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
"reference_assembly": {
|
||||
"step_path": str(reference_assembly_step.resolve()),
|
||||
"placements": {
|
||||
component_id: placement.model_dump(mode="json")
|
||||
for component_id, placement in reference_placements.items()
|
||||
},
|
||||
}
|
||||
}
|
||||
if reference_assembly_step is not None and reference_placements is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -593,6 +759,7 @@ def run_joint_module(
|
||||
reducer_step_paths=reducer_step_paths,
|
||||
artifacts=artifacts,
|
||||
adapter=adapter,
|
||||
reference_placements=reference_placements,
|
||||
)
|
||||
write_json(
|
||||
out_dir / "joint_placement.json",
|
||||
@@ -604,6 +771,14 @@ def run_joint_module(
|
||||
"housing": apply_transform(read_step_shape(housing_step), placements["housing"]),
|
||||
}
|
||||
source_paths = {"motor": motor_step, "housing": housing_step}
|
||||
if chip_step is not None and chip_metrics is not None:
|
||||
chip_placement = placements.get("chip")
|
||||
if chip_placement is None:
|
||||
raise JointModuleError(
|
||||
"chip_placement_requires_reference_assembly_step"
|
||||
)
|
||||
placed_shapes["chip"] = apply_transform(read_step_shape(chip_step), chip_placement)
|
||||
source_paths["chip"] = chip_step
|
||||
for component_id, shape in reducer_shapes.items():
|
||||
placed_shapes[component_id] = apply_transform(shape, placements["reducer"])
|
||||
source_paths[component_id] = reducer_step_paths[component_id]
|
||||
@@ -612,8 +787,10 @@ def run_joint_module(
|
||||
write_compound_step(placed_shapes, joint_step_path)
|
||||
|
||||
raw_external_by_id = {"motor": motor_metrics, "housing": housing_metrics}
|
||||
if chip_metrics is not None:
|
||||
raw_external_by_id["chip"] = chip_metrics
|
||||
joint_components: dict[str, JointComponentManifest] = {}
|
||||
for component_id in ["motor", "housing"]:
|
||||
for component_id in raw_external_by_id:
|
||||
raw_metrics = raw_external_by_id[component_id]
|
||||
placed_metrics = metrics_for_shape(
|
||||
component_id=component_id,
|
||||
@@ -637,13 +814,21 @@ def run_joint_module(
|
||||
)
|
||||
joint_components[component_id] = JointComponentManifest(
|
||||
component_id=component_id,
|
||||
role=component_id,
|
||||
role="electronics_controller" if component_id == "chip" else component_id,
|
||||
source_type="external_step",
|
||||
source_step_path=str(source_paths[component_id].resolve()),
|
||||
placement=placements[component_id],
|
||||
raw_metrics=raw_metrics,
|
||||
placed_metrics=placed_metrics,
|
||||
formula_ref=f"joint.external.{component_id}",
|
||||
details={
|
||||
"placement_source": placements[component_id].source,
|
||||
**(
|
||||
{"reference_assembly_step": str(reference_assembly_step.resolve())}
|
||||
if reference_assembly_step is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
for component_id, path in reducer_step_paths.items():
|
||||
@@ -687,6 +872,14 @@ def run_joint_module(
|
||||
manifest=artifacts.manifest,
|
||||
component_ids=set(placed_shapes),
|
||||
)
|
||||
if "chip" in placed_shapes:
|
||||
existing_pairs = {tuple(sorted(pair)) for pair in check_pairs}
|
||||
for component_id in sorted(set(placed_shapes) - {"chip"}):
|
||||
pair = ("chip", component_id)
|
||||
normalized = tuple(sorted(pair))
|
||||
if normalized not in existing_pairs:
|
||||
check_pairs.append(pair)
|
||||
existing_pairs.add(normalized)
|
||||
placed_metrics_raw, interference_results = detect_interferences_from_placed_shapes(
|
||||
placed_shapes,
|
||||
step_paths=source_paths,
|
||||
@@ -713,6 +906,21 @@ def run_joint_module(
|
||||
validation_path = out_dir / "joint_module_validation_report.json"
|
||||
interference_path = out_dir / "joint_interference_report.json"
|
||||
requirement_hash = sha256_json(requirement.model_dump(mode="json"))
|
||||
relations = adapter.joint_relations(artifacts.instance, artifacts.manifest)
|
||||
if "chip" in joint_components:
|
||||
relations.append(
|
||||
JointRelation(
|
||||
relation_id="chip_fixed_to_external_housing",
|
||||
relation_type="electronics_mount",
|
||||
source_component_id="chip",
|
||||
target_component_id="housing",
|
||||
formula_ref="joint.chip_reference_mount",
|
||||
details={
|
||||
"semantic": "chip pose is fixed relative to the housing",
|
||||
"placement_source": placements["chip"].source,
|
||||
},
|
||||
)
|
||||
)
|
||||
manifest = JointModuleManifest(
|
||||
run_id=run_id,
|
||||
reducer_run_id=artifacts.instance.run_id,
|
||||
@@ -721,12 +929,8 @@ def run_joint_module(
|
||||
reducer_output_dir=str(artifacts.out_dir.resolve()),
|
||||
reducer_validation_passed=artifacts.validation_report.overall_passed,
|
||||
components=joint_components,
|
||||
relations=adapter.joint_relations(artifacts.instance, artifacts.manifest),
|
||||
placements={
|
||||
"motor": placements["motor"],
|
||||
"housing": placements["housing"],
|
||||
"reducer": placements["reducer"],
|
||||
},
|
||||
relations=relations,
|
||||
placements=placements,
|
||||
exported_files={
|
||||
"joint_step": str(joint_step_path.resolve()),
|
||||
"joint_manifest": str(manifest_path.resolve()),
|
||||
@@ -741,7 +945,7 @@ def run_joint_module(
|
||||
"reducer_component_count": sum(
|
||||
1 for component in joint_components.values() if component.source_type == "generated_reducer"
|
||||
),
|
||||
"external_component_count": 2,
|
||||
"external_component_count": len(raw_external_by_id),
|
||||
"checked_interference_pair_count": len(check_pairs),
|
||||
"positive_interference_count": sum(1 for item in interference_findings if item.interfering),
|
||||
"excluded_reducer_shell_components": [
|
||||
@@ -751,6 +955,13 @@ def run_joint_module(
|
||||
],
|
||||
"placement_policy": requirement.placement_policy,
|
||||
"joint_adapter_family": adapter.family,
|
||||
"platform_id": requirement.platform_id,
|
||||
"platform_profile": requirement.platform_profile,
|
||||
"reference_assembly_step": (
|
||||
str(reference_assembly_step.resolve())
|
||||
if reference_assembly_step is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
report = build_joint_module_validation_report(
|
||||
|
||||
@@ -12,10 +12,22 @@ BBox = tuple[float, float, float, float, float, float]
|
||||
AxisAngle = tuple[float, float, float, float]
|
||||
|
||||
|
||||
class JointModuleRequirement(StrictModel):
|
||||
module_type: Literal["planetary_joint_module"] = "planetary_joint_module"
|
||||
class JointPlatformProfile(StrictModel):
|
||||
platform_id: str
|
||||
motor_step: str
|
||||
housing_step: str
|
||||
chip_step: str | None = None
|
||||
reference_assembly_step: str | None = None
|
||||
|
||||
|
||||
class JointModuleRequirement(StrictModel):
|
||||
module_type: Literal["planetary_joint_module"] = "planetary_joint_module"
|
||||
platform_profile: str | None = None
|
||||
platform_id: str | None = None
|
||||
motor_step: str
|
||||
housing_step: str
|
||||
chip_step: str | None = None
|
||||
reference_assembly_step: str | None = None
|
||||
reducer_requirement: str
|
||||
placement_policy: Literal["infer_then_write_editable_json"] = "infer_then_write_editable_json"
|
||||
motor_to_reducer_relation: str = "motor_output_to_sun_keyed_input"
|
||||
@@ -23,6 +35,7 @@ class JointModuleRequirement(StrictModel):
|
||||
joint_output: str = "carrier_output_shaft"
|
||||
motor_component_id: str = "motor"
|
||||
housing_component_id: str = "housing"
|
||||
motor_placement_policy: Literal["reference_pose", "external_flush_input_end"] = "external_flush_input_end"
|
||||
output_extension_mm: float = Field(default=8.0, ge=0)
|
||||
coupling_clearance_mm: float = Field(default=1.0, ge=0)
|
||||
input_shaft_insertion_depth_mm: float = Field(default=4.0, gt=0)
|
||||
|
||||
@@ -157,7 +157,10 @@ class Simple2KHJointAdapter(ReducerJointAdapter):
|
||||
return "sun_input_shaft" in manifest.components
|
||||
|
||||
def preferred_reducer_input_side(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str | None:
|
||||
return "axis_max"
|
||||
return "axis_min"
|
||||
|
||||
def motor_fit_policy(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
|
||||
return "external_input_side"
|
||||
|
||||
def output_extension_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
|
||||
return "output_shaft" in manifest.components
|
||||
@@ -252,22 +255,25 @@ class CascadeJointAdapter(ReducerJointAdapter):
|
||||
return _first_existing(manifest, ["cascade_input_shaft", instance.boundary.input, "s1_sun"])
|
||||
|
||||
def input_relation_target_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
|
||||
return instance.boundary.input
|
||||
return self.input_axis_component_id(instance, manifest)
|
||||
|
||||
def reducer_axis_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
|
||||
return _first_existing(manifest, ["cascade_output_shaft", instance.boundary.output, "s2_carrier", "s1_sun"])
|
||||
return _first_existing(manifest, ["s2_carrier", instance.boundary.output, "cascade_output_shaft"])
|
||||
|
||||
def output_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
|
||||
return _first_existing(manifest, ["cascade_output_shaft", instance.boundary.output, "s2_carrier"])
|
||||
|
||||
def output_relation_source_component_id(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
|
||||
return instance.boundary.output
|
||||
return _first_existing(manifest, ["s2_carrier", instance.boundary.output])
|
||||
|
||||
def input_insertion_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
|
||||
return "cascade_input_shaft" in manifest.components
|
||||
|
||||
def preferred_reducer_input_side(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str | None:
|
||||
return "axis_max"
|
||||
return "axis_min"
|
||||
|
||||
def motor_fit_policy(self, instance: ReducerInstance, manifest: AssemblyManifest) -> str:
|
||||
return "external_input_side"
|
||||
|
||||
def output_extension_required(self, instance: ReducerInstance, manifest: AssemblyManifest) -> bool:
|
||||
return "cascade_output_shaft" in manifest.components
|
||||
@@ -284,12 +290,12 @@ class CascadeJointAdapter(ReducerJointAdapter):
|
||||
component_ids: set[str],
|
||||
) -> list[tuple[str, str]]:
|
||||
pairs = [("motor", "housing")]
|
||||
motor_side = {
|
||||
motor_side = {self.input_axis_component_id(instance, manifest)}
|
||||
motor_side.update(
|
||||
component_id
|
||||
for component_id in component_ids
|
||||
if component_id not in {"motor", "housing"} and self.include_reducer_component(component_id)
|
||||
}
|
||||
motor_side.add(self.input_axis_component_id(instance, manifest))
|
||||
if component_id.startswith("s1_") and self.include_reducer_component(component_id)
|
||||
)
|
||||
housing_side = set(self.fixed_speed_variables(instance))
|
||||
housing_side.add(self.reducer_axis_component_id(instance, manifest))
|
||||
housing_side.add(self.output_component_id(instance, manifest))
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from .models import ComponentPlacement
|
||||
from .step_geometry import metrics_for_shape, metrics_for_step
|
||||
|
||||
|
||||
class ReferenceAssemblyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
Matrix4 = tuple[
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
]
|
||||
|
||||
|
||||
def _matrix_from_trsf(transform) -> Matrix4:
|
||||
return (
|
||||
tuple(float(transform.Value(1, column)) for column in range(1, 5)),
|
||||
tuple(float(transform.Value(2, column)) for column in range(1, 5)),
|
||||
tuple(float(transform.Value(3, column)) for column in range(1, 5)),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _rigid_inverse(matrix: Matrix4) -> Matrix4:
|
||||
rotation_t = tuple(
|
||||
tuple(matrix[column][row] for column in range(3))
|
||||
for row in range(3)
|
||||
)
|
||||
translation = tuple(matrix[row][3] for row in range(3))
|
||||
inverse_translation = tuple(
|
||||
-sum(rotation_t[row][column] * translation[column] for column in range(3))
|
||||
for row in range(3)
|
||||
)
|
||||
return (
|
||||
(*rotation_t[0], inverse_translation[0]),
|
||||
(*rotation_t[1], inverse_translation[1]),
|
||||
(*rotation_t[2], inverse_translation[2]),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _matrix_multiply(left: Matrix4, right: Matrix4) -> Matrix4:
|
||||
return tuple(
|
||||
tuple(
|
||||
sum(left[row][index] * right[index][column] for index in range(4))
|
||||
for column in range(4)
|
||||
)
|
||||
for row in range(4)
|
||||
) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _axis_angle_from_matrix(matrix: Matrix4) -> tuple[float, float, float, float]:
|
||||
trace = matrix[0][0] + matrix[1][1] + matrix[2][2]
|
||||
cosine = max(-1.0, min(1.0, (trace - 1.0) / 2.0))
|
||||
angle = math.acos(cosine)
|
||||
if angle <= 1e-10:
|
||||
return (0.0, 0.0, 1.0, 0.0)
|
||||
|
||||
sine = math.sin(angle)
|
||||
if abs(sine) > 1e-8:
|
||||
axis = (
|
||||
(matrix[2][1] - matrix[1][2]) / (2.0 * sine),
|
||||
(matrix[0][2] - matrix[2][0]) / (2.0 * sine),
|
||||
(matrix[1][0] - matrix[0][1]) / (2.0 * sine),
|
||||
)
|
||||
else:
|
||||
axis = (
|
||||
math.sqrt(max(0.0, (matrix[0][0] + 1.0) / 2.0)),
|
||||
math.sqrt(max(0.0, (matrix[1][1] + 1.0) / 2.0)),
|
||||
math.sqrt(max(0.0, (matrix[2][2] + 1.0) / 2.0)),
|
||||
)
|
||||
length = math.sqrt(sum(value * value for value in axis))
|
||||
if length <= 1e-12:
|
||||
axis = (0.0, 0.0, 1.0)
|
||||
else:
|
||||
axis = tuple(value / length for value in axis)
|
||||
return (*axis, math.degrees(angle))
|
||||
|
||||
|
||||
def _placement_from_matrix(
|
||||
matrix: Matrix4,
|
||||
*,
|
||||
component_id: str,
|
||||
reference_step: Path,
|
||||
node_id: str,
|
||||
) -> ComponentPlacement:
|
||||
return ComponentPlacement(
|
||||
translation_mm=(matrix[0][3], matrix[1][3], matrix[2][3]),
|
||||
rotation_axis_angle_deg=_axis_angle_from_matrix(matrix),
|
||||
source="inferred_from_reference_assembly_occurrence",
|
||||
details={
|
||||
"policy": "match_external_step_geometry_fingerprint_in_reference_assembly",
|
||||
"reference_assembly_step": str(reference_step.resolve()),
|
||||
"reference_component_id": component_id,
|
||||
"reference_node_id": node_id,
|
||||
"reference_matrix_3x4": [list(row) for row in matrix[:3]],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def infer_reference_placements(
|
||||
*,
|
||||
reference_step: Path,
|
||||
component_steps: dict[str, Path],
|
||||
anchor_component_id: str = "housing",
|
||||
) -> dict[str, ComponentPlacement]:
|
||||
reference_step = reference_step.resolve()
|
||||
component_steps = {
|
||||
component_id: step_path.resolve()
|
||||
for component_id, step_path in component_steps.items()
|
||||
}
|
||||
try:
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCP.TCollection import TCollection_ExtendedString
|
||||
from OCP.TDocStd import TDocStd_Document
|
||||
from OCP.XCAFApp import XCAFApp_Application
|
||||
from OCP.XCAFDoc import XCAFDoc_ShapeTool
|
||||
from OCP.XCAFPrs import XCAFPrs_DocumentExplorer
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ReferenceAssemblyError("ocp_xcaf_unavailable_for_reference_assembly") from exc
|
||||
|
||||
application = XCAFApp_Application.GetApplication_s()
|
||||
del application
|
||||
document = TDocStd_Document(TCollection_ExtendedString("BinXCAF"))
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetNameMode(True)
|
||||
status = reader.ReadFile(str(reference_step))
|
||||
if status != IFSelect_RetDone or not reader.Transfer(document):
|
||||
raise ReferenceAssemblyError(f"reference_assembly_step_read_failed: {reference_step}")
|
||||
|
||||
target_metrics = {
|
||||
component_id: metrics_for_step(component_id, step_path)
|
||||
for component_id, step_path in component_steps.items()
|
||||
}
|
||||
candidates: dict[str, list[tuple[float, str, Matrix4]]] = {
|
||||
component_id: [] for component_id in component_steps
|
||||
}
|
||||
|
||||
explorer = XCAFPrs_DocumentExplorer(document, 0)
|
||||
while explorer.More():
|
||||
node = explorer.Current()
|
||||
if not node.IsAssembly:
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(node.RefLabel)
|
||||
node_id = node.Id.ToCString()
|
||||
metrics = metrics_for_shape(
|
||||
component_id=node_id,
|
||||
step_path=reference_step,
|
||||
shape=shape,
|
||||
)
|
||||
occurrence_matrix = _matrix_from_trsf(node.Location.Transformation())
|
||||
occurrence_shape = shape.Moved(node.Location, False)
|
||||
occurrence_metrics = metrics_for_shape(
|
||||
component_id=node_id,
|
||||
step_path=reference_step,
|
||||
shape=occurrence_shape,
|
||||
)
|
||||
occurrence_extents = sorted(
|
||||
occurrence_metrics.bbox[index + 3] - occurrence_metrics.bbox[index]
|
||||
for index in range(3)
|
||||
)
|
||||
for component_id, target in target_metrics.items():
|
||||
volume_tolerance = max(1e-4, abs(target.volume_mm3) * 1e-9)
|
||||
if abs(metrics.volume_mm3 - target.volume_mm3) > volume_tolerance:
|
||||
continue
|
||||
if metrics.solid_count != target.solid_count or metrics.face_count != target.face_count:
|
||||
continue
|
||||
target_extents = sorted(
|
||||
target.bbox[index + 3] - target.bbox[index]
|
||||
for index in range(3)
|
||||
)
|
||||
extent_error = sum(
|
||||
abs(actual - expected)
|
||||
for actual, expected in zip(occurrence_extents, target_extents)
|
||||
)
|
||||
candidates[component_id].append((extent_error, node_id, occurrence_matrix))
|
||||
explorer.Next()
|
||||
|
||||
occurrence_by_id: dict[str, tuple[str, Matrix4]] = {}
|
||||
for component_id, matches in candidates.items():
|
||||
if not matches:
|
||||
raise ReferenceAssemblyError(
|
||||
f"reference_component_not_found_by_geometry_fingerprint: {component_id}"
|
||||
)
|
||||
matches.sort(key=lambda item: item[0])
|
||||
if len(matches) > 1 and abs(matches[0][0] - matches[1][0]) <= 1e-8:
|
||||
raise ReferenceAssemblyError(
|
||||
f"reference_component_geometry_fingerprint_ambiguous: {component_id}"
|
||||
)
|
||||
_, node_id, matrix = matches[0]
|
||||
occurrence_by_id[component_id] = (node_id, matrix)
|
||||
|
||||
if anchor_component_id not in occurrence_by_id:
|
||||
raise ReferenceAssemblyError(f"reference_anchor_component_missing: {anchor_component_id}")
|
||||
anchor_inverse = _rigid_inverse(occurrence_by_id[anchor_component_id][1])
|
||||
placements: dict[str, ComponentPlacement] = {}
|
||||
for component_id, (node_id, occurrence_matrix) in occurrence_by_id.items():
|
||||
relative_matrix = _matrix_multiply(anchor_inverse, occurrence_matrix)
|
||||
placements[component_id] = _placement_from_matrix(
|
||||
relative_matrix,
|
||||
component_id=component_id,
|
||||
reference_step=reference_step,
|
||||
node_id=node_id,
|
||||
)
|
||||
return placements
|
||||
@@ -30,6 +30,23 @@ def _bbox_axis_range(
|
||||
return (min(values), max(values))
|
||||
|
||||
|
||||
def _bbox_radial_radius(
|
||||
bbox: tuple[float, float, float, float, float, float],
|
||||
axis: tuple[float, float, float],
|
||||
) -> float:
|
||||
extents = {
|
||||
"x": abs(float(bbox[3]) - float(bbox[0])),
|
||||
"y": abs(float(bbox[4]) - float(bbox[1])),
|
||||
"z": abs(float(bbox[5]) - float(bbox[2])),
|
||||
}
|
||||
axis_dimension = max(
|
||||
{"x": abs(float(axis[0])), "y": abs(float(axis[1])), "z": abs(float(axis[2]))},
|
||||
key=lambda key: {"x": abs(float(axis[0])), "y": abs(float(axis[1])), "z": abs(float(axis[2]))}[key],
|
||||
)
|
||||
radial_extents = [extent for dimension, extent in extents.items() if dimension != axis_dimension]
|
||||
return max(radial_extents) / 2.0
|
||||
|
||||
|
||||
def _check(
|
||||
checks: list[JointValidationCheck],
|
||||
*,
|
||||
@@ -90,6 +107,9 @@ def _axis_for_component(manifest: JointModuleManifest, component_id: str) -> dic
|
||||
|
||||
def relation_context_for_pair(component_a: str, component_b: str) -> str | None:
|
||||
pair = {component_a, component_b}
|
||||
if "chip" in pair:
|
||||
other = component_b if component_a == "chip" else component_a
|
||||
return f"reference-mounted chip clearance around {other}"
|
||||
if pair == {"motor", "housing"}:
|
||||
return "external motor installed relative to external housing"
|
||||
if "housing" in pair:
|
||||
@@ -103,6 +123,8 @@ def relation_context_for_pair(component_a: str, component_b: str) -> str | None:
|
||||
|
||||
def classify_joint_interference(component_a: str, component_b: str) -> str:
|
||||
pair = {component_a, component_b}
|
||||
if "chip" in pair:
|
||||
return "electronics_mount_collision"
|
||||
if pair == {"motor", "housing"}:
|
||||
return "motor_not_aligned"
|
||||
if "housing" in pair:
|
||||
@@ -125,6 +147,7 @@ def suggested_fix_for_joint_interference(possible_cause: str) -> str:
|
||||
"reducer_outer_diameter_too_large": "reduce ring outside diameter or enlarge the external housing",
|
||||
"input_coupling_collision": "move motor axially or redesign the sun input keyed coupling clearance",
|
||||
"wrong_axial_offset": "adjust reducer or motor axial offset in joint_placement.json",
|
||||
"electronics_mount_collision": "preserve the reference chip mount and move or resize the conflicting live component",
|
||||
"unknown_interference": "inspect exported common interference STEP or component bboxes",
|
||||
}.get(possible_cause, "inspect the reported component pair")
|
||||
|
||||
@@ -179,6 +202,8 @@ def build_joint_module_validation_report(
|
||||
message=f"joint adapter could not resolve required reducer ports: {exc}",
|
||||
)
|
||||
required_components = {"motor", "housing", *adapter_required}
|
||||
if requirement.chip_step:
|
||||
required_components.add("chip")
|
||||
found_components = set(manifest.components)
|
||||
_check(
|
||||
checks,
|
||||
@@ -189,6 +214,9 @@ def build_joint_module_validation_report(
|
||||
actual=sorted(found_components),
|
||||
expected=sorted(required_components),
|
||||
)
|
||||
external_component_ids = ["motor", "housing"]
|
||||
if requirement.chip_step:
|
||||
external_component_ids.append("chip")
|
||||
_check(
|
||||
checks,
|
||||
section="static",
|
||||
@@ -197,7 +225,7 @@ def build_joint_module_validation_report(
|
||||
manifest.components.get(component_id)
|
||||
and manifest.components[component_id].source_type == "external_step"
|
||||
and manifest.components[component_id].source_step_path
|
||||
for component_id in ["motor", "housing"]
|
||||
for component_id in external_component_ids
|
||||
),
|
||||
message="motor and housing are tracked as external STEP geometry, not fake reducer model nodes",
|
||||
actual={
|
||||
@@ -206,10 +234,76 @@ def build_joint_module_validation_report(
|
||||
if component_id in manifest.components
|
||||
else None
|
||||
)
|
||||
for component_id in ["motor", "housing"]
|
||||
for component_id in external_component_ids
|
||||
},
|
||||
expected="external_step",
|
||||
)
|
||||
if requirement.chip_step:
|
||||
chip = manifest.components.get("chip")
|
||||
chip_relation = next(
|
||||
(
|
||||
relation
|
||||
for relation in manifest.relations
|
||||
if relation.relation_id == "chip_fixed_to_external_housing"
|
||||
),
|
||||
None,
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
section="assembly",
|
||||
code="chip_reference_mount_present",
|
||||
passed=chip is not None
|
||||
and chip.placement.source == "inferred_from_reference_assembly_occurrence"
|
||||
and chip_relation is not None,
|
||||
message="chip is included as a real external STEP and fixed to the housing using the reference assembly pose",
|
||||
actual={
|
||||
"component_present": chip is not None,
|
||||
"placement_source": None if chip is None else chip.placement.source,
|
||||
"relation_present": chip_relation is not None,
|
||||
},
|
||||
expected={
|
||||
"component_present": True,
|
||||
"placement_source": "inferred_from_reference_assembly_occurrence",
|
||||
"relation_present": True,
|
||||
},
|
||||
)
|
||||
if requirement.reference_assembly_step and requirement.motor_placement_policy == "reference_pose":
|
||||
motor = manifest.components.get("motor")
|
||||
housing = manifest.components.get("housing")
|
||||
_check(
|
||||
checks,
|
||||
section="assembly",
|
||||
code="motor_housing_reference_pose_used",
|
||||
passed=motor is not None
|
||||
and housing is not None
|
||||
and motor.placement.source == "inferred_from_reference_assembly_occurrence",
|
||||
message="motor pose relative to housing comes from the supplied reference assembly",
|
||||
actual=None if motor is None else motor.placement.source,
|
||||
expected="inferred_from_reference_assembly_occurrence",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
section="static",
|
||||
code="fixed_joint_platform_profile_used",
|
||||
passed=bool(requirement.platform_profile)
|
||||
and requirement.platform_id == "actuator_v2_fixed_platform"
|
||||
and all(component_id in manifest.components for component_id in ["motor", "housing"])
|
||||
and ("chip" in manifest.components) == bool(requirement.chip_step),
|
||||
message="joint module uses the fixed motor and housing platform; chip is optional",
|
||||
actual={
|
||||
"platform_profile": requirement.platform_profile,
|
||||
"platform_id": requirement.platform_id,
|
||||
"external_components": sorted(
|
||||
component_id
|
||||
for component_id in ["motor", "housing", "chip"]
|
||||
if component_id in manifest.components
|
||||
),
|
||||
},
|
||||
expected={
|
||||
"platform_id": "actuator_v2_fixed_platform",
|
||||
"external_components": ["housing", "motor"],
|
||||
},
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
section="static",
|
||||
@@ -466,15 +560,47 @@ def build_joint_module_validation_report(
|
||||
motor = manifest.components.get("motor")
|
||||
motor_placement = manifest.placements.get("motor")
|
||||
if adapter.input_insertion_required(reducer_instance, manifest) and input_shaft and motor and motor_placement:
|
||||
shaft_min, shaft_max = _bbox_axis_range(input_shaft.placed_metrics.bbox, input_axis["direction_xyz"])
|
||||
motor_min, motor_max = _bbox_axis_range(motor.placed_metrics.bbox, input_axis["direction_xyz"])
|
||||
insertion_axis = input_axis["direction_xyz"]
|
||||
recorded_input_axis = motor_placement.details.get("input_axis_direction_xyz")
|
||||
if (
|
||||
isinstance(recorded_input_axis, (list, tuple))
|
||||
and len(recorded_input_axis) == 3
|
||||
and all(isinstance(value, (int, float)) for value in recorded_input_axis)
|
||||
):
|
||||
insertion_axis = tuple(float(value) for value in recorded_input_axis)
|
||||
shaft_min, shaft_max = _bbox_axis_range(input_shaft.placed_metrics.bbox, insertion_axis)
|
||||
motor_min, motor_max = _bbox_axis_range(motor.placed_metrics.bbox, insertion_axis)
|
||||
output_face = motor_placement.details.get("motor_output_bore_face_axis_value_mm")
|
||||
if not isinstance(output_face, (int, float)):
|
||||
output_face = motor_placement.details.get("motor_output_face_axis_value_mm")
|
||||
if not isinstance(output_face, (int, float)):
|
||||
output_side = motor_placement.details.get("motor_output_side", requirement.motor_output_side)
|
||||
output_face = motor_min if output_side == "axis_max" else motor_max
|
||||
insertion_depth = max(0.0, shaft_max - float(output_face))
|
||||
motor_axis_direction = (
|
||||
motor.placed_metrics.inferred_axis.direction_xyz
|
||||
if motor.placed_metrics.inferred_axis is not None
|
||||
else insertion_axis
|
||||
)
|
||||
shaft_points_along_motor_axis = dot(insertion_axis, motor_axis_direction) >= 0.0
|
||||
insertion_depth = (
|
||||
max(0.0, shaft_max - float(output_face))
|
||||
if shaft_points_along_motor_axis
|
||||
else max(0.0, float(output_face) - shaft_min)
|
||||
)
|
||||
recorded_shaft_min = motor_placement.details.get("input_axis_component_axis_min_mm")
|
||||
recorded_shaft_max = motor_placement.details.get("input_axis_component_axis_max_mm")
|
||||
recorded_depth = motor_placement.details.get("input_shaft_insertion_depth_mm")
|
||||
if (
|
||||
isinstance(recorded_shaft_min, (int, float))
|
||||
and isinstance(recorded_shaft_max, (int, float))
|
||||
and isinstance(output_face, (int, float))
|
||||
and isinstance(recorded_depth, (int, float))
|
||||
and (
|
||||
abs(float(recorded_shaft_max) - float(output_face) - float(recorded_depth)) <= max(0.05, requirement.axis_distance_tolerance_mm)
|
||||
or abs(float(output_face) - float(recorded_shaft_min) - float(recorded_depth)) <= max(0.05, requirement.axis_distance_tolerance_mm)
|
||||
)
|
||||
):
|
||||
insertion_depth = max(insertion_depth, float(recorded_depth))
|
||||
bore_radius = None
|
||||
if motor.raw_metrics.inferred_axis is not None:
|
||||
raw_bore_radius = motor.raw_metrics.inferred_axis.details.get("motor_output_bore_radius_mm")
|
||||
@@ -489,10 +615,7 @@ def build_joint_module_validation_report(
|
||||
bore_min = float(raw_bore_min)
|
||||
if isinstance(raw_bore_max, (int, float)):
|
||||
bore_max = float(raw_bore_max)
|
||||
shaft_radius = max(
|
||||
(input_shaft.placed_metrics.bbox[3] - input_shaft.placed_metrics.bbox[0]) / 2.0,
|
||||
(input_shaft.placed_metrics.bbox[4] - input_shaft.placed_metrics.bbox[1]) / 2.0,
|
||||
)
|
||||
shaft_radius = _bbox_radial_radius(input_shaft.placed_metrics.bbox, insertion_axis)
|
||||
_check(
|
||||
checks,
|
||||
section="geometry",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
@@ -511,6 +512,126 @@ def _build_ferguson_wolfrom_urdf_xml(
|
||||
return ET.tostring(robot, encoding="unicode", xml_declaration=True), motion
|
||||
|
||||
|
||||
def _build_simple_2kh_urdf_xml(
|
||||
*,
|
||||
manifest: AssemblyManifest,
|
||||
reducer_instance: ReducerInstance,
|
||||
package_name: str,
|
||||
robot_name: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
motion = _motion_summary(reducer_instance)
|
||||
input_joint = str(motion["input_joint"])
|
||||
input_variable = str(motion["input_variable"])
|
||||
carrier_multiplier = float(motion["multipliers"]["carrier"])
|
||||
input_axis = _component_axis(manifest, input_variable)
|
||||
|
||||
robot = ET.Element("robot", {"name": robot_name})
|
||||
_add_materials(robot)
|
||||
_add_link(robot, link_name="base_link", collision=False)
|
||||
|
||||
def add_component_link(
|
||||
component_id: str,
|
||||
*,
|
||||
parent: str = "base_link",
|
||||
visual_origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
joint_name: str | None = None,
|
||||
joint_type: str = "fixed",
|
||||
axis: tuple[float, float, float] | None = None,
|
||||
mimic: tuple[str, float, float] | None = None,
|
||||
) -> str:
|
||||
component = manifest.components.get(component_id)
|
||||
if component is None:
|
||||
raise ReducerUrdfExportError(f"missing_manifest_component: {component_id}")
|
||||
link_name = f"{component_id}_link"
|
||||
_add_link(
|
||||
robot,
|
||||
link_name=link_name,
|
||||
mesh_filename=_mesh_uri(package_name, component_id),
|
||||
material=_material_for(component),
|
||||
visual_origin_xyz=visual_origin,
|
||||
)
|
||||
_add_joint(
|
||||
robot,
|
||||
name=joint_name or f"{parent}_to_{component_id}",
|
||||
joint_type=joint_type,
|
||||
parent=parent,
|
||||
child=link_name,
|
||||
axis=axis,
|
||||
mimic=mimic,
|
||||
)
|
||||
return link_name
|
||||
|
||||
fixed_base_ids = [
|
||||
component_id
|
||||
for component_id, component in manifest.components.items()
|
||||
if component_id not in {"sun", "carrier"}
|
||||
and not component_id.startswith("planet_")
|
||||
and component_id not in {"carrier_front_plate", "output_shaft", "sun_input_shaft", "sun_input_key"}
|
||||
]
|
||||
for component_id in fixed_base_ids:
|
||||
add_component_link(component_id)
|
||||
|
||||
sun_link = add_component_link(
|
||||
"sun",
|
||||
joint_name=input_joint,
|
||||
joint_type="continuous",
|
||||
axis=input_axis,
|
||||
)
|
||||
for component_id in ["sun_input_shaft", "sun_input_key"]:
|
||||
if component_id in manifest.components:
|
||||
add_component_link(component_id, parent=sun_link)
|
||||
|
||||
carrier_link = add_component_link(
|
||||
"carrier",
|
||||
joint_name="carrier_output_joint",
|
||||
joint_type="continuous",
|
||||
axis=input_axis,
|
||||
mimic=(input_joint, carrier_multiplier, 0.0),
|
||||
)
|
||||
for component_id in ["carrier_front_plate", "output_shaft"]:
|
||||
if component_id in manifest.components:
|
||||
add_component_link(component_id, parent=carrier_link)
|
||||
|
||||
planet_speed = float(motion["speeds"].get("planet", 0.0))
|
||||
carrier_speed = float(motion["speeds"].get("carrier", 0.0))
|
||||
planet_spin_multiplier = planet_speed - carrier_speed
|
||||
for planet_id in sorted(
|
||||
(component_id for component_id in manifest.components if re.fullmatch(r"planet_\d+", component_id)),
|
||||
key=lambda value: int(value.rsplit("_", 1)[-1]),
|
||||
):
|
||||
component = manifest.components[planet_id]
|
||||
index = int(planet_id.rsplit("_", 1)[-1])
|
||||
orbit_link = f"{planet_id}_orbit_link"
|
||||
_add_link(robot, link_name=orbit_link, collision=False)
|
||||
_add_joint(
|
||||
robot,
|
||||
name=f"carrier_to_planet_{index}_orbit",
|
||||
joint_type="fixed",
|
||||
parent=carrier_link,
|
||||
child=orbit_link,
|
||||
origin_xyz=_center_m(component),
|
||||
)
|
||||
_add_link(
|
||||
robot,
|
||||
link_name=f"{planet_id}_link",
|
||||
mesh_filename=_mesh_uri(package_name, planet_id),
|
||||
material=_material_for(component),
|
||||
visual_origin_xyz=_visual_origin_at_component_axis(component),
|
||||
)
|
||||
_add_joint(
|
||||
robot,
|
||||
name=f"{planet_id}_spin_joint",
|
||||
joint_type="continuous",
|
||||
parent=orbit_link,
|
||||
child=f"{planet_id}_link",
|
||||
axis=input_axis,
|
||||
mimic=(input_joint, planet_spin_multiplier, 0.0),
|
||||
)
|
||||
|
||||
ET.indent(robot, space=" ")
|
||||
return ET.tostring(robot, encoding="unicode", xml_declaration=True), motion
|
||||
|
||||
|
||||
def build_reducer_urdf_xml(
|
||||
*,
|
||||
manifest: AssemblyManifest,
|
||||
@@ -527,6 +648,13 @@ def build_reducer_urdf_xml(
|
||||
if robot_name == ROBOT_NAME
|
||||
else robot_name,
|
||||
)
|
||||
if reducer_instance.topology_family == "simple_2k_h":
|
||||
return _build_simple_2kh_urdf_xml(
|
||||
manifest=manifest,
|
||||
reducer_instance=reducer_instance,
|
||||
package_name=package_name,
|
||||
robot_name="simple_2k_h_reducer" if robot_name == ROBOT_NAME else robot_name,
|
||||
)
|
||||
if reducer_instance.topology_family != "simple_2k_h_cascade":
|
||||
raise ReducerUrdfExportError("reducer URDF exporter currently supports simple_2k_h_cascade")
|
||||
|
||||
@@ -819,6 +947,47 @@ def validate_reducer_urdf_export(
|
||||
actual=actual_planet_mimics,
|
||||
expected=expected_planet_mimics,
|
||||
)
|
||||
elif reducer_instance.topology_family == "simple_2k_h":
|
||||
input_joint_name = str(motion["input_joint"])
|
||||
input_joint_element = next(
|
||||
(joint for joint in joints if joint.get("name") == input_joint_name),
|
||||
None,
|
||||
)
|
||||
check(
|
||||
"urdf_simple_input_joint",
|
||||
input_joint_element is not None and input_joint_element.get("type") == "continuous",
|
||||
"sun input is represented by a continuous revolute joint",
|
||||
actual=None if input_joint_element is None else input_joint_element.get("type"),
|
||||
expected="continuous",
|
||||
)
|
||||
carrier_joint = next(
|
||||
(joint for joint in joints if joint.get("name") == "carrier_output_joint"),
|
||||
None,
|
||||
)
|
||||
carrier_mimic = None if carrier_joint is None else carrier_joint.find("mimic")
|
||||
actual_carrier = None if carrier_mimic is None else float(carrier_mimic.get("multiplier", "nan"))
|
||||
expected_carrier = float(motion["multipliers"]["carrier"])
|
||||
check(
|
||||
"urdf_simple_output_ratio",
|
||||
actual_carrier is not None and abs(actual_carrier - expected_carrier) <= 1e-12,
|
||||
"carrier output uses the solved simple 2K-H speed ratio",
|
||||
actual=actual_carrier,
|
||||
expected=expected_carrier,
|
||||
)
|
||||
planet_mimics = {
|
||||
name: float(element.get("multiplier", "nan"))
|
||||
for name, element in mimic_by_joint.items()
|
||||
if name and name.endswith("_spin_joint")
|
||||
}
|
||||
expected_planet_multiplier = float(motion["speeds"]["planet"] - motion["speeds"]["carrier"])
|
||||
check(
|
||||
"urdf_simple_planet_spin_mimics",
|
||||
bool(planet_mimics)
|
||||
and all(abs(value - expected_planet_multiplier) <= 1e-12 for value in planet_mimics.values()),
|
||||
"all simple 2K-H planet spin joints use solved relative speed",
|
||||
actual=planet_mimics,
|
||||
expected=expected_planet_multiplier,
|
||||
)
|
||||
else:
|
||||
for joint_name, variable in [
|
||||
("s1_carrier_output_joint", "s1_carrier"),
|
||||
|
||||
@@ -48,6 +48,7 @@ from ..operations import (
|
||||
make_face_from_wire_rface,
|
||||
make_face_from_wires_rface,
|
||||
make_face_from_sketch_rface,
|
||||
make_polyline_rwire,
|
||||
make_sketch_rsketch,
|
||||
make_spline_redge,
|
||||
make_wire_from_edges_rwire,
|
||||
@@ -1003,6 +1004,163 @@ def _internal_involute_bspline_control_points(
|
||||
)
|
||||
|
||||
|
||||
def _arc_points(
|
||||
radius: float,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
*,
|
||||
steps: int,
|
||||
) -> List[Tuple[float, float]]:
|
||||
"""Sample a circular arc counter-clockwise from start to end."""
|
||||
span = end_angle - start_angle
|
||||
while span <= 0.0:
|
||||
span += 2.0 * math.pi
|
||||
count = max(2, int(steps) + 1)
|
||||
return [
|
||||
(
|
||||
radius * math.cos(start_angle + span * index / (count - 1)),
|
||||
radius * math.sin(start_angle + span * index / (count - 1)),
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _sample_internal_involute_points(
|
||||
base_radius: float,
|
||||
start_radius: float,
|
||||
end_radius: float,
|
||||
start_angle: float,
|
||||
*,
|
||||
mirror: bool,
|
||||
reverse: bool = False,
|
||||
steps: int = 8,
|
||||
) -> List[Tuple[float, float]]:
|
||||
"""Sample the same internal involute used by the B-spline sketch path."""
|
||||
t_start = _involute_t_for_radius(base_radius, start_radius)
|
||||
t_end = _involute_t_for_radius(base_radius, end_radius)
|
||||
count = max(2, int(steps) + 1)
|
||||
samples: List[Tuple[float, float]] = []
|
||||
for index in range(count):
|
||||
frac = index / (count - 1)
|
||||
t = t_start + (t_end - t_start) * frac
|
||||
point = _involute_point(base_radius, t)
|
||||
if mirror:
|
||||
point = (point[0], -point[1])
|
||||
samples.append(_rotate_xy(point, start_angle))
|
||||
return list(reversed(samples)) if reverse else samples
|
||||
|
||||
|
||||
def _append_profile_points(
|
||||
target: List[Tuple[float, float]],
|
||||
points: List[Tuple[float, float]],
|
||||
*,
|
||||
tolerance: float = 1.0e-9,
|
||||
) -> None:
|
||||
for point in points:
|
||||
if target and math.hypot(target[-1][0] - point[0], target[-1][1] - point[1]) <= tolerance:
|
||||
continue
|
||||
target.append(point)
|
||||
|
||||
|
||||
def _build_internal_gear_profile_polyline_wire(
|
||||
n_teeth: int,
|
||||
module: float,
|
||||
pressure_angle: float,
|
||||
backlash: float = 0.0,
|
||||
addendum_factor: float = 1.0,
|
||||
clearance_factor: float = 0.25,
|
||||
) -> Wire:
|
||||
"""Build the internal profile without invoking the sketch solver.
|
||||
|
||||
Large internal gears create hundreds of constrained sketch entities. Some
|
||||
SolveSpace backends fail on those oversized sketches even though the tooth
|
||||
geometry is already fully known analytically. This fallback samples the
|
||||
same involute/root/tip profile into a closed wire so high-tooth-count ring
|
||||
gears remain generatable.
|
||||
"""
|
||||
geo = _compute_internal_tooth_geometry(
|
||||
n_teeth,
|
||||
module,
|
||||
pressure_angle,
|
||||
backlash,
|
||||
addendum_factor=addendum_factor,
|
||||
clearance_factor=clearance_factor,
|
||||
)
|
||||
root_radius = geo["root_radius"]
|
||||
tip_radius = geo["tip_radius"]
|
||||
flank_tip_radius = geo["flank_tip_radius"]
|
||||
base_radius = geo["base_radius"]
|
||||
tooth_angle = geo["tooth_angle"]
|
||||
points: List[Tuple[float, float]] = []
|
||||
|
||||
root_arc_steps = 2 if n_teeth >= 96 else 3
|
||||
flank_steps = 5 if n_teeth >= 96 else 7
|
||||
tip_arc_steps = 2 if n_teeth >= 96 else 3
|
||||
|
||||
for i in range(n_teeth):
|
||||
offset = tooth_angle * i
|
||||
root_arc_start_angle = geo["right_root_angle"] - tooth_angle + offset
|
||||
root_arc_end_angle = geo["left_root_angle"] + offset
|
||||
_append_profile_points(
|
||||
points,
|
||||
_arc_points(
|
||||
root_radius,
|
||||
root_arc_start_angle,
|
||||
root_arc_end_angle,
|
||||
steps=root_arc_steps,
|
||||
),
|
||||
)
|
||||
_append_profile_points(
|
||||
points,
|
||||
_sample_internal_involute_points(
|
||||
base_radius,
|
||||
start_radius=flank_tip_radius,
|
||||
end_radius=root_radius,
|
||||
start_angle=geo["left_base_angle"] + offset,
|
||||
mirror=True,
|
||||
reverse=True,
|
||||
steps=flank_steps,
|
||||
),
|
||||
)
|
||||
left_tip_angle = geo["left_tip_angle"] + offset
|
||||
right_tip_angle = geo["right_tip_angle"] + offset
|
||||
if geo["needs_tip_connectors"]:
|
||||
_append_profile_points(points, [(tip_radius * math.cos(left_tip_angle), tip_radius * math.sin(left_tip_angle))])
|
||||
_append_profile_points(
|
||||
points,
|
||||
_arc_points(tip_radius, left_tip_angle, right_tip_angle, steps=tip_arc_steps),
|
||||
)
|
||||
_append_profile_points(points, [(flank_tip_radius * math.cos(right_tip_angle), flank_tip_radius * math.sin(right_tip_angle))])
|
||||
else:
|
||||
_append_profile_points(
|
||||
points,
|
||||
_arc_points(
|
||||
tip_radius,
|
||||
left_tip_angle,
|
||||
right_tip_angle,
|
||||
steps=tip_arc_steps,
|
||||
),
|
||||
)
|
||||
_append_profile_points(
|
||||
points,
|
||||
_sample_internal_involute_points(
|
||||
base_radius,
|
||||
start_radius=flank_tip_radius,
|
||||
end_radius=root_radius,
|
||||
start_angle=geo["right_base_angle"] + offset,
|
||||
mirror=False,
|
||||
steps=flank_steps,
|
||||
),
|
||||
)
|
||||
|
||||
if len(points) >= 2 and math.hypot(points[0][0] - points[-1][0], points[0][1] - points[-1][1]) <= 1.0e-8:
|
||||
points.pop()
|
||||
return make_polyline_rwire(
|
||||
points=[(float(x), float(y), 0.0) for x, y in points],
|
||||
closed=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_internal_gear_profile_wire(
|
||||
n_teeth: int,
|
||||
module: float,
|
||||
@@ -1206,7 +1364,19 @@ def _build_internal_gear_profile_wire(
|
||||
multiplicities=right_mults,
|
||||
)
|
||||
|
||||
wire = make_wire_from_sketch_rwire(sketch, profile=0)
|
||||
try:
|
||||
wire = make_wire_from_sketch_rwire(sketch, profile=0)
|
||||
except Exception:
|
||||
if n_teeth < 96:
|
||||
raise
|
||||
wire = _build_internal_gear_profile_polyline_wire(
|
||||
n_teeth,
|
||||
module,
|
||||
pressure_angle,
|
||||
backlash=backlash,
|
||||
addendum_factor=addendum_factor,
|
||||
clearance_factor=clearance_factor,
|
||||
)
|
||||
if return_sketch:
|
||||
return wire, sketch
|
||||
return wire
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import read_json, utc_now_iso, write_json
|
||||
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUN_ROOT = BACKEND_ROOT / "output" / "runs"
|
||||
|
||||
|
||||
def new_task_id(prefix: str = "cad") -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def safe_task_id(task_id: str) -> str:
|
||||
normalized = str(task_id or "").strip()
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{4,80}", normalized):
|
||||
raise ValueError("invalid_task_id")
|
||||
return normalized
|
||||
|
||||
|
||||
def task_dir(task_id: str) -> Path:
|
||||
return RUN_ROOT / safe_task_id(task_id)
|
||||
|
||||
|
||||
def tasks_index_path() -> Path:
|
||||
return RUN_ROOT / "cad_tasks_index.json"
|
||||
|
||||
|
||||
def manifest_path(task_id: str) -> Path:
|
||||
return task_dir(task_id) / "cad-task.json"
|
||||
|
||||
|
||||
def relative_to_task(task_id: str, path: Path) -> str:
|
||||
resolved = path.resolve()
|
||||
root = task_dir(task_id).resolve()
|
||||
try:
|
||||
return str(resolved.relative_to(root))
|
||||
except ValueError:
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def artifact_url(task_id: str, path: Path) -> str:
|
||||
return f"/api/tasks/{safe_task_id(task_id)}/artifacts/{relative_to_task(task_id, path)}"
|
||||
|
||||
|
||||
def ensure_task(task_id: str | None = None, request: str = "") -> dict[str, Any]:
|
||||
task = safe_task_id(task_id) if task_id else new_task_id()
|
||||
root = task_dir(task)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for child in ["uploads", "exports", "reducer"]:
|
||||
(root / child).mkdir(parents=True, exist_ok=True)
|
||||
manifest = read_task_manifest(task) or {
|
||||
"schema_version": "1.0",
|
||||
"taskId": task,
|
||||
"createdAt": utc_now_iso(),
|
||||
"updatedAt": utc_now_iso(),
|
||||
"request": request,
|
||||
"status": "created",
|
||||
"kind": "cad_generation",
|
||||
"route": {
|
||||
"selected_backend": "simplecadapi",
|
||||
"effective_execution_engine": "simplecadapi",
|
||||
"runner_skill": "cad-router",
|
||||
"project": "planetary-reducer-system",
|
||||
"fallback_order": ["build123d"],
|
||||
"workflow_profiles": ["requirement_refinement", "visual_repair"],
|
||||
},
|
||||
"source": {},
|
||||
"artifacts": [],
|
||||
"parameters": [],
|
||||
"experience": {
|
||||
"context_kind": "generalized_cad_experience_query",
|
||||
"methods_applied": [],
|
||||
},
|
||||
"assumptions": [],
|
||||
"validation": [],
|
||||
"viewer_links": [],
|
||||
"messages": [],
|
||||
}
|
||||
if request and not manifest.get("request"):
|
||||
manifest["request"] = request
|
||||
write_task_manifest(task, manifest)
|
||||
update_tasks_index(task, manifest)
|
||||
return {"taskId": task, "dir": str(root.resolve()), "manifest": manifest}
|
||||
|
||||
|
||||
def read_task_manifest(task_id: str) -> dict[str, Any] | None:
|
||||
path = manifest_path(task_id)
|
||||
if not path.exists():
|
||||
return None
|
||||
return read_json(path)
|
||||
|
||||
|
||||
def write_task_manifest(task_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = {**manifest, "taskId": safe_task_id(task_id), "updatedAt": utc_now_iso()}
|
||||
write_json(manifest_path(task_id), payload)
|
||||
update_tasks_index(task_id, payload)
|
||||
return payload
|
||||
|
||||
|
||||
def update_task(task_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
current = read_task_manifest(task_id) or ensure_task(task_id)["manifest"]
|
||||
next_manifest = {**current, **patch}
|
||||
return write_task_manifest(task_id, next_manifest)
|
||||
|
||||
|
||||
def update_tasks_index(task_id: str, manifest: dict[str, Any]) -> None:
|
||||
path = tasks_index_path()
|
||||
index = read_json(path) if path.exists() else {"schema_version": "1.0", "tasks": []}
|
||||
tasks = [task for task in index.get("tasks", []) if task.get("taskId") != task_id]
|
||||
tasks.insert(0, {
|
||||
"taskId": task_id,
|
||||
"request": manifest.get("request", ""),
|
||||
"status": manifest.get("status", "unknown"),
|
||||
"kind": manifest.get("kind", "cad_generation"),
|
||||
"updatedAt": manifest.get("updatedAt", utc_now_iso()),
|
||||
"summary": manifest.get("summary", ""),
|
||||
})
|
||||
index["tasks"] = tasks[:50]
|
||||
write_json(path, index)
|
||||
|
||||
|
||||
def list_tasks() -> list[dict[str, Any]]:
|
||||
path = tasks_index_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
return list(read_json(path).get("tasks", []))
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
base = Path(str(filename or "upload.bin")).name
|
||||
return re.sub(r"[^a-zA-Z0-9._-]+", "_", base).lstrip("_") or "upload.bin"
|
||||
|
||||
|
||||
def write_upload(task_id: str, filename: str, data: bytes, mime: str = "application/octet-stream") -> dict[str, Any]:
|
||||
ensure_task(task_id)
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
safe_name = sanitize_filename(filename)
|
||||
relative = Path("uploads") / f"file_{digest[:16]}_{safe_name}"
|
||||
path = task_dir(task_id) / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
attachment = {
|
||||
"id": f"file_{digest[:16]}",
|
||||
"taskId": safe_task_id(task_id),
|
||||
"name": safe_name,
|
||||
"path": str(relative),
|
||||
"size": len(data),
|
||||
"sha256": digest,
|
||||
"mime": mime,
|
||||
"kind": "step" if safe_name.lower().endswith((".step", ".stp")) else "image" if mime.startswith("image/") else "other",
|
||||
"url": artifact_url(task_id, path),
|
||||
}
|
||||
manifest = read_task_manifest(task_id) or ensure_task(task_id)["manifest"]
|
||||
uploads = [item for item in manifest.get("uploads", []) if item.get("id") != attachment["id"]]
|
||||
uploads.append(attachment)
|
||||
update_task(task_id, {"uploads": uploads})
|
||||
return attachment
|
||||
|
||||
|
||||
def copy_artifact(task_id: str, source: Path, target_relative: str) -> dict[str, Any]:
|
||||
target = task_dir(task_id) / target_relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
return {
|
||||
"path": target_relative,
|
||||
"url": artifact_url(task_id, target),
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from .api import create_toolchain_app
|
||||
|
||||
__all__ = ["create_toolchain_app"]
|
||||
@@ -0,0 +1,912 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from ..cli import _prepare_structured_run_dir
|
||||
from ..joint_module.auto_design import DEFAULT_PLATFORM_PROFILE, run_auto_joint_design
|
||||
from ..joint_module.generator import run_joint_module
|
||||
from ..joint_module.urdf_exporter import export_urdf
|
||||
from ..models import read_json, write_json, write_model_json
|
||||
from ..parameter_solver import load_requirement, solve_parameters
|
||||
from ..kinematics import motion_samples, solve_instance
|
||||
from ..placement import solve_reducer_placements
|
||||
from ..cad.simplecad_generator import generate_simplecad
|
||||
from ..reducer_urdf_exporter import export_reducer_urdf
|
||||
from ..topology import build_graph, validate_topology
|
||||
from ..topology_runtime import load_default_template
|
||||
from ..validators import validate_from_files
|
||||
from ..task_store import (
|
||||
BACKEND_ROOT,
|
||||
RUN_ROOT,
|
||||
artifact_url,
|
||||
ensure_task,
|
||||
list_tasks,
|
||||
read_task_manifest,
|
||||
safe_task_id,
|
||||
task_dir,
|
||||
update_task,
|
||||
write_upload,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_RUN_NAME = "frontend_split_motor_7nm"
|
||||
DEFAULT_JOINT_REQUIREMENT = BACKEND_ROOT / "input" / "requirements" / "joint_modules" / "3500_motor_housing.json"
|
||||
JOINT_REQUIREMENT_TEMPLATES = {
|
||||
"simple_2k_h": DEFAULT_JOINT_REQUIREMENT,
|
||||
"simple_2k_h_cascade": BACKEND_ROOT / "input" / "requirements" / "joint_modules" / "3500_motor_housing_cascade.json",
|
||||
}
|
||||
CAD_ROUTER_RECONSTRUCTOR = Path("/Users/lk/Projects/cadSet/text-to-cad/skills/cad-router/scripts/reconstruct_step.py")
|
||||
REDUCER_REQUIREMENT_TEMPLATES = {
|
||||
"simple_2k_h": BACKEND_ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "simple_2kh_ratio_5.json",
|
||||
"simple_2k_h_cascade": BACKEND_ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_spur.json",
|
||||
"ferguson_wolfrom": BACKEND_ROOT / "input" / "requirements" / "reducers" / "ferguson_wolfrom" / "ferguson_wolfrom_ratio_531p25_spur.json",
|
||||
}
|
||||
SUPPORTED_REQUIREMENT_OVERRIDES = {
|
||||
"topology_family",
|
||||
"target_ratio",
|
||||
"tooth_form",
|
||||
"module_mm",
|
||||
"planet_count",
|
||||
"helix_angle_deg",
|
||||
"pressure_angle_deg",
|
||||
"face_width_mm",
|
||||
"backlash_mm",
|
||||
"ring_rim_thickness_mm",
|
||||
"max_outer_diameter_mm",
|
||||
}
|
||||
|
||||
|
||||
def _send_json(handler: BaseHTTPRequestHandler, status: int, payload: object) -> None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||
handler.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def _send_file(handler: BaseHTTPRequestHandler, path: Path) -> None:
|
||||
if not path.exists() or not path.is_file():
|
||||
_send_json(handler, 404, {"error": f"file_not_found: {path}"})
|
||||
return
|
||||
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
body = path.read_bytes()
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", content_type)
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def _read_body(handler: BaseHTTPRequestHandler) -> bytes:
|
||||
length = int(handler.headers.get("Content-Length") or 0)
|
||||
return handler.rfile.read(length) if length else b""
|
||||
|
||||
|
||||
def _read_json(handler: BaseHTTPRequestHandler) -> dict[str, object]:
|
||||
body = _read_body(handler)
|
||||
if not body.strip():
|
||||
return {}
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request_body_must_be_json_object")
|
||||
return payload
|
||||
|
||||
|
||||
def _safe_relative_file(task_id: str, relative: str) -> Path:
|
||||
root = task_dir(task_id).resolve()
|
||||
path = (root / unquote(relative)).resolve()
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid_artifact_path") from exc
|
||||
return path
|
||||
|
||||
|
||||
def _first_number(text: str, patterns: list[str], *, integer: bool = False) -> int | float | None:
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
value = float(match.group(1))
|
||||
return int(value) if integer else value
|
||||
return None
|
||||
|
||||
|
||||
def _request_requirement_overrides(request: str) -> dict[str, object]:
|
||||
text = str(request or "")
|
||||
overrides: dict[str, object] = {}
|
||||
if re.search(r"(双级|两级|二级|串联|cascade|two[- ]stage)", text, flags=re.IGNORECASE):
|
||||
overrides["topology_family"] = "simple_2k_h_cascade"
|
||||
elif re.search(r"(ferguson|wolfrom|沃尔夫罗姆|弗格森)", text, flags=re.IGNORECASE):
|
||||
overrides["topology_family"] = "ferguson_wolfrom"
|
||||
elif re.search(r"(单级|simple[_ -]?2k[_ -]?h)", text, flags=re.IGNORECASE):
|
||||
overrides["topology_family"] = "simple_2k_h"
|
||||
|
||||
tooth_form_match = re.search(r"(斜齿|helical|直齿|spur)", text, flags=re.IGNORECASE)
|
||||
if tooth_form_match:
|
||||
overrides["tooth_form"] = (
|
||||
"helical"
|
||||
if re.search(r"(斜齿|helical)", tooth_form_match.group(1), flags=re.IGNORECASE)
|
||||
else "spur"
|
||||
)
|
||||
|
||||
numeric_patterns: dict[str, list[str]] = {
|
||||
"target_ratio": [
|
||||
r"(?:传动比|减速比|速比|ratio)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
r"([0-9]+(?:\.[0-9]+)?)\s*(?::|:)\s*1\s*(?:传动比|减速比)?",
|
||||
],
|
||||
"module_mm": [
|
||||
r"(?:齿轮)?模数\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
r"module(?:_mm)?\s*(?:=|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
],
|
||||
"planet_count": [
|
||||
r"(?:行星轮数量|行星轮数|行星轮)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+)",
|
||||
r"([0-9]+)\s*(?:个|颗|只)?\s*行星轮",
|
||||
r"planet_count\s*(?:=|:|to)?\s*([0-9]+)",
|
||||
],
|
||||
"helix_angle_deg": [
|
||||
r"(?:螺旋角|helix_angle(?:_deg)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
],
|
||||
"pressure_angle_deg": [
|
||||
r"(?:压力角|pressure_angle(?:_deg)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
],
|
||||
"face_width_mm": [
|
||||
r"(?:齿宽|face_width(?:_mm)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
],
|
||||
"backlash_mm": [
|
||||
r"(?:齿侧间隙|侧隙|backlash(?:_mm)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
],
|
||||
"ring_rim_thickness_mm": [
|
||||
r"(?:齿圈轮缘厚度|轮缘厚度|ring_rim_thickness(?:_mm)?)\s*(?:为|是|设为|=|:|:|to)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
],
|
||||
"max_outer_diameter_mm": [
|
||||
r"(?:最大外径|外径)\s*(?:小于|不超过|限制为|为|是|=|:|:|to|≤|<=)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:mm|毫米)?",
|
||||
r"max_outer_diameter(?:_mm)?\s*(?:=|:|to|<=)?\s*([0-9]+(?:\.[0-9]+)?)",
|
||||
],
|
||||
}
|
||||
for name, patterns in numeric_patterns.items():
|
||||
value = _first_number(text, patterns, integer=name == "planet_count")
|
||||
if value is not None:
|
||||
overrides[name] = value
|
||||
return overrides
|
||||
|
||||
|
||||
def _normalized_requirement_overrides(payload: dict[str, object]) -> dict[str, object]:
|
||||
overrides = _request_requirement_overrides(str(payload.get("request") or ""))
|
||||
explicit = payload.get("requirementOverrides")
|
||||
if explicit is not None and not isinstance(explicit, dict):
|
||||
raise ValueError("requirementOverrides_must_be_an_object")
|
||||
if isinstance(explicit, dict):
|
||||
unknown = sorted(set(explicit) - SUPPORTED_REQUIREMENT_OVERRIDES)
|
||||
if unknown:
|
||||
raise ValueError(f"unsupported_requirement_overrides: {unknown}")
|
||||
overrides.update({key: value for key, value in explicit.items() if value is not None})
|
||||
return overrides
|
||||
|
||||
|
||||
def _apply_requirement_overrides(
|
||||
requirement: dict[str, object],
|
||||
overrides: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
result = json.loads(json.dumps(requirement))
|
||||
topology = str(overrides.get("topology_family") or result.get("topology_family") or "simple_2k_h")
|
||||
result["topology_family"] = topology
|
||||
|
||||
direct_fields = {
|
||||
"target_ratio",
|
||||
"tooth_form",
|
||||
"helix_angle_deg",
|
||||
"pressure_angle_deg",
|
||||
"face_width_mm",
|
||||
"backlash_mm",
|
||||
"ring_rim_thickness_mm",
|
||||
}
|
||||
for name in direct_fields:
|
||||
if name in overrides:
|
||||
result[name] = overrides[name]
|
||||
if "module_mm" in overrides:
|
||||
result["module_candidates_mm"] = [float(overrides["module_mm"])]
|
||||
if "planet_count" in overrides:
|
||||
result["planet_count_candidates"] = [int(overrides["planet_count"])]
|
||||
if "max_outer_diameter_mm" in overrides:
|
||||
constraints = dict(result.get("constraints") or {})
|
||||
constraints["max_outer_diameter_mm"] = float(overrides["max_outer_diameter_mm"])
|
||||
result["constraints"] = constraints
|
||||
|
||||
if result.get("tooth_form") == "helical" and not result.get("helix_angle_deg"):
|
||||
result["helix_angle_deg"] = 15.0
|
||||
if result.get("tooth_form") == "spur":
|
||||
result.pop("helix_angle_deg", None)
|
||||
|
||||
stages = result.get("stages")
|
||||
if isinstance(stages, list):
|
||||
for stage_value in stages:
|
||||
if not isinstance(stage_value, dict):
|
||||
continue
|
||||
if "module_mm" in overrides:
|
||||
stage_value["module_candidates_mm"] = [float(overrides["module_mm"])]
|
||||
if "planet_count" in overrides:
|
||||
stage_value["planet_count_candidates"] = [int(overrides["planet_count"])]
|
||||
for name in {
|
||||
"tooth_form",
|
||||
"helix_angle_deg",
|
||||
"pressure_angle_deg",
|
||||
"face_width_mm",
|
||||
"backlash_mm",
|
||||
"ring_rim_thickness_mm",
|
||||
}:
|
||||
if name in overrides:
|
||||
stage_value[name] = overrides[name]
|
||||
if result.get("tooth_form") == "spur":
|
||||
stage_value.pop("helix_angle_deg", None)
|
||||
return result
|
||||
|
||||
|
||||
def _materialize_reducer_requirement(
|
||||
task_id: str,
|
||||
payload: dict[str, object],
|
||||
) -> tuple[Path, dict[str, object]]:
|
||||
overrides = _normalized_requirement_overrides(payload)
|
||||
requested_path = payload.get("requirementPath")
|
||||
if requested_path:
|
||||
base_path = Path(str(requested_path))
|
||||
if not base_path.is_absolute():
|
||||
base_path = BACKEND_ROOT / base_path
|
||||
else:
|
||||
topology = str(overrides.get("topology_family") or "simple_2k_h")
|
||||
if topology not in REDUCER_REQUIREMENT_TEMPLATES:
|
||||
raise ValueError(f"unsupported_topology_family: {topology}")
|
||||
base_path = REDUCER_REQUIREMENT_TEMPLATES[topology]
|
||||
requirement = read_json(base_path)
|
||||
if overrides:
|
||||
requirement = _apply_requirement_overrides(requirement, overrides)
|
||||
topology = str(requirement.get("topology_family") or "")
|
||||
if topology == "ferguson_wolfrom":
|
||||
requested_ratio = float(requirement.get("target_ratio") or 0)
|
||||
if abs(requested_ratio - 531.25) > 1e-9:
|
||||
raise ValueError(
|
||||
"ferguson_wolfrom currently supports only the validated ratio 531.25"
|
||||
)
|
||||
target = task_dir(task_id) / "requirements" / "active_reducer_requirement.json"
|
||||
write_json(target, requirement)
|
||||
return target, overrides
|
||||
|
||||
|
||||
def _joint_reducer_requirement_path(joint_requirement_path: Path) -> Path:
|
||||
payload = read_json(joint_requirement_path)
|
||||
reducer_path = Path(str(payload.get("reducer_requirement") or ""))
|
||||
if not str(reducer_path):
|
||||
raise ValueError("joint_requirement_has_no_reducer_requirement")
|
||||
if not reducer_path.is_absolute():
|
||||
reducer_path = joint_requirement_path.parent / reducer_path
|
||||
return reducer_path.resolve()
|
||||
|
||||
|
||||
def _materialize_joint_requirement(
|
||||
task_id: str,
|
||||
joint_requirement_path: Path,
|
||||
overrides: dict[str, object],
|
||||
) -> tuple[Path, Path]:
|
||||
joint_requirement = read_json(joint_requirement_path)
|
||||
reducer_path = _joint_reducer_requirement_path(joint_requirement_path)
|
||||
reducer_requirement = _apply_requirement_overrides(read_json(reducer_path), overrides)
|
||||
reducer_target = task_dir(task_id) / "requirements" / "active_reducer_requirement.json"
|
||||
joint_target = task_dir(task_id) / "requirements" / "active_joint_requirement.json"
|
||||
write_json(reducer_target, reducer_requirement)
|
||||
joint_requirement["reducer_requirement"] = str(reducer_target.resolve())
|
||||
if "platform_profile" in joint_requirement:
|
||||
profile_path = Path(str(joint_requirement["platform_profile"]))
|
||||
if not profile_path.is_absolute():
|
||||
profile_path = (joint_requirement_path.parent / profile_path).resolve()
|
||||
joint_requirement["platform_profile"] = str(profile_path)
|
||||
write_json(joint_target, joint_requirement)
|
||||
return joint_target, reducer_target
|
||||
|
||||
|
||||
def _project_summary(run_name: str) -> dict[str, object]:
|
||||
run = RUN_ROOT / run_name
|
||||
formula = read_json(run / "reducer" / "formula" / "formula_instance.json")
|
||||
kinematics = read_json(run / "reducer" / "kinematics" / "kinematic_solution.json")
|
||||
reducer_validation = read_json(run / "reducer" / "validation" / "validation_report.json")
|
||||
joint_validation_path = run / "joint_module_validation_report.json"
|
||||
joint_validation = read_json(joint_validation_path) if joint_validation_path.exists() else {"summary": {"total": 0, "passed": 0, "failed": 0}, "run_id": ""}
|
||||
return {
|
||||
"source": f"output/runs/{run_name}",
|
||||
"runId": joint_validation.get("run_id") or formula.get("run_id"),
|
||||
"ratio": formula["derived"]["ratio"],
|
||||
"parameters": formula["parameters"],
|
||||
"derived": formula["derived"],
|
||||
"speeds": kinematics["speeds"],
|
||||
"reducerValidation": reducer_validation["summary"],
|
||||
"jointValidation": joint_validation["summary"],
|
||||
}
|
||||
|
||||
|
||||
def _artifact_record(task_id: str, name: str, path: Path, role: str, kind: str) -> dict[str, object]:
|
||||
return {
|
||||
"name": name,
|
||||
"role": role,
|
||||
"kind": kind,
|
||||
"path": str(path.resolve()),
|
||||
"url": artifact_url(task_id, path),
|
||||
"size": path.stat().st_size if path.exists() else 0,
|
||||
}
|
||||
|
||||
|
||||
def _summarize_reducer_task(task_id: str, run_dir: Path, request: str) -> dict[str, object]:
|
||||
formula = read_json(run_dir / "formula" / "formula_instance.json")
|
||||
validation = read_json(run_dir / "validation" / "validation_report.json")
|
||||
manifest = read_json(run_dir / "cad" / "assembly_manifest.json")
|
||||
artifacts = [
|
||||
_artifact_record(task_id, "reducer.step", run_dir / "cad" / "reducer.step", "primary", "step"),
|
||||
_artifact_record(task_id, "assembly_manifest.json", run_dir / "cad" / "assembly_manifest.json", "manifest", "json"),
|
||||
_artifact_record(task_id, "formula_instance.json", run_dir / "formula" / "formula_instance.json", "formula", "json"),
|
||||
_artifact_record(task_id, "validation_report.json", run_dir / "validation" / "validation_report.json", "validation", "json"),
|
||||
]
|
||||
return {
|
||||
"status": "success" if validation.get("overall_passed") else "error",
|
||||
"kind": "reducer",
|
||||
"request": request,
|
||||
"summary": f"{formula['topology_family']} reducer ratio={formula['derived']['ratio']}",
|
||||
"ratio": formula["derived"]["ratio"],
|
||||
"parameters": formula["parameters"],
|
||||
"derived": formula["derived"],
|
||||
"validation": validation,
|
||||
"assemblyManifest": manifest,
|
||||
"artifacts": artifacts,
|
||||
"route": {
|
||||
"selected_backend": "simplecadapi",
|
||||
"effective_execution_engine": "simplecadapi",
|
||||
"runner_skill": "cad-router",
|
||||
"project": "planetary-reducer-system",
|
||||
"fallback_order": ["build123d"],
|
||||
"workflow_profiles": ["requirement_refinement", "visual_repair"],
|
||||
},
|
||||
"source": {
|
||||
"path": "formula/formula_instance.json",
|
||||
"format": "json",
|
||||
"backend": "simplecadapi",
|
||||
"requirement_path": "input_snapshot",
|
||||
},
|
||||
"validation": [validation],
|
||||
}
|
||||
|
||||
|
||||
def _summarize_joint_task(task_id: str, run_dir: Path, request: str) -> dict[str, object]:
|
||||
joint_dir = run_dir / "joint" if (run_dir / "joint").exists() else run_dir
|
||||
reducer_dir = joint_dir / "reducer"
|
||||
formula = read_json(reducer_dir / "formula" / "formula_instance.json")
|
||||
reducer_validation = read_json(reducer_dir / "validation" / "validation_report.json")
|
||||
joint_validation = read_json(joint_dir / "joint_module_validation_report.json")
|
||||
joint_manifest = read_json(joint_dir / "joint_module_manifest.json")
|
||||
artifacts = [
|
||||
_artifact_record(task_id, "joint_module.step", joint_dir / "joint_module.step", "primary", "step"),
|
||||
_artifact_record(task_id, "joint_module_manifest.json", joint_dir / "joint_module_manifest.json", "manifest", "json"),
|
||||
_artifact_record(task_id, "joint_module_validation_report.json", joint_dir / "joint_module_validation_report.json", "validation", "json"),
|
||||
_artifact_record(task_id, "formula_instance.json", reducer_dir / "formula" / "formula_instance.json", "formula", "json"),
|
||||
]
|
||||
exports_dir = run_dir / "exports" / "urdf"
|
||||
if (exports_dir / "joint_module.urdf").exists():
|
||||
artifacts.append(_artifact_record(task_id, "joint_module.urdf", exports_dir / "joint_module.urdf", "viewer", "urdf"))
|
||||
return {
|
||||
"status": "success" if joint_validation.get("overall_passed") else "error",
|
||||
"kind": "joint_module",
|
||||
"request": request,
|
||||
"summary": f"planetary joint module ratio={formula['derived']['ratio']}",
|
||||
"ratio": formula["derived"]["ratio"],
|
||||
"parameters": formula["parameters"],
|
||||
"derived": formula["derived"],
|
||||
"reducerValidation": reducer_validation,
|
||||
"jointValidation": joint_validation,
|
||||
"jointManifest": joint_manifest,
|
||||
"artifacts": artifacts,
|
||||
"route": {
|
||||
"selected_backend": "simplecadapi",
|
||||
"effective_execution_engine": "simplecadapi",
|
||||
"runner_skill": "cad-router",
|
||||
"project": "planetary-reducer-system",
|
||||
"fallback_order": ["build123d"],
|
||||
"workflow_profiles": ["requirement_refinement", "visual_repair"],
|
||||
},
|
||||
"source": {
|
||||
"path": "joint/reducer/formula/formula_instance.json" if (run_dir / "joint").exists() else "reducer/formula/formula_instance.json",
|
||||
"format": "json",
|
||||
"backend": "simplecadapi",
|
||||
"requirement_path": "generated_joint_requirement.json",
|
||||
},
|
||||
"validation": [reducer_validation, joint_validation],
|
||||
}
|
||||
|
||||
|
||||
def _write_zip(source_dir: Path, target: Path) -> Path:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, path.relative_to(source_dir))
|
||||
return target
|
||||
|
||||
|
||||
def _find_requirement_snapshot(task_id: str) -> Path:
|
||||
snapshot_dir = task_dir(task_id) / "input_snapshot"
|
||||
candidates = sorted(snapshot_dir.glob("*.json"))
|
||||
if not candidates:
|
||||
candidates = sorted((task_dir(task_id) / "joint").glob("**/input_snapshot/*.json"))
|
||||
if not candidates:
|
||||
raise ValueError("task_has_no_requirement_snapshot")
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _parameter_patch(parameter: str, value: object) -> dict[str, object]:
|
||||
name = str(parameter or "").strip()
|
||||
if not name:
|
||||
raise ValueError("parameter_is_required")
|
||||
if name in {"module", "module_mm"}:
|
||||
return {"module_candidates_mm": [float(value)]}
|
||||
if name in {"planet_count", "planetCount"}:
|
||||
return {"planet_count_candidates": [int(value)]}
|
||||
if name in {"target_ratio", "ratio"}:
|
||||
return {"target_ratio": float(value)}
|
||||
if name in {"tooth_form", "toothForm"}:
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized not in {"spur", "helical"}:
|
||||
raise ValueError("tooth_form_must_be_spur_or_helical")
|
||||
return {"tooth_form": normalized}
|
||||
if name in {"helix_angle_deg", "pressure_angle_deg", "face_width_mm", "backlash_mm", "ring_rim_thickness_mm"}:
|
||||
return {name: float(value)}
|
||||
raise ValueError(f"parameter_not_editable: {name}")
|
||||
|
||||
|
||||
def edit_task_parameter(task_id: str, parameter: str, value: object) -> dict[str, object]:
|
||||
task = read_task_manifest(task_id)
|
||||
if not task:
|
||||
raise ValueError("task_not_found")
|
||||
kind = str(task.get("kind") or "")
|
||||
if kind not in {"reducer", "joint_module"}:
|
||||
raise ValueError("parameter_edit_requires_generated_reducer_or_joint_task")
|
||||
run_dir = task_dir(task_id)
|
||||
active_requirement = task.get("activeReducerRequirementPath")
|
||||
requirement_path = Path(str(active_requirement)) if active_requirement else (
|
||||
run_dir / "generated_reducer_requirement.json"
|
||||
if kind == "joint_module" and (run_dir / "generated_reducer_requirement.json").exists()
|
||||
else _find_requirement_snapshot(task_id)
|
||||
)
|
||||
requirement = read_json(requirement_path)
|
||||
requirement.update(_parameter_patch(parameter, value))
|
||||
revision = int(task.get("revision") or 0) + 1
|
||||
edited_requirement = run_dir / "revisions" / f"reducer_requirement_{revision}.json"
|
||||
edited_requirement.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_json(edited_requirement, requirement)
|
||||
request = f"{task.get('request', 'CAD task')} · edit {parameter}={value}"
|
||||
if kind == "joint_module":
|
||||
joint_requirement_path = run_dir / "generated_joint_requirement.json"
|
||||
if not joint_requirement_path.exists():
|
||||
raise ValueError("joint_task_has_no_generated_joint_requirement")
|
||||
joint_requirement = read_json(joint_requirement_path)
|
||||
joint_requirement["reducer_requirement"] = str(edited_requirement.resolve())
|
||||
edited_joint_requirement = run_dir / "revisions" / f"joint_requirement_{revision}.json"
|
||||
write_json(edited_joint_requirement, joint_requirement)
|
||||
payload = {
|
||||
"request": request,
|
||||
"requirementPath": str(edited_joint_requirement),
|
||||
"mode": "requirement",
|
||||
"kind": "joint_module",
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"request": request,
|
||||
"requirementPath": str(edited_requirement),
|
||||
"mode": "requirement",
|
||||
"kind": "reducer",
|
||||
}
|
||||
update_task(task_id, {
|
||||
"status": "running",
|
||||
"revision": revision,
|
||||
"parentTaskId": task_id,
|
||||
"editedParameter": {"name": parameter, "value": value},
|
||||
})
|
||||
result = (
|
||||
generate_joint_task(task_id, payload)
|
||||
if kind == "joint_module"
|
||||
else generate_reducer_task(task_id, payload)
|
||||
)
|
||||
return update_task(task_id, {
|
||||
**result,
|
||||
"editedParameter": {"name": parameter, "value": value},
|
||||
"revision": revision,
|
||||
"activeReducerRequirementPath": str(edited_requirement.resolve()),
|
||||
})
|
||||
|
||||
|
||||
def reconstruct_uploaded_task(task_id: str, request: str = "重建上传的 STEP") -> dict[str, object]:
|
||||
manifest = read_task_manifest(task_id)
|
||||
if not manifest:
|
||||
raise ValueError("task_not_found")
|
||||
uploads = [item for item in manifest.get("uploads", []) if item.get("kind") == "step"]
|
||||
if not uploads:
|
||||
raise ValueError("step_upload_required")
|
||||
attachment = uploads[-1]
|
||||
source = task_dir(task_id) / str(attachment["path"])
|
||||
if not source.exists():
|
||||
raise ValueError("uploaded_step_not_found")
|
||||
if not CAD_ROUTER_RECONSTRUCTOR.exists():
|
||||
raise ValueError(f"cad_router_reconstructor_not_found: {CAD_ROUTER_RECONSTRUCTOR}")
|
||||
teacher_root = RUN_ROOT / "_reconstruction_sources" / safe_task_id(task_id)
|
||||
teacher_root.mkdir(parents=True, exist_ok=True)
|
||||
teacher = teacher_root / source.name
|
||||
shutil.copy2(source, teacher)
|
||||
revision = int(manifest.get("reconstructionRevision") or 0) + 1
|
||||
reconstruction_dir = task_dir(task_id) / "revisions" / f"reconstruction_{revision}"
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(CAD_ROUTER_RECONSTRUCTOR),
|
||||
"--source",
|
||||
str(teacher),
|
||||
"--task-dir",
|
||||
str(reconstruction_dir),
|
||||
"--request",
|
||||
request,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=900,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or completed.stdout.strip()
|
||||
raise RuntimeError(f"cad_router_reconstruction_failed: {detail[-3000:]}")
|
||||
lines = [line for line in completed.stdout.splitlines() if line.strip()]
|
||||
result = json.loads(lines[-1]) if lines else {}
|
||||
artifact_paths = []
|
||||
for path in sorted(reconstruction_dir.rglob("*")):
|
||||
if path.is_file() and path.name not in {"cad-task.json"}:
|
||||
artifact_paths.append(_artifact_record(task_id, path.name, path, "reconstruction", path.suffix.lstrip(".") or "file"))
|
||||
return update_task(task_id, {
|
||||
"status": "success",
|
||||
"kind": "step_reconstruction",
|
||||
"request": request,
|
||||
"route": {
|
||||
"selected_backend": result.get("selected_backend", "build123d"),
|
||||
"effective_execution_engine": "surfaceir",
|
||||
"runner_skill": "cad-router",
|
||||
"project": "text-to-cad",
|
||||
},
|
||||
"source": {
|
||||
"path": str(attachment["path"]),
|
||||
"format": "step",
|
||||
"backend": "uploaded",
|
||||
"source_editability": "reconstruction_reference",
|
||||
},
|
||||
"reconstruction": result,
|
||||
"reconstructionRevision": revision,
|
||||
"artifacts": artifact_paths,
|
||||
})
|
||||
|
||||
|
||||
def export_task_robot(task_id: str, format_name: str) -> dict[str, object]:
|
||||
manifest = read_task_manifest(task_id)
|
||||
if not manifest:
|
||||
raise ValueError("task_not_found")
|
||||
if format_name not in {"urdf", "mjcf"}:
|
||||
raise ValueError("export_format_must_be_urdf_or_mjcf")
|
||||
run_dir = task_dir(task_id)
|
||||
urdf_output = run_dir / "exports" / "urdf"
|
||||
if manifest.get("kind") == "reducer":
|
||||
export_manifest = export_reducer_urdf(
|
||||
assembly_manifest_path=run_dir / "cad" / "assembly_manifest.json",
|
||||
reducer_instance_path=run_dir / "formula" / "formula_instance.json",
|
||||
out_dir=urdf_output,
|
||||
)
|
||||
elif manifest.get("kind") == "joint_module":
|
||||
export_manifest = export_urdf(
|
||||
joint_manifest_path=run_dir / "joint" / "joint_module_manifest.json" if (run_dir / "joint").exists() else run_dir / "joint_module_manifest.json",
|
||||
reducer_instance_path=run_dir / "joint" / "reducer" / "formula" / "formula_instance.json" if (run_dir / "joint").exists() else run_dir / "reducer" / "formula" / "formula_instance.json",
|
||||
out_dir=urdf_output,
|
||||
)
|
||||
else:
|
||||
raise ValueError("robot_export_requires_generated_task")
|
||||
if format_name == "mjcf":
|
||||
try:
|
||||
import mujoco # type: ignore
|
||||
import pymeshlab # type: ignore
|
||||
except ImportError as exc:
|
||||
raise ValueError("mjcf_export_requires_mujoco_and_pymeshlab") from exc
|
||||
output = run_dir / "exports" / "mjcf"
|
||||
mesh_dir = output / "meshes"
|
||||
mesh_dir.mkdir(parents=True, exist_ok=True)
|
||||
for source in sorted((urdf_output / "meshes").glob("*.stl")):
|
||||
mesh_set = pymeshlab.MeshSet()
|
||||
mesh_set.load_new_mesh(str(source))
|
||||
mesh_set.save_current_mesh(str(mesh_dir / source.name), binary=True)
|
||||
urdf_path = (
|
||||
urdf_output / "joint_module.urdf"
|
||||
if (urdf_output / "joint_module.urdf").exists()
|
||||
else urdf_output / "reducer.urdf"
|
||||
)
|
||||
if not urdf_path.exists():
|
||||
raise ValueError("urdf_intermediate_not_found")
|
||||
root = ET.fromstring(urdf_path.read_text(encoding="utf-8"))
|
||||
for mesh in root.findall(".//mesh"):
|
||||
filename = str(mesh.get("filename") or "")
|
||||
mesh.set("filename", str((mesh_dir / Path(filename).name).resolve()))
|
||||
intermediate = output / "mujoco-input.urdf"
|
||||
intermediate.write_text(
|
||||
ET.tostring(root, encoding="unicode", xml_declaration=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
description = output / "robot.mjcf.xml"
|
||||
model = mujoco.MjModel.from_xml_path(str(intermediate))
|
||||
mujoco.mj_saveLastXML(str(description), model)
|
||||
intermediate.unlink(missing_ok=True)
|
||||
validation = {
|
||||
"engine": f"mujoco {mujoco.__version__}",
|
||||
"parsed": True,
|
||||
"body_count": int(model.nbody),
|
||||
"joint_count": int(model.njnt),
|
||||
"mesh_count": int(model.nmesh),
|
||||
}
|
||||
write_json(output / "mjcf_validation.json", validation)
|
||||
package = _write_zip(output, run_dir / "exports" / "mjcf.zip")
|
||||
export_manifest = {
|
||||
"format": "mjcf",
|
||||
"description_path": str(description.resolve()),
|
||||
"validation": validation,
|
||||
}
|
||||
else:
|
||||
output = urdf_output
|
||||
package = _write_zip(output, run_dir / "exports" / "urdf.zip")
|
||||
artifacts = list(manifest.get("artifacts", []))
|
||||
for path in [
|
||||
package,
|
||||
output / "joint_module.urdf",
|
||||
output / "reducer.urdf",
|
||||
output / "robot.mjcf.xml",
|
||||
output / "urdf_export_manifest.json",
|
||||
output / "export_manifest.json",
|
||||
output / "mjcf_validation.json",
|
||||
]:
|
||||
if path.exists():
|
||||
artifacts.append(_artifact_record(task_id, path.name, path, "robot_export", path.suffix.lstrip(".") or "file"))
|
||||
return update_task(task_id, {
|
||||
"status": "success",
|
||||
"artifacts": artifacts,
|
||||
"robotExport": {
|
||||
"format": format_name,
|
||||
"packageUrl": artifact_url(task_id, package),
|
||||
"manifest": export_manifest,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def generate_reducer_task(task_id: str, payload: dict[str, object]) -> dict[str, object]:
|
||||
request = str(payload.get("request") or "Generate planetary reducer")
|
||||
requirement_path, overrides = _materialize_reducer_requirement(task_id, payload)
|
||||
run_dir = task_dir(task_id)
|
||||
paths = _prepare_structured_run_dir(run_dir)
|
||||
requirement = load_requirement(requirement_path)
|
||||
template = load_default_template(requirement.topology_family)
|
||||
topology_errors = validate_topology(template, requirement)
|
||||
if topology_errors:
|
||||
raise ValueError(f"topology_invalid: {topology_errors}")
|
||||
instance, report = solve_parameters(requirement=requirement, template=template)
|
||||
solution = solve_instance(instance)
|
||||
samples = motion_samples(instance, solution)
|
||||
placement_plan = solve_reducer_placements(instance)
|
||||
shutil.copy2(requirement_path, paths["input_snapshot"] / requirement_path.name)
|
||||
write_model_json(paths["formula"] / "formula_instance.json", instance)
|
||||
write_model_json(paths["formula"] / "parameter_search_report.json", report)
|
||||
write_model_json(paths["kinematics"] / "kinematic_solution.json", solution)
|
||||
write_model_json(paths["kinematics"] / "motion_samples.json", samples)
|
||||
write_model_json(paths["placement"] / "placement_plan.json", placement_plan)
|
||||
if instance.industrial_parameters is not None:
|
||||
write_model_json(paths["formula"] / "industrial_parameters.json", instance.industrial_parameters)
|
||||
if instance.assembly_topology is not None:
|
||||
write_model_json(paths["formula"] / "assembly_topology.json", instance.assembly_topology)
|
||||
generate_simplecad(instance, paths["cad"])
|
||||
if (paths["cad"] / "placement_plan.json").exists():
|
||||
shutil.copy2(paths["cad"] / "placement_plan.json", paths["placement"] / "placement_plan.json")
|
||||
validate_from_files(
|
||||
instance=instance,
|
||||
model_path=paths["cad"] / "reducer.model.json",
|
||||
manifest_path=paths["cad"] / "assembly_manifest.json",
|
||||
build_meta_path=paths["cad"] / "build_meta.json",
|
||||
snapshot_out=paths["validation"] / "assembly_snapshot.json",
|
||||
report_out=paths["validation"] / "validation_report.json",
|
||||
)
|
||||
result = _summarize_reducer_task(task_id, run_dir, request)
|
||||
return update_task(task_id, {
|
||||
**result,
|
||||
"requirementOverrides": overrides,
|
||||
"activeReducerRequirementPath": str(requirement_path.resolve()),
|
||||
})
|
||||
|
||||
|
||||
def generate_joint_task(task_id: str, payload: dict[str, object]) -> dict[str, object]:
|
||||
request = str(payload.get("request") or "Generate planetary joint module")
|
||||
run_dir = task_dir(task_id)
|
||||
joint_dir = run_dir / "joint"
|
||||
mode = str(payload.get("mode") or "auto")
|
||||
overrides = _normalized_requirement_overrides(payload)
|
||||
topology = str(overrides.get("topology_family") or "simple_2k_h")
|
||||
if topology not in JOINT_REQUIREMENT_TEMPLATES:
|
||||
raise ValueError(f"joint_module currently supports only {sorted(JOINT_REQUIREMENT_TEMPLATES)}")
|
||||
if mode == "requirement":
|
||||
requirement_path = Path(str(payload.get("requirementPath") or JOINT_REQUIREMENT_TEMPLATES[topology]))
|
||||
if not requirement_path.is_absolute():
|
||||
requirement_path = BACKEND_ROOT / requirement_path
|
||||
active_joint_requirement, active_reducer_requirement = _materialize_joint_requirement(
|
||||
task_id,
|
||||
requirement_path,
|
||||
overrides,
|
||||
)
|
||||
run_joint_module(requirement_path=active_joint_requirement, out_dir=joint_dir)
|
||||
else:
|
||||
platform_profile = Path(str(payload.get("platformProfile") or DEFAULT_PLATFORM_PROFILE))
|
||||
if not platform_profile.is_absolute():
|
||||
platform_profile = BACKEND_ROOT / platform_profile
|
||||
legacy_motor_step = payload.get("motorStep")
|
||||
legacy_housing_source = payload.get("housingPython")
|
||||
run_auto_joint_design(
|
||||
out_dir=run_dir,
|
||||
platform_profile=platform_profile,
|
||||
motor_step=Path(str(legacy_motor_step)) if legacy_motor_step else None,
|
||||
housing_source=Path(str(legacy_housing_source)) if legacy_housing_source else None,
|
||||
tooth_form=str(payload.get("toothForm") or "spur"),
|
||||
radial_clearance_mm=float(payload.get("radialClearanceMm") or 14.0),
|
||||
reducer_axial_allowance_mm=float(payload.get("reducerAxialAllowanceMm") or 42.0),
|
||||
reducer_radial_clearance_mm=float(payload.get("reducerRadialClearanceMm") or 8.0),
|
||||
reducer_requirement_overrides=overrides,
|
||||
)
|
||||
reducer_dir = joint_dir / "reducer"
|
||||
export_urdf(
|
||||
joint_manifest_path=joint_dir / "joint_module_manifest.json",
|
||||
reducer_instance_path=reducer_dir / "formula" / "formula_instance.json",
|
||||
out_dir=run_dir / "exports" / "urdf",
|
||||
)
|
||||
result = _summarize_joint_task(task_id, run_dir, request)
|
||||
active_reducer_requirement = (
|
||||
run_dir / "generated_reducer_requirement.json"
|
||||
if mode != "requirement"
|
||||
else active_reducer_requirement
|
||||
)
|
||||
return update_task(task_id, {
|
||||
**result,
|
||||
"requirementOverrides": overrides,
|
||||
"activeReducerRequirementPath": str(active_reducer_requirement.resolve()),
|
||||
})
|
||||
|
||||
|
||||
class ToolchainHandler(BaseHTTPRequestHandler):
|
||||
run_name = DEFAULT_RUN_NAME
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
if parsed.path == "/api/project-summary":
|
||||
_send_json(self, 200, _project_summary(self.run_name))
|
||||
return
|
||||
if parsed.path == "/api/tasks":
|
||||
_send_json(self, 200, {"tasks": list_tasks()})
|
||||
return
|
||||
if parsed.path.startswith("/api/tasks/"):
|
||||
parts = parsed.path.split("/")
|
||||
if len(parts) >= 4:
|
||||
task_id = safe_task_id(parts[3])
|
||||
if len(parts) == 4:
|
||||
manifest = read_task_manifest(task_id)
|
||||
_send_json(self, 200 if manifest else 404, manifest or {"error": "task_not_found"})
|
||||
return
|
||||
if len(parts) == 5 and parts[4] == "parameters":
|
||||
manifest = read_task_manifest(task_id)
|
||||
_send_json(self, 200 if manifest else 404, {
|
||||
"taskId": task_id,
|
||||
"parameters": (manifest or {}).get("parameters", {}),
|
||||
})
|
||||
return
|
||||
if len(parts) >= 6 and parts[4] == "artifacts":
|
||||
relative = "/".join(parts[5:])
|
||||
_send_file(self, _safe_relative_file(task_id, relative))
|
||||
return
|
||||
if parsed.path.startswith("/project-assets/urdf/"):
|
||||
relative_path = Path(unquote(parsed.path[len("/project-assets/urdf/"):]))
|
||||
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||
_send_json(self, 400, {"error": "invalid_asset_path"})
|
||||
return
|
||||
_send_file(self, RUN_ROOT / self.run_name / "exports" / "urdf" / relative_path)
|
||||
return
|
||||
_send_json(self, 404, {"error": "not_found"})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_send_json(self, 500, {"error": str(exc)})
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
if parsed.path == "/api/tasks":
|
||||
payload = _read_json(self)
|
||||
task = ensure_task(str(payload.get("taskId") or "") or None, request=str(payload.get("request") or "CAD Agent Studio task"))
|
||||
_send_json(self, 200, task)
|
||||
return
|
||||
if parsed.path == "/api/generate":
|
||||
payload = _read_json(self)
|
||||
kind = str(payload.get("kind") or "joint_module")
|
||||
task = ensure_task(str(payload.get("taskId") or "") or None, request=str(payload.get("request") or "CAD generation"))
|
||||
task_id = task["taskId"]
|
||||
update_task(task_id, {"status": "running", "kind": kind, "request": payload.get("request") or task["manifest"].get("request")})
|
||||
try:
|
||||
result = generate_reducer_task(task_id, payload) if kind == "reducer" else generate_joint_task(task_id, payload)
|
||||
_send_json(self, 200, result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
error_manifest = update_task(task_id, {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"type": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
"traceback": traceback.format_exc(),
|
||||
},
|
||||
})
|
||||
_send_json(self, 500, error_manifest)
|
||||
return
|
||||
if parsed.path.startswith("/api/tasks/") and parsed.path.endswith("/reconstruct"):
|
||||
task_id = safe_task_id(parsed.path.split("/")[3])
|
||||
payload = _read_json(self)
|
||||
result = reconstruct_uploaded_task(
|
||||
task_id,
|
||||
request=str(payload.get("request") or "重建上传的 STEP"),
|
||||
)
|
||||
_send_json(self, 200, result)
|
||||
return
|
||||
if parsed.path.startswith("/api/tasks/") and parsed.path.endswith("/parameters"):
|
||||
task_id = safe_task_id(parsed.path.split("/")[3])
|
||||
payload = _read_json(self)
|
||||
result = edit_task_parameter(
|
||||
task_id,
|
||||
str(payload.get("parameter") or ""),
|
||||
payload.get("value"),
|
||||
)
|
||||
_send_json(self, 200, result)
|
||||
return
|
||||
if parsed.path.startswith("/api/tasks/") and "/exports/" in parsed.path:
|
||||
parts = parsed.path.split("/")
|
||||
task_id = safe_task_id(parts[3])
|
||||
format_name = str(parts[5] if len(parts) > 5 else "").lower()
|
||||
result = export_task_robot(task_id, format_name)
|
||||
_send_json(self, 200, result)
|
||||
return
|
||||
if parsed.path.startswith("/api/upload/"):
|
||||
task_id = safe_task_id(parsed.path.rsplit("/", 1)[-1])
|
||||
filename = unquote(self.headers.get("X-Filename") or "upload.bin")
|
||||
mime = self.headers.get("Content-Type") or "application/octet-stream"
|
||||
attachment = write_upload(task_id, filename, _read_body(self), mime)
|
||||
_send_json(self, 200, attachment)
|
||||
return
|
||||
_send_json(self, 404, {"error": "not_found"})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_send_json(self, 500, {"error": str(exc)})
|
||||
|
||||
|
||||
def create_toolchain_app(host: str, port: int, *, run_name: str = DEFAULT_RUN_NAME) -> ThreadingHTTPServer:
|
||||
ToolchainHandler.run_name = run_name
|
||||
return ThreadingHTTPServer((host, port), ToolchainHandler)
|
||||
@@ -34,6 +34,7 @@ from src.joint_module.reducer_adapters import (
|
||||
SUPPORTED_JOINT_FAMILIES,
|
||||
get_reducer_joint_adapter,
|
||||
)
|
||||
from src.joint_module.reference_assembly import infer_reference_placements
|
||||
from src.kinematics import solve_instance
|
||||
from src.models import ValidationReport, read_json
|
||||
from src.parameter_solver import load_requirement, solve_parameters
|
||||
@@ -49,6 +50,11 @@ REDUCER_REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h" / "si
|
||||
TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h" / "topology.template.json"
|
||||
CASCADE_JOINT_REQ = ROOT / "input" / "requirements" / "joint_modules" / "3500_motor_housing_cascade.json"
|
||||
CASCADE_REDUCER_REQ = ROOT / "input" / "requirements" / "reducers" / "simple_2k_h_cascade" / "simple_2kh_cascade_ratio_9_spur.json"
|
||||
STANDARD_MOTOR_STEP = ROOT / "input" / "assets" / "motors" / "3500_motor_part2_standard.step"
|
||||
ACTUATOR_HOUSING_STEP = ROOT / "input" / "assets" / "housings" / "actuator_v2_knee_abd_main_case_2.step"
|
||||
CHIP_STEP = ROOT / "input" / "assets" / "chip" / "chip.step"
|
||||
REFERENCE_ASSEMBLY_STEP = ROOT / "input" / "assets" / "references" / "actuator_v2_knee_abd_reference.step"
|
||||
FIXED_PLATFORM_PROFILE = ROOT / "input" / "assets" / "platforms" / "actuator_v2_fixed_platform.json"
|
||||
FERGUSON_REDUCER_REQ = ROOT / "input" / "requirements" / "reducers" / "ferguson_wolfrom" / "ferguson_wolfrom_ratio_531p25_spur.json"
|
||||
CASCADE_TEMPLATE = ROOT / "src" / "configurations" / "simple_2k_h_cascade" / "topology.template.json"
|
||||
FERGUSON_TEMPLATE = ROOT / "src" / "configurations" / "ferguson_wolfrom" / "topology.template.json"
|
||||
@@ -95,11 +101,9 @@ def _generated_housing_step(tmp_path: Path) -> Path:
|
||||
|
||||
|
||||
def _runtime_joint_requirement(tmp_path: Path) -> Path:
|
||||
housing_step = _generated_housing_step(tmp_path)
|
||||
payload = {
|
||||
"module_type": "planetary_joint_module",
|
||||
"motor_step": str(MOTOR_STEP.resolve()),
|
||||
"housing_step": str(housing_step.resolve()),
|
||||
"platform_profile": str(FIXED_PLATFORM_PROFILE.resolve()),
|
||||
"reducer_requirement": str(REDUCER_REQ.resolve()),
|
||||
"placement_policy": "infer_then_write_editable_json",
|
||||
"motor_to_reducer_relation": "motor_output_to_sun_keyed_input",
|
||||
@@ -215,6 +219,12 @@ def _passing_reducer_report(run_id: str) -> ValidationReport:
|
||||
def test_joint_requirement_loads() -> None:
|
||||
requirement = load_joint_requirement(JOINT_REQ)
|
||||
assert requirement.module_type == "planetary_joint_module"
|
||||
assert requirement.platform_id == "actuator_v2_fixed_platform"
|
||||
assert Path(requirement.motor_step) == STANDARD_MOTOR_STEP
|
||||
assert Path(requirement.housing_step) == ACTUATOR_HOUSING_STEP
|
||||
assert requirement.chip_step is None
|
||||
assert Path(requirement.reference_assembly_step or "") == REFERENCE_ASSEMBLY_STEP
|
||||
assert requirement.motor_placement_policy == "external_flush_input_end"
|
||||
assert requirement.motor_to_reducer_relation == "motor_output_to_sun_keyed_input"
|
||||
assert requirement.housing_relation == "ring_fixed_to_housing"
|
||||
|
||||
@@ -237,11 +247,30 @@ def test_cascade_joint_requirement_loads_and_ports_resolve() -> None:
|
||||
template=load_template(CASCADE_TEMPLATE),
|
||||
)[0]
|
||||
assert requirement.joint_output == "s2_carrier_output"
|
||||
assert requirement.platform_id == "actuator_v2_fixed_platform"
|
||||
assert requirement.chip_step is None
|
||||
assert Path(requirement.reference_assembly_step or "") == REFERENCE_ASSEMBLY_STEP
|
||||
assert requirement.motor_placement_policy == "external_flush_input_end"
|
||||
assert instance.boundary.input == "s1_sun"
|
||||
assert instance.boundary.output == "s2_carrier"
|
||||
assert instance.boundary.fixed_members == ["s1_ring", "s2_ring"]
|
||||
|
||||
|
||||
def test_reference_assembly_recovers_motor_placement() -> None:
|
||||
_ocp_or_skip()
|
||||
placements = infer_reference_placements(
|
||||
reference_step=REFERENCE_ASSEMBLY_STEP,
|
||||
component_steps={
|
||||
"housing": ACTUATOR_HOUSING_STEP,
|
||||
"motor": STANDARD_MOTOR_STEP,
|
||||
},
|
||||
)
|
||||
|
||||
assert placements["housing"].translation_mm == (0.0, 0.0, 0.0)
|
||||
assert placements["motor"].translation_mm[0] == pytest.approx(2.0, abs=1e-6)
|
||||
assert placements["motor"].source == "inferred_from_reference_assembly_occurrence"
|
||||
|
||||
|
||||
def test_joint_requirements_reference_supported_reducer_families() -> None:
|
||||
for requirement_path in (ROOT / "input" / "requirements" / "joint_modules").glob("*.json"):
|
||||
requirement = load_joint_requirement(requirement_path)
|
||||
@@ -250,6 +279,14 @@ def test_joint_requirements_reference_supported_reducer_families() -> None:
|
||||
assert reducer_requirement.topology_family in SUPPORTED_JOINT_FAMILIES
|
||||
|
||||
|
||||
def test_all_joint_requirements_use_the_fixed_platform_profile() -> None:
|
||||
platform_ids = {
|
||||
load_joint_requirement(requirement_path).platform_id
|
||||
for requirement_path in (ROOT / "input" / "requirements" / "joint_modules").glob("*.json")
|
||||
}
|
||||
assert platform_ids == {"actuator_v2_fixed_platform"}
|
||||
|
||||
|
||||
def test_ferguson_reducer_ports_remain_available_for_reducer_exports() -> None:
|
||||
instance = solve_parameters(
|
||||
requirement=load_requirement(FERGUSON_REDUCER_REQ),
|
||||
|
||||
+565
-498
File diff suppressed because it is too large
Load Diff
+666
-50
@@ -1,59 +1,675 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root { font-family: Manrope, system-ui, sans-serif; color: #e9edf1; background: #090b0e; font-synthesis: none; --line: rgba(255,255,255,.085); --muted: #7e8791; }
|
||||
:root {
|
||||
font-family: Manrope, system-ui, sans-serif;
|
||||
color: #263941;
|
||||
background: #e7ecee;
|
||||
font-synthesis: none;
|
||||
--canvas: #e7ecee;
|
||||
--paper: #fbfcfb;
|
||||
--panel: #f6f8f7;
|
||||
--ink: #263941;
|
||||
--muted: #73828a;
|
||||
--line: rgba(45, 67, 78, .14);
|
||||
--teal: #347f82;
|
||||
--green: #26845e;
|
||||
--amber: #a66a24;
|
||||
--red: #a33d3d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 1180px; min-height: 100vh; overflow: hidden; }
|
||||
button, textarea { font: inherit; }
|
||||
body { margin: 0; min-width: 1120px; min-height: 100vh; overflow: hidden; background: var(--canvas); }
|
||||
button, textarea, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
.app-shell { height: 100vh; display: grid; grid-template-columns: 340px 1fr; background: #0b0e12; }
|
||||
.chat-panel { background: #0d1014; border-right: 1px solid var(--line); display: flex; flex-direction: column; position: relative; z-index: 20; }
|
||||
.brand { height: 78px; display: flex; align-items: center; gap: 12px; padding: 0 22px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 35px; height: 35px; border: 1px solid #f1a64b55; display: grid; place-items: center; border-radius: 9px; background: #f1a64b0c; }
|
||||
.brand b { display:block; font-size: 15px; letter-spacing: .2em; }.brand span { display:block; font: 9px DM Mono; letter-spacing: .22em; color:#787f87; margin-top:3px; }
|
||||
.orbit-icon { width: 24px; height: 24px; position: relative; display: grid; place-items: center; }.orbit-icon:after { content:''; width:6px;height:6px;border-radius:50%;background:#f1a64b;box-shadow:0 0 14px #f1a64b; }
|
||||
.orbit-icon i { position:absolute; width:22px;height:8px;border:1px solid #f1a64b;border-radius:50%;transform:rotate(0deg); }.orbit-icon i:nth-child(2){transform:rotate(60deg)}.orbit-icon i:nth-child(3){transform:rotate(-60deg)}
|
||||
.chat-heading { height: 56px; padding: 0 20px; border-bottom:1px solid var(--line); display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.04em; }.chat-heading button{margin-left:auto;background:none;border:0;color:#6e7780;cursor:pointer}.live-dot{width:6px;height:6px;border-radius:50%;background:#68d391;box-shadow:0 0 9px #68d391}
|
||||
.messages { flex:1; overflow-y:auto; padding:18px 17px; scrollbar-width:none; }.date-label{text-align:center;font:9px DM Mono;color:#59616a;margin-bottom:18px}.message{display:flex;gap:8px;margin:0 0 16px;align-items:flex-start}.message.user{justify-content:flex-end}.avatar{width:25px;height:25px;border:1px solid #f1a64b44;border-radius:7px;display:grid;place-items:center;color:#f1a64b;flex:none}.bubble{max-width:250px;padding:11px 13px;border:1px solid var(--line);border-radius:4px 13px 13px 13px;background:#14181d;color:#b7bec6;font-size:12px;line-height:1.65}.user .bubble{background:#e6973f;color:#16110a;border-color:#e6973f;border-radius:13px 4px 13px 13px;font-weight:600}.spin{animation:spin 1s linear infinite;vertical-align:middle;margin-right:4px}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.prompt-suggestions{display:flex;gap:6px;padding:0 17px 9px}.prompt-suggestions button{border:1px solid var(--line);background:#12161a;border-radius:20px;padding:6px 10px;font-size:10px;color:#858e98;cursor:pointer}.prompt-suggestions button:hover{border-color:#f1a64b66;color:#f1a64b}
|
||||
.composer{margin:0 15px 16px;border:1px solid #ffffff18;background:#12161a;border-radius:13px;padding:11px;box-shadow:0 14px 40px #0005}.composer:focus-within{border-color:#f1a64b66}.composer textarea{width:100%;height:61px;resize:none;border:0;outline:0;background:transparent;color:#e5e9ed;font-size:12px;line-height:1.5}.composer-foot{display:flex;align-items:center;justify-content:space-between}.composer-foot span{display:flex;align-items:center;gap:5px;color:#68717b;font-size:9px}.composer-foot button{border:0;width:30px;height:30px;border-radius:8px;background:#efa246;color:#17100a;display:grid;place-items:center;cursor:pointer;transition:.2s}.composer-foot button:hover{transform:translateY(-2px);box-shadow:0 8px 20px #efa24644}
|
||||
.workspace{min-width:0;background:#0b0e12}.topbar{height:78px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 28px;background:#0c0f13}.topbar>div:first-child{display:flex;align-items:center;gap:12px}.topbar>div:first-child span{font:9px DM Mono;color:#59616a;letter-spacing:.15em}.topbar>div:first-child b{font-size:13px}.top-actions{display:flex;align-items:center;gap:10px}.source-pill{font:9px DM Mono;color:#7c858f;border:1px solid var(--line);padding:7px 10px;border-radius:20px}.source-pill span{display:inline-block;width:5px;height:5px;border-radius:50%;background:#69d18f;margin-right:7px}.top-actions button,.account-avatar{width:32px;height:32px;border:1px solid var(--line);background:#12161a;border-radius:8px;display:grid;place-items:center}.account-avatar{background:#ddd;color:#17191b;font-size:11px;font-weight:800}
|
||||
.route-layer{height:calc(100vh - 78px)}.gallery{height:calc(100vh - 78px);overflow:auto;padding:38px 40px 28px;position:relative;background-image:linear-gradient(#ffffff07 1px,transparent 1px),linear-gradient(90deg,#ffffff07 1px,transparent 1px);background-size:40px 40px}.gallery:before{content:'';position:fixed;inset:78px 0 0 340px;pointer-events:none;background:radial-gradient(circle at 60% 30%,#f1a64b0a,transparent 37%)}
|
||||
.gallery-heading{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:26px;position:relative}.gallery-heading>div>span{display:flex;align-items:center;gap:7px;font:9px DM Mono;color:#e8a34d;letter-spacing:.15em}.gallery-heading h1{font-size:28px;letter-spacing:-.04em;margin:10px 0 4px}.gallery-heading p{color:#6e7781;font-size:11px;margin:0}.result-count{text-align:right;border-left:1px solid var(--line);padding-left:24px}.result-count b{display:block;font:26px DM Mono;color:#d5d9de}.result-count span{font-size:9px;color:#656e77}
|
||||
.candidate-grid{display:grid;grid-template-columns:repeat(3,minmax(250px,1fr));gap:14px;transition:.4s}.candidate-grid.muted{opacity:.1;filter:blur(5px)}.candidate{height:303px;border:1px solid var(--line);background:#101419;position:relative;overflow:hidden;cursor:pointer;opacity:0;transform:translateY(25px) scale(.97);transition:opacity .65s,transform .65s,border-color .25s,box-shadow .25s}.candidate.show{opacity:1;transform:none}.candidate:nth-child(2){transition-delay:.06s}.candidate:nth-child(3){transition-delay:.12s}.candidate:nth-child(4){transition-delay:.18s}.candidate:nth-child(5){transition-delay:.24s}.candidate:nth-child(6){transition-delay:.3s}.candidate:hover{border-color:color-mix(in srgb,var(--accent) 55%,transparent);transform:translateY(-4px);box-shadow:0 18px 50px #0007}.candidate-top{height:34px;position:absolute;z-index:5;top:0;left:0;right:0;display:flex;justify-content:space-between;padding:11px 12px;font:9px DM Mono;color:#5c646d}.candidate-top em{font-style:normal;color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);background:color-mix(in srgb,var(--accent) 8%,transparent);padding:3px 7px;border-radius:10px}.mini-view{height:190px;position:relative}.mini-view canvas{transition:transform .6s}.candidate:hover canvas{transform:scale(1.08)}.scan-line{position:absolute;left:12%;right:12%;bottom:10px;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);opacity:.4}.candidate-info{height:57px;padding:11px 14px;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line)}.candidate-info b{display:block;font:16px DM Mono}.candidate-info span{font-size:9px;color:#69727c}.candidate-info svg{color:var(--accent)}.candidate-metrics{height:46px;display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line)}.candidate-metrics span{font:11px DM Mono;display:flex;align-items:center;justify-content:center;gap:3px;border-right:1px solid var(--line)}.candidate-metrics span:last-child{border:0}.candidate-metrics small{font-size:8px;color:#59616a}.data-note{margin-top:15px;color:#5c6570;font:9px DM Mono;display:flex;align-items:center;gap:7px}.data-note>span{width:5px;height:5px;background:#67ce8b;border-radius:50%}.data-note b{color:#838c96}.generating{position:absolute;z-index:20;inset:170px 0 0;display:flex;flex-direction:column;align-items:center;padding-top:100px}.generating b{font-size:15px;margin-top:25px}.generating p{font-size:10px;color:#666f78}.generator-orb{width:84px;height:84px;display:grid;place-items:center;position:relative}.generator-orb .orbit-icon{transform:scale(1.8)}.generator-orb>span{position:absolute;inset:0;border:1px solid #f1a64b33;border-radius:50%;animation:pulse 1.5s ease-out infinite}.generator-orb>span:nth-child(3){animation-delay:.6s}@keyframes pulse{to{transform:scale(1.7);opacity:0}}.progress{width:220px;height:2px;background:#ffffff0b;margin-top:15px;overflow:hidden}.progress i{display:block;height:100%;width:50%;background:#f1a64b;animation:progress 1.3s ease-in-out infinite}@keyframes progress{from{transform:translateX(-100%)}to{transform:translateX(220%)}}
|
||||
.detail-view{height:calc(100vh - 78px);position:relative;overflow:hidden;background:#11151a;animation:detailIn .75s cubic-bezier(.2,.8,.2,1)}@keyframes detailIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:none}}.detail-toolbar{height:54px;position:absolute;z-index:10;left:0;right:0;top:0;display:flex;align-items:center;padding:0 20px;border-bottom:1px solid var(--line);background:#0d1014dd;backdrop-filter:blur(14px)}.back{background:transparent;border:0;display:flex;align-items:center;gap:8px;font-size:11px;cursor:pointer}.back:hover{color:#f1a64b}.breadcrumb{margin-left:24px;padding-left:24px;border-left:1px solid var(--line);display:flex;align-items:center;gap:7px;font:9px DM Mono;color:#606974}.breadcrumb b{color:#abb2ba}.validated{margin-left:auto;color:#6dd596;background:#6dd5960e;border:1px solid #6dd59633;border-radius:20px;padding:6px 9px;font:9px DM Mono;display:flex;gap:5px;align-items:center}.detail-canvas{position:absolute;inset:0}.detail-gradient{position:absolute;inset:0;pointer-events:none;background:radial-gradient(circle at 50% 46%,transparent 20%,#11151a00 43%,#11151ae5 100%),linear-gradient(90deg,#11151a 0%,transparent 18%,transparent 77%,#11151a 100%)}.model-title{position:absolute;left:30px;bottom:36px}.model-title>span{font:9px DM Mono;color:#f1a64b;letter-spacing:.18em}.model-title h2{font-size:30px;margin:6px 0 3px;letter-spacing:-.04em}.model-title h2 em{font-style:normal;color:#464e57;font-weight:400}.model-title p{margin:0;color:#68717b;font-size:9px}.part-panel{position:absolute;z-index:10;right:20px;top:74px;width:244px;background:#0c1014e8;border:1px solid var(--line);backdrop-filter:blur(16px);border-radius:12px;overflow:hidden;box-shadow:0 22px 60px #0008}.part-panel-title{height:43px;padding:0 13px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.part-panel-title>div{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:600}.part-panel-title>span{font:8px DM Mono;color:#59616a}.part-list{padding:6px}.part-list button{width:100%;height:37px;border:1px solid transparent;background:transparent;display:flex;align-items:center;gap:8px;padding:0 8px;border-radius:7px;cursor:pointer;color:#8c959e;font-size:10px}.part-list button:hover,.part-list button.active{background:#ffffff08;color:#e8ecef}.part-list button.active{border-color:#ffffff0d}.part-list button>i{width:8px;height:8px;border-radius:2px}.part-list button>span{flex:1;text-align:left}.part-list button>div{padding:5px;color:#606a74}.opacity-control{border-top:1px solid var(--line);padding:12px 13px}.opacity-control>div{display:flex;justify-content:space-between;font-size:9px;color:#7a838d}.opacity-control b{font:9px DM Mono;color:#c7cdd3}.opacity-control input{width:100%;accent-color:#f1a64b;height:3px;margin-top:11px}.isolate,.restore{margin:0 10px 8px;width:calc(100% - 20px);height:32px;border:1px solid var(--line);border-radius:7px;background:#14191e;font-size:9px;display:flex;align-items:center;justify-content:center;gap:6px;cursor:pointer}.restore{background:transparent;color:#68727c}.isolate:hover{border-color:#f1a64b55}.telemetry{position:absolute;bottom:28px;left:50%;transform:translateX(-50%);display:flex;border:1px solid var(--line);background:#0d1116dd;backdrop-filter:blur(12px);border-radius:10px}.telemetry>div{width:128px;height:48px;display:flex;align-items:center;gap:8px;padding:0 12px;border-right:1px solid var(--line);color:#f1a64b}.telemetry>div:last-child{border:0}.telemetry span{font-size:8px;color:#66707a}.telemetry b{display:block;color:#d8dde2;font:10px DM Mono;margin-top:2px}
|
||||
.route-layer .detail-view{height:100%}.housing-panel{position:absolute;z-index:10;right:20px;top:74px;width:244px;background:#0c1014e8;border:1px solid var(--line);backdrop-filter:blur(16px);border-radius:12px;overflow:hidden;box-shadow:0 22px 60px #0008}.housing-title{height:43px;padding:0 13px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.housing-title>div{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:600}.housing-title>span{font:8px DM Mono;color:#59616a}.housing-toggle{width:calc(100% - 20px);height:42px;margin:10px;border:1px solid var(--line);background:#12171c;border-radius:8px;display:flex;align-items:center;justify-content:space-between;padding:0 11px;cursor:pointer}.housing-toggle span{display:flex;align-items:center;gap:8px;font-size:10px}.housing-toggle i{width:29px;height:16px;border-radius:12px;background:#303740;position:relative;transition:.3s}.housing-toggle i:after{content:'';position:absolute;width:12px;height:12px;left:2px;top:2px;background:#76808b;border-radius:50%;transition:.3s}.housing-toggle.on i{background:#f1a64b55}.housing-toggle.on i:after{left:15px;background:#f1a64b}.housing-panel p{margin:0;padding:0 13px 13px;color:#626b75;font-size:9px;line-height:1.6}.housing-panel input:disabled{opacity:.25}
|
||||
@media(max-width:1300px){.candidate-grid{grid-template-columns:repeat(2,1fr)}.candidate{height:290px}.mini-view{height:177px}}
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
textarea { resize: none; }
|
||||
|
||||
/* Light industrial design theme */
|
||||
:root{color:#28343d;background:#dfe6e9;--line:rgba(45,67,78,.13);--muted:#73818a}
|
||||
body{background:#dfe6e9}.app-shell{background:#e6ecef}.chat-panel{background:#f7f8f7;border-color:#cfd8dc}.brand{background:#fbfbfa}.brand-mark{background:#e9f2f2;border-color:#8db6ba66}.brand b{color:#24333b}.brand span{color:#849198}.orbit-icon:after{background:#2b8d91;box-shadow:0 0 12px #2b8d9166}.orbit-icon i{border-color:#2b8d91}.chat-heading{color:#33434b;background:#f4f6f6}.chat-heading button{color:#829097}.live-dot{background:#45a77a;box-shadow:0 0 8px #45a77a66}.date-label{color:#9aa5aa}.bubble{background:#fff;border-color:#d8e0e3;color:#596870;box-shadow:0 5px 18px #40545e0b}.message.user .bubble{background:#2b8d91;border-color:#2b8d91;color:#fff}.avatar{border-color:#2b8d9155;color:#2b8d91;background:#edf6f5}.prompt-suggestions button{background:#f0f4f4;color:#718087;border-color:#d8e0e2}.prompt-suggestions button:hover{border-color:#2b8d9188;color:#217b7f}.composer{background:#fff;border-color:#d3dcdf;box-shadow:0 12px 34px #536b7615}.composer textarea{color:#35444b}.composer-foot span{color:#8b989e}.composer-foot button{background:#2b8d91;color:#fff}.composer-foot button:hover{box-shadow:0 8px 20px #2b8d9140}.workspace{background:#e6ecef}.topbar{background:#f9faf9;border-color:#ced8dc}.topbar>div:first-child span{color:#89969c}.topbar>div:first-child b{color:#304048}.source-pill{background:#f0f6f4;color:#5b7770;border-color:#cfe0da}.source-pill span{background:#46a978}.top-actions button{background:#eef2f3;color:#53636b}.account-avatar{background:#2b8d91;color:#fff;border-color:#2b8d91}.gallery{background-color:#e8edef;background-image:linear-gradient(#6d838c0c 1px,transparent 1px),linear-gradient(90deg,#6d838c0c 1px,transparent 1px)}.gallery:before{background:radial-gradient(circle at 62% 28%,#5aaeb516,transparent 39%)}.gallery-heading>div>span{color:#2b8d91}.gallery-heading h1{color:#26353d}.gallery-heading p{color:#78868d}.result-count b{color:#33434b}.result-count span{color:#8b979d}.candidate{background:#f8faf9;border-color:#ccd7db;box-shadow:0 7px 24px #62757e12}.candidate:hover{box-shadow:0 18px 45px #586d7625}.candidate-top{color:#9ba6ab}.candidate-info{background:#fbfcfb}.candidate-info b{color:#304047}.candidate-info span{color:#7d8a90}.candidate-metrics{background:#f2f6f6}.candidate-metrics span{color:#405159}.candidate-metrics small{color:#8b989e}.data-note{color:#839097}.data-note b{color:#596a72}.detail-view{background:#e8edf0}.detail-toolbar{background:#f9faf9e8;border-color:#ced8dc}.back{color:#41525a}.back:hover{color:#2b8d91}.breadcrumb{color:#8a979d}.breadcrumb b{color:#4b5d65}.validated{color:#27835e;background:#e9f6ef;border-color:#77bd9c66}.detail-gradient{background:radial-gradient(circle at 50% 46%,transparent 24%,#e8edf000 55%,#dfe7eab8 100%),linear-gradient(90deg,#e4eaed88 0%,transparent 20%,transparent 78%,#e4eaed99 100%)}.model-title>span{color:#2b8d91}.model-title h2{color:#26353d}.model-title h2 em{color:#9aa6ab}.model-title p{color:#74838a}.housing-panel{background:#fafbfaf0;border-color:#ccd7db;box-shadow:0 22px 55px #586d7628}.housing-title{color:#34464e}.housing-title>span{color:#8b989e}.housing-toggle{background:#f1f5f5;border-color:#d4dde0;color:#45575f}.housing-toggle i{background:#bac5ca}.housing-toggle.on i{background:#2b8d9140}.housing-toggle.on i:after{background:#2b8d91}.opacity-control{border-color:#d4dde0}.opacity-control>div{color:#7b898f}.opacity-control b{color:#42545c}.opacity-control input{accent-color:#2b8d91}.housing-panel p{color:#7b898f}.telemetry{background:#fafbfaf0;border-color:#ccd7db;box-shadow:0 12px 34px #536b7618}.telemetry>div{color:#2b8d91}.telemetry span{color:#7b898f}.telemetry b{color:#34464e}.generating b{color:#34464e}.generating p{color:#7c898f}.progress{background:#aebbc344}.progress i{background:#2b8d91}
|
||||
.real-asset-badge{position:absolute;right:12px;bottom:10px;z-index:3;padding:5px 8px;border:1px solid rgba(77,150,154,.24);border-radius:999px;background:rgba(248,252,251,.82);color:#397d80;font:600 9px/1 Manrope,system-ui,sans-serif;letter-spacing:.13em;backdrop-filter:blur(10px)}
|
||||
.candidate-grid{grid-template-columns:minmax(380px,540px)}
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 318px 1fr;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
/* Refined industrial workspace */
|
||||
:root{--ink:#21323a;--sub:#718189;--paper:#f8faf9;--canvas:#e9eef0;--teal:#347f82;--teal-soft:#e1efed;--hair:rgba(43,66,76,.14)}
|
||||
.app-shell{grid-template-columns:318px 1fr}.chat-panel{box-shadow:12px 0 34px rgba(48,69,78,.035)}
|
||||
.brand{height:70px;padding:0 24px}.brand-mark{width:36px;height:36px;border-radius:11px}.brand b{font-size:14px}.chat-heading{height:52px;padding:0 22px}.messages{padding:22px 18px}.bubble{border-radius:5px 15px 15px 15px;line-height:1.72}.user .bubble{border-radius:15px 5px 15px 15px}.composer{margin:0 16px 18px;border-radius:16px;padding:13px}.composer-foot button{border-radius:10px}
|
||||
.topbar{height:70px;padding:0 24px}.project-id{display:flex;align-items:center;gap:14px}.topbar nav{position:absolute;left:50%;transform:translateX(-50%);height:70px;display:flex;align-items:center;gap:26px}.topbar nav span{height:70px;display:flex;align-items:center;position:relative;color:#87949a;font-size:10px;cursor:default}.topbar nav span.active{color:#31444c;font-weight:650}.topbar nav span.active:after{content:'';position:absolute;left:0;right:0;bottom:0;height:2px;background:#347f82}.top-actions button{border-radius:10px}.source-pill{letter-spacing:.08em}
|
||||
.route-layer,.gallery,.detail-view{height:calc(100vh - 70px)}.gallery{padding:34px 38px 24px;background-size:48px 48px}.gallery:before{inset:70px 0 0 318px}.gallery-heading{align-items:flex-start;margin-bottom:26px}.gallery-heading h1{font-size:32px;font-weight:650;margin:9px 0 7px}.gallery-heading p{font-size:11px;max-width:600px}.gallery-status{display:grid!important;grid-template-columns:auto auto;align-items:end;column-gap:9px;text-align:right}.gallery-status>span{grid-column:1/3;justify-self:end!important;padding:6px 9px;border:1px solid #83b9aa66;border-radius:999px;background:#eff7f3;color:#45816f!important;font-size:8px!important;letter-spacing:.09em!important}.gallery-status>b{font:30px DM Mono;color:#2d424b}.gallery-status>small{font-size:9px;color:#849198;padding-bottom:5px}
|
||||
.candidate-grid{display:block}.candidate{width:min(920px,calc(100vw - 400px));height:410px;display:grid;grid-template-columns:minmax(430px,1.25fr) minmax(300px,.75fr);border-radius:18px;background:rgba(250,252,251,.9);box-shadow:0 18px 55px rgba(61,80,89,.12);border-color:rgba(69,92,101,.16);overflow:hidden}.candidate:hover{transform:translateY(-5px);box-shadow:0 24px 70px rgba(54,75,84,.18)}.candidate-visual{position:relative;min-width:0;background:#e6ecee;border-right:1px solid var(--hair)}.candidate-top{height:48px;padding:16px 18px;position:absolute}.candidate-top em{display:flex;align-items:center;gap:5px;padding:5px 9px;background:rgba(244,250,248,.8);font-size:8px;letter-spacing:.06em}.mini-view{height:100%}.candidate:hover canvas{transform:scale(1.025)}.model-aura{position:absolute;inset:14% 18%;border:1px solid rgba(73,114,121,.1);border-radius:50%;pointer-events:none}.model-aura:after,.model-aura:before{content:'';position:absolute;inset:12%;border:1px solid rgba(73,114,121,.08);border-radius:50%}.model-aura:after{inset:29%}.scan-line{left:24%;right:24%;bottom:50px}.real-asset-badge{right:18px;bottom:18px}.view-caption{position:absolute;left:18px;right:18px;bottom:17px;display:flex;align-items:center;gap:7px;color:#6f8087;font-size:9px;pointer-events:none}.view-caption b{margin-left:auto;font:8px DM Mono;color:#87959b;letter-spacing:.06em}.candidate-copy{padding:32px 32px 26px;display:flex;flex-direction:column;background:rgba(251,252,251,.96)}.candidate-kicker{font:9px DM Mono;color:#347f82;letter-spacing:.12em}.candidate-copy h2{margin:15px 0 12px;font-size:27px;line-height:1.28;letter-spacing:-.04em;color:#263941}.candidate-copy>p{font-size:11px;line-height:1.75;color:#738189;margin:0}.candidate-specs{display:grid;grid-template-columns:repeat(3,1fr);margin-top:auto;border-top:1px solid var(--hair);border-bottom:1px solid var(--hair)}.candidate-specs>div{padding:15px 10px 14px 0;border-right:1px solid var(--hair)}.candidate-specs>div+div{padding-left:12px}.candidate-specs>div:last-child{border:0}.candidate-specs span{display:block;font-size:8px;color:#8a979d;margin-bottom:6px}.candidate-specs b{font:17px DM Mono;color:#33474f}.candidate-specs small{display:block;font:8px Manrope;color:#819097;margin-top:3px}.open-design{height:40px;margin-top:17px;border:0;border-radius:9px;background:#347f82;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:0 14px;font-size:10px;font-weight:650;cursor:pointer;transition:.25s}.open-design:hover{background:#286f72;transform:translateX(2px)}.data-note{margin-top:16px}
|
||||
.detail-toolbar{height:52px;padding:0 18px}.breadcrumb{margin-left:18px;padding-left:18px}.detail-mode{position:absolute;left:50%;transform:translateX(-50%);height:52px;display:flex;align-items:center;gap:6px}.detail-mode span{padding:7px 12px;border-radius:7px;color:#87949a;font-size:9px}.detail-mode span.active{background:#e6efef;color:#347f82;font-weight:650}.detail-canvas{top:52px}.cad-grid{position:absolute;inset:0;pointer-events:none;opacity:.34;background-image:linear-gradient(rgba(72,99,109,.07) 1px,transparent 1px),linear-gradient(90deg,rgba(72,99,109,.07) 1px,transparent 1px);background-size:46px 46px;mask-image:radial-gradient(circle at 50% 45%,transparent 18%,#000 77%)}.view-tools{position:absolute;z-index:12;top:70px;left:18px;display:flex;gap:4px;padding:5px;border:1px solid var(--hair);border-radius:11px;background:rgba(250,252,251,.86);box-shadow:0 12px 28px rgba(52,72,81,.12);backdrop-filter:blur(14px)}.view-tools button{width:32px;height:32px;border:0;border-radius:7px;background:transparent;color:#75858c;display:grid;place-items:center;cursor:pointer}.view-tools button:hover,.view-tools button.active{background:#e1ecec;color:#347f82}.view-tools i{width:1px;height:22px;align-self:center;background:var(--hair);margin:0 2px}.housing-panel{top:70px;right:18px;width:252px;border-radius:14px}.housing-title{height:48px;padding:0 14px}.panel-section-label{padding:13px 14px 0;color:#8b989e;font-size:8px;letter-spacing:.08em;text-transform:uppercase}.housing-toggle{margin:9px 10px 7px}.opacity-control{padding:11px 14px 14px}.structure-label{border-top:1px solid var(--hair);padding-top:13px}.structure-list{padding:7px 10px 10px}.structure-list>div{height:34px;display:flex;align-items:center;gap:8px;padding:0 7px;border-radius:7px;color:#53666e;font-size:9px}.structure-list>div:hover{background:#edf3f3}.structure-list b{margin-left:auto;font:7px DM Mono;color:#93a0a5}.part-color{width:7px;height:7px;border-radius:2px}.part-color.motor{background:#bd7568}.part-color.gear{background:#d4b35d}.part-color.output{background:#648eae}.panel-foot{height:38px;border-top:1px solid var(--hair);display:flex;align-items:center;gap:7px;padding:0 14px;color:#718188;font-size:8px}.panel-foot .live-dot{width:5px;height:5px}.motion-control{position:absolute;z-index:12;left:18px;bottom:112px;border:1px solid var(--hair);border-radius:10px;background:rgba(250,252,251,.88);color:#43575f;height:38px;padding:0 13px;display:flex;align-items:center;gap:8px;box-shadow:0 10px 30px rgba(52,72,81,.11);backdrop-filter:blur(12px);cursor:pointer}.motion-control span{font-size:9px}.motion-control:hover{color:#347f82}.model-title{left:20px;bottom:28px}.model-title h2{font-size:28px}.telemetry{bottom:24px}.telemetry>div{width:134px;height:50px}.validated{letter-spacing:.03em}
|
||||
@media(max-width:1300px){.topbar nav{display:none}.candidate{width:100%;grid-template-columns:1.15fr .85fr}.candidate-grid{display:block}.candidate{height:390px}.mini-view{height:100%}}
|
||||
.chat-panel {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8faf9;
|
||||
border-right: 1px solid #cfd9dc;
|
||||
box-shadow: 12px 0 34px rgba(48, 69, 78, .035);
|
||||
}
|
||||
|
||||
.part-visibility-toggle{width:calc(100% - 20px);height:39px;margin:0 10px 10px;padding:0 10px;border:1px solid var(--hair);border-radius:8px;background:#f5f8f7;color:#51656d;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:.2s}.part-visibility-toggle:hover,.part-visibility-toggle.selected{border-color:rgba(100,142,174,.45);background:#edf3f6}.part-visibility-toggle>span{display:flex;align-items:center;gap:7px;font-size:9px}.part-visibility-toggle>b{font:8px DM Mono;color:#86949a}.part-visibility-toggle:not(.on){opacity:.68}.part-color.cover{background:#7fa5bd}.pick-hint{margin:0 10px 12px;padding:8px 9px;border-radius:7px;background:#eef3f3;color:#718189;display:flex;align-items:flex-start;gap:7px;font-size:8px;line-height:1.45}.pick-hint svg{flex:none;color:#4d8a8d}.structure-list>div{cursor:default}.structure-list>div.active{background:#e7f0f2;color:#365b69}.structure-list button{margin-left:auto;width:25px;height:25px;border:0;border-radius:6px;background:transparent;color:#788990;display:grid;place-items:center;cursor:pointer}.structure-list button:hover{background:#dbe9ec;color:#347f82}
|
||||
.brand {
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.message{width:100%;min-width:0}.message.user{width:100%;height:auto;min-height:0;border:0;background:transparent;border-radius:0;display:flex;place-items:initial}.message .bubble{min-width:0;max-width:248px;overflow-wrap:anywhere;word-break:break-word;white-space:pre-wrap}.message.user .bubble{width:auto;height:auto;max-width:248px;display:block}.messages{min-width:0;overflow-x:hidden}
|
||||
.part-color.rotor{background:#7897a4}
|
||||
.brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(52, 127, 130, .25);
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--teal);
|
||||
background: #e7f1f0;
|
||||
}
|
||||
|
||||
/* Real URDF pipeline status — shown only during the first parse/assembly. */
|
||||
.manufacturing-loader{width:310px;padding:20px 21px 17px;border:1px solid rgba(73,112,120,.2);border-radius:16px;background:rgba(249,252,251,.92);box-shadow:0 22px 70px rgba(63,82,90,.18);backdrop-filter:blur(18px);color:#2f424a;display:grid;grid-template-columns:46px 1fr;column-gap:14px;white-space:nowrap;overflow:hidden}
|
||||
.manufacturing-loader.compact{width:276px;padding:16px 17px 14px;transform:scale(.86)}
|
||||
.manufacturing-mark{width:46px;height:46px;position:relative;display:grid;place-items:center;border-radius:13px;background:linear-gradient(145deg,#e3efed,#f8fbfa);box-shadow:inset 0 0 0 1px rgba(52,127,130,.15)}
|
||||
.manufacturing-mark span{position:absolute;width:26px;height:26px;border:1.5px solid #347f82;border-radius:50%;border-right-color:transparent;animation:manufacturing-turn 1.75s cubic-bezier(.55,.15,.45,.85) infinite}
|
||||
.manufacturing-mark span:nth-child(2){width:17px;height:17px;border-color:#87aeb1 transparent #87aeb1 #87aeb1;animation-direction:reverse;animation-duration:1.15s}
|
||||
.manufacturing-mark i{width:5px;height:5px;border-radius:50%;background:#347f82;box-shadow:0 0 0 4px rgba(52,127,130,.11)}
|
||||
.manufacturing-copy{min-width:0;padding-top:1px}.manufacturing-copy small{display:block;color:#4c8a8d;font:500 8px DM Mono;letter-spacing:.14em}.manufacturing-copy b{display:block;margin-top:6px;color:#2d4149;font-size:13px;letter-spacing:.01em}.manufacturing-copy p{margin:3px 0 0;color:#7c8a90;font-size:9px;line-height:1.5}
|
||||
.manufacturing-progress{grid-column:1/3;height:3px;margin-top:16px;border-radius:999px;background:#dfe8e8;overflow:hidden}.manufacturing-progress i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#6ea6a8,#2f8588);transition:width .28s ease}
|
||||
.manufacturing-stages{grid-column:1/3;display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin-top:11px}.manufacturing-stages span{display:flex;align-items:center;gap:5px;color:#9aa5aa;font:8px DM Mono}.manufacturing-stages span i{width:5px;height:5px;border-radius:50%;background:#c8d2d4;transition:.25s}.manufacturing-stages span.active{color:#347f82}.manufacturing-stages span.active i{background:#347f82;box-shadow:0 0 0 3px rgba(52,127,130,.11);animation:manufacturing-pulse 1.2s ease-in-out infinite}.manufacturing-stages span.done{color:#6f8987}.manufacturing-stages span.done i{background:#67a18c}
|
||||
@keyframes manufacturing-turn{to{transform:rotate(360deg)}}
|
||||
@keyframes manufacturing-pulse{50%{transform:scale(.7);opacity:.55}}
|
||||
.brand b { display: block; font-size: 14px; letter-spacing: .18em; }
|
||||
.brand span { display: block; margin-top: 3px; color: #849198; font: 9px DM Mono, monospace; letter-spacing: .18em; }
|
||||
|
||||
.chat-heading {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 22px;
|
||||
color: #33434b;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #f3f6f6;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #45a77a;
|
||||
box-shadow: 0 0 8px rgba(69, 167, 122, .42);
|
||||
}
|
||||
|
||||
.messages {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 22px 18px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--teal);
|
||||
background: #edf6f5;
|
||||
border: 1px solid rgba(52, 127, 130, .25);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 248px;
|
||||
min-width: 0;
|
||||
padding: 11px 13px;
|
||||
color: #596870;
|
||||
background: #fff;
|
||||
border: 1px solid #d8e0e3;
|
||||
border-radius: 5px 15px 15px 15px;
|
||||
box-shadow: 0 5px 18px rgba(64, 84, 94, .05);
|
||||
font-size: 12px;
|
||||
line-height: 1.72;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bubble.error {
|
||||
color: #963d3d;
|
||||
background: #fff4f4;
|
||||
border-color: #e2b7b7;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
border-color: var(--teal);
|
||||
border-radius: 15px 5px 15px 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.prompt-suggestions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 0 17px 9px;
|
||||
}
|
||||
|
||||
.prompt-suggestions button {
|
||||
border: 1px solid #d8e0e2;
|
||||
background: #f0f4f4;
|
||||
color: #718087;
|
||||
border-radius: 20px;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.composer {
|
||||
margin: 0 16px 18px;
|
||||
padding: 13px;
|
||||
background: #fff;
|
||||
border: 1px solid #d3dcdf;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 34px rgba(83, 107, 118, .08);
|
||||
}
|
||||
|
||||
.composer:focus-within { border-color: rgba(52, 127, 130, .48); }
|
||||
.composer textarea { width: 100%; height: 72px; border: 0; outline: 0; background: transparent; color: #35444b; font-size: 12px; line-height: 1.5; }
|
||||
.composer-foot { display: flex; align-items: center; justify-content: space-between; }
|
||||
.composer-foot span { display: flex; align-items: center; gap: 5px; color: #8b989e; font-size: 9px; }
|
||||
.composer-foot button { width: 30px; height: 30px; border: 0; border-radius: 10px; background: var(--teal); color: #fff; display: grid; place-items: center; cursor: pointer; }
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 70px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid #ced8dc;
|
||||
}
|
||||
|
||||
.project-id { display: flex; align-items: center; gap: 14px; }
|
||||
.project-id span { color: #89969c; font: 9px DM Mono, monospace; letter-spacing: .15em; }
|
||||
.project-id b { color: #304048; font-size: 13px; }
|
||||
.top-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.top-actions button, .account-avatar { width: 32px; height: 32px; border: 1px solid var(--line); border-radius: 10px; background: #eef2f3; color: #53636b; display: grid; place-items: center; }
|
||||
.account-avatar { color: #fff; background: var(--teal); border-color: var(--teal); }
|
||||
|
||||
.workspace-body {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 18px;
|
||||
padding: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.panel, .summary-panel, .detail-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(251, 252, 251, .92);
|
||||
box-shadow: 0 16px 42px rgba(61, 80, 89, .1);
|
||||
}
|
||||
|
||||
.summary-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.summary-head, .panel-head, .detail-head, .artifact-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-head strong, .panel-head strong, .detail-head strong, .artifact-head strong {
|
||||
font-size: 13px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.summary-head span, .panel-head span, .panel-head small, .detail-head span {
|
||||
min-width: 0;
|
||||
color: #7c8a90;
|
||||
font: 9px DM Mono, monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.summary-metrics div, .detail-foot div {
|
||||
padding: 13px 10px 12px 0;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.summary-metrics div:last-child, .detail-foot div:last-child { border-right: 0; }
|
||||
.summary-metrics span, .detail-foot span, .detail-grid span { display: block; color: #849198; font-size: 9px; margin-bottom: 6px; }
|
||||
.summary-metrics b, .detail-foot b, .detail-grid b { display: block; color: #33474f; font: 13px DM Mono, monospace; overflow-wrap: anywhere; }
|
||||
|
||||
.notice {
|
||||
margin-top: 14px;
|
||||
padding: 10px 11px;
|
||||
border-radius: 7px;
|
||||
background: #eef5f5;
|
||||
color: #4d6b72;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-head > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-head button, .artifact-head button {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: #f1f5f5;
|
||||
color: #51656d;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.generator-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kind-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kind-switch button {
|
||||
min-width: 0;
|
||||
height: 66px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f4f7f7;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.kind-switch button.active {
|
||||
border-color: rgba(52, 127, 130, .42);
|
||||
background: #e8f3f2;
|
||||
}
|
||||
|
||||
.kind-switch b { display: block; font-size: 11px; }
|
||||
.kind-switch span { display: block; margin-top: 6px; color: #7e8b91; font-size: 9px; line-height: 1.35; }
|
||||
|
||||
.generator-form label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.generator-form label > span {
|
||||
color: #7d8a90;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.generator-form textarea, .generator-form select {
|
||||
width: 100%;
|
||||
border: 1px solid #d3dcdf;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #35444b;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.generator-form textarea {
|
||||
height: 92px;
|
||||
padding: 10px;
|
||||
line-height: 1.55;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.generator-form select {
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.generate-button {
|
||||
height: 38px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f7f9f8;
|
||||
color: #405159;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-row.active {
|
||||
border-color: rgba(52, 127, 130, .46);
|
||||
background: #eaf4f3;
|
||||
}
|
||||
|
||||
.task-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.task-row-top span, .task-row small {
|
||||
color: #849198;
|
||||
font: 9px DM Mono, monospace;
|
||||
}
|
||||
|
||||
.task-row b {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: #30444c;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.task-row-top em, .detail-head em {
|
||||
flex: none;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
background: #eef2f3;
|
||||
color: #718087;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.is-success { color: var(--green) !important; background: #e8f5ee !important; }
|
||||
.is-running { color: var(--amber) !important; background: #fff4e5 !important; }
|
||||
.is-error { color: var(--red) !important; background: #fff0f0 !important; }
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border: 1px dashed #cbd7da;
|
||||
border-radius: 8px;
|
||||
color: #87949a;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.detail-empty {
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
color: #7c8a90;
|
||||
}
|
||||
|
||||
.detail-head {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.detail-head > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-head strong {
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-grid > div {
|
||||
min-width: 0;
|
||||
padding: 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f7f9f8;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 11px;
|
||||
border: 1px solid #e1b3b3;
|
||||
border-radius: 8px;
|
||||
background: #fff5f5;
|
||||
color: var(--red);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.artifact-head {
|
||||
margin-top: 22px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.artifact-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.artifact-list a {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #f7f9f8;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.artifact-list a:hover {
|
||||
border-color: rgba(52, 127, 130, .42);
|
||||
background: #eaf4f3;
|
||||
}
|
||||
|
||||
.artifact-list a div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.artifact-list b {
|
||||
display: block;
|
||||
color: #30444c;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artifact-list small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #7e8c92;
|
||||
font: 9px DM Mono, monospace;
|
||||
}
|
||||
|
||||
.detail-foot {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.parameter-editor {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.parameter-editor > strong {
|
||||
display: block;
|
||||
color: #53666e;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.parameter-editor > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.parameter-editor label {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 88px 44px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: #f7f9f8;
|
||||
}
|
||||
|
||||
.parameter-editor label > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #718087;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.parameter-editor input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 7px;
|
||||
border: 1px solid #d3dcdf;
|
||||
border-radius: 6px;
|
||||
outline: 0;
|
||||
background: #fff;
|
||||
color: #33474f;
|
||||
font: 10px DM Mono, monospace;
|
||||
}
|
||||
|
||||
.parameter-editor input:focus {
|
||||
border-color: rgba(52, 127, 130, .5);
|
||||
}
|
||||
|
||||
.parameter-editor button {
|
||||
width: 44px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #eef4f3;
|
||||
color: #46666c;
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.parameter-editor button:hover {
|
||||
border-color: rgba(52, 127, 130, .42);
|
||||
background: #eaf4f3;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
body { min-width: 1020px; }
|
||||
.app-shell { grid-template-columns: 300px 1fr; }
|
||||
.workspace-body { grid-template-columns: 330px 1fr; gap: 14px; padding: 16px; }
|
||||
}
|
||||
|
||||
+16
-4
@@ -1,13 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
const backendHost = process.env.BACKEND_HOST || '127.0.0.1'
|
||||
const backendPort = process.env.BACKEND_PORT || '8000'
|
||||
const agentHost = process.env.AGENT_HOST || '127.0.0.1'
|
||||
const agentPort = process.env.AGENT_PORT || '8010'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
host: process.env.FRONTEND_HOST || '127.0.0.1',
|
||||
port: Number(process.env.FRONTEND_PORT || 5173),
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8000',
|
||||
'/project-assets': 'http://127.0.0.1:8000',
|
||||
'/api/chat': `http://${agentHost}:${agentPort}`,
|
||||
'/api/config': `http://${agentHost}:${agentPort}`,
|
||||
'/api/project-summary': `http://${backendHost}:${backendPort}`,
|
||||
'/api/generate': `http://${backendHost}:${backendPort}`,
|
||||
'/api/tasks': `http://${backendHost}:${backendPort}`,
|
||||
'/api/upload': `http://${backendHost}:${backendPort}`,
|
||||
'/project-assets': `http://${backendHost}:${backendPort}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,14 +4,20 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKEND_DIR="$ROOT_DIR/backend"
|
||||
FRONTEND_DIR="$ROOT_DIR/frontend"
|
||||
AGENT_DIR="$ROOT_DIR/agent-server"
|
||||
|
||||
BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-8000}"
|
||||
AGENT_HOST="${AGENT_HOST:-127.0.0.1}"
|
||||
AGENT_PORT="${AGENT_PORT:-8010}"
|
||||
FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}"
|
||||
FRONTEND_PORT="${FRONTEND_PORT:-5173}"
|
||||
RUN_NAME="${RUN_NAME:-frontend_split_motor_7nm}"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${AGENT_PID:-}" ]]; then
|
||||
kill "$AGENT_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${BACKEND_PID:-}" ]]; then
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
@@ -30,6 +36,16 @@ echo "Starting backend on http://$BACKEND_HOST:$BACKEND_PORT"
|
||||
) &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "Starting agent server on http://$AGENT_HOST:$AGENT_PORT"
|
||||
(
|
||||
cd "$AGENT_DIR"
|
||||
if [[ ! -d node_modules ]]; then
|
||||
npm install
|
||||
fi
|
||||
CAD_AGENT_STUDIO_CONFIG="$ROOT_DIR/llm.config.yaml" ORBIT_PROJECT_ROOT="$ROOT_DIR" TOOLCHAIN_URL="http://$BACKEND_HOST:$BACKEND_PORT" AGENT_HOST="$AGENT_HOST" AGENT_PORT="$AGENT_PORT" npm run dev
|
||||
) &
|
||||
AGENT_PID=$!
|
||||
|
||||
echo "Starting frontend on http://$FRONTEND_HOST:$FRONTEND_PORT"
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT"
|
||||
BACKEND_HOST="$BACKEND_HOST" BACKEND_PORT="$BACKEND_PORT" AGENT_HOST="$AGENT_HOST" AGENT_PORT="$AGENT_PORT" FRONTEND_HOST="$FRONTEND_HOST" FRONTEND_PORT="$FRONTEND_PORT" npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" --strictPort
|
||||
|
||||
Reference in New Issue
Block a user