Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
@@ -0,0 +1,32 @@
{
"name": "text-to-cad",
"owner": {
"name": "earthtojake"
},
"metadata": {
"description": "CAD, robotics, fabrication, and local review plugins."
},
"description": "CAD, robotics, fabrication, and local review plugins.",
"version": "0.3.9",
"plugins": [
{
"name": "cad",
"source": "./plugins/cad",
"description": "CAD, robotics, fabrication, and local review skills.",
"version": "0.3.9",
"author": {
"name": "earthtojake"
},
"homepage": "https://www.cadskills.xyz",
"repository": "https://github.com/earthtojake/text-to-cad",
"license": "MIT",
"category": "productivity",
"tags": [
"cad",
"robotics",
"fabrication",
"skills"
]
}
]
}
@@ -0,0 +1,20 @@
{
"name": "text-to-cad",
"interface": {
"displayName": "Text to CAD"
},
"plugins": [
{
"name": "cad",
"source": {
"source": "local",
"path": "./plugins/cad"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# Keep CAD exchange files and generated render/topology assets out of normal git object history.
assets/** filter=lfs diff=lfs merge=lfs -text
benchmarks/** filter=lfs diff=lfs merge=lfs -text
# Keep benchmark/spec markdown in normal git so reviews and diffs stay readable.
assets/**/*.md !filter !diff !merge text
benchmarks/**/*.md !filter !diff !merge text
*.step filter=lfs diff=lfs merge=lfs -text
*.stp filter=lfs diff=lfs merge=lfs -text
*.stl filter=lfs diff=lfs merge=lfs -text
*.3mf filter=lfs diff=lfs merge=lfs -text
*.gcode filter=lfs diff=lfs merge=lfs -text
*.gcode.3mf filter=lfs diff=lfs merge=lfs -text
*.dxf filter=lfs diff=lfs merge=lfs -text
*.glb filter=lfs diff=lfs merge=lfs -text
*.gltf filter=lfs diff=lfs merge=lfs -text
*.obj filter=lfs diff=lfs merge=lfs -text
*.ply filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
docs/public/hero/**/*.glb !filter !diff !merge -text
.*.step.glb filter=lfs diff=lfs merge=lfs -text
.*.stp.glb filter=lfs diff=lfs merge=lfs -text
**/.*.step/**/*.glb filter=lfs diff=lfs merge=lfs -text
**/.*.step/topology.bin filter=lfs diff=lfs merge=lfs -text
**/.*.step/topology.json filter=lfs diff=lfs merge=lfs -text
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-checkout.\n"; exit 2; }
git lfs post-checkout "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-commit.\n"; exit 2; }
git lfs post-commit "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-merge.\n"; exit 2; }
git lfs post-merge "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
REPO_ROOT="$(git rev-parse --show-toplevel)"
exec "$REPO_ROOT/scripts/git-hooks/pre-commit" "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/pre-push.\n"; exit 2; }
git lfs pre-push "$@"
+39
View File
@@ -0,0 +1,39 @@
name: Set up dependencies
description: >-
Set up Python and Node.js with dependency caching, then install the repo
Python and Node dependencies used by tests and production bundling.
runs:
using: composite
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: |
packages/cadjs/package-lock.json
packages/implicitjs/package-lock.json
viewer/package-lock.json
docs/package-lock.json
- name: Install Python dependencies
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-dev.txt
- name: Install Node dependencies
shell: bash
run: |
npm ci --prefix packages/cadjs
npm ci --prefix packages/implicitjs
npm ci --prefix viewer
npm ci --prefix docs
+29
View File
@@ -0,0 +1,29 @@
changelog:
exclude:
labels:
- ignore-for-release
authors:
- github-actions[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- semver-major
- title: Features
labels:
- enhancement
- feature
- semver-minor
- title: Fixes
labels:
- bug
- fix
- semver-patch
- title: Maintenance
labels:
- dependencies
- documentation
- maintenance
- title: Other Changes
labels:
- "*"
+60
View File
@@ -0,0 +1,60 @@
name: Deploy Docs
on:
workflow_dispatch:
inputs:
ref:
description: Production-layout ref to deploy, normally main or a publish commit.
required: true
default: main
type: string
workflow_call:
inputs:
ref:
description: Production-layout ref to deploy.
required: true
type: string
permissions:
contents: read
concurrency:
group: deploy-docs
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs app
runs-on: ubuntu-latest
steps:
- name: Check out deploy ref
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install Vercel CLI
run: npm install --global vercel@50.28.0
- name: Deploy docs app to Vercel production
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_DOCS_PROJECT_ID: ${{ secrets.VERCEL_DOCS_PROJECT_ID }}
run: |
for name in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_DOCS_PROJECT_ID; do
if [ -z "${!name:-}" ]; then
echo "Missing GitHub Actions secret: $name" >&2
exit 1
fi
done
scripts/github-workflows/deploy-vercel-app.sh \
--label "Docs app" \
--project-id "$VERCEL_DOCS_PROJECT_ID" \
--public-urls "https://www.cadskills.xyz https://cadskills.xyz https://www.cad.fun https://cad.fun"
+60
View File
@@ -0,0 +1,60 @@
name: Deploy Viewer
on:
workflow_dispatch:
inputs:
ref:
description: Production-layout ref to deploy, normally main or a publish commit.
required: true
default: main
type: string
workflow_call:
inputs:
ref:
description: Production-layout ref to deploy.
required: true
type: string
permissions:
contents: read
concurrency:
group: deploy-viewer
cancel-in-progress: false
jobs:
deploy:
name: Deploy demo viewer app
runs-on: ubuntu-latest
steps:
- name: Check out deploy ref
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install Vercel CLI
run: npm install --global vercel@50.28.0
- name: Deploy demo viewer app to Vercel production
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_VIEWER_PROJECT_ID: ${{ secrets.VERCEL_VIEWER_PROJECT_ID }}
run: |
for name in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_VIEWER_PROJECT_ID; do
if [ -z "${!name:-}" ]; then
echo "Missing GitHub Actions secret: $name" >&2
exit 1
fi
done
scripts/github-workflows/deploy-vercel-app.sh \
--label "Demo viewer app" \
--project-id "$VERCEL_VIEWER_PROJECT_ID" \
--public-urls "https://demo.cadskills.xyz https://demo.cad.fun"
+512
View File
@@ -0,0 +1,512 @@
name: Release
on:
workflow_dispatch:
inputs:
bump:
description: Semver part to bump when set_version is empty.
required: true
default: patch
type: choice
options:
- patch
- minor
- major
set_version:
description: Exact X.Y.Z version to release instead of bumping. Set to the current version to resume a failed publish.
required: false
type: string
base_branch:
description: Branch to release from and merge the release PR into.
required: true
default: develop
type: string
target_branch:
description: Publish target. Use main for releases; use build-test only to test CI/CD or build changes.
required: true
default: main
type: choice
options:
- main
- build-test
dry_run:
description: Show the release version changes without pushing, merging, or publishing.
required: true
default: false
type: boolean
publish:
description: Publish the GitHub Release. Set to false to leave it as a draft.
required: true
default: true
type: boolean
create_release:
description: Create the GitHub Release after pushing the tag.
required: true
default: true
type: boolean
auto_merge:
description: Merge the release PR immediately, then publish. Set to false to only prepare the PR.
required: true
default: true
type: boolean
permissions:
contents: write
pull-requests: write
concurrency:
group: release-${{ inputs.base_branch }}-${{ inputs.target_branch }}
cancel-in-progress: false
jobs:
release-pr:
name: Release PR
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
source_sha: ${{ steps.publish_source.outputs.sha }}
steps:
- name: Check out base branch
uses: actions/checkout@v4
with:
ref: ${{ inputs.base_branch }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Configure release credentials
env:
RELEASE_ORCHESTRATOR_TOKEN: ${{ secrets.RELEASE_ORCHESTRATOR_TOKEN }}
PREPARE_RELEASE_TOKEN: ${{ secrets.PREPARE_RELEASE_TOKEN }}
PUBLISH_PUSH_TOKEN: ${{ secrets.PUBLISH_PUSH_TOKEN }}
BUILD_TEST_PUSH_TOKEN: ${{ secrets.BUILD_TEST_PUSH_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
run: |
release_token="${RELEASE_ORCHESTRATOR_TOKEN:-${PREPARE_RELEASE_TOKEN:-${PUBLISH_PUSH_TOKEN:-${BUILD_TEST_PUSH_TOKEN:-$GITHUB_TOKEN}}}}"
if [ -n "${RELEASE_ORCHESTRATOR_TOKEN:-}" ]; then
echo "Using RELEASE_ORCHESTRATOR_TOKEN for release orchestration."
elif [ -n "${PREPARE_RELEASE_TOKEN:-}" ]; then
echo "Using PREPARE_RELEASE_TOKEN for release orchestration."
elif [ -n "${PUBLISH_PUSH_TOKEN:-}" ]; then
echo "Using PUBLISH_PUSH_TOKEN for release orchestration."
elif [ -n "${BUILD_TEST_PUSH_TOKEN:-}" ]; then
echo "Using BUILD_TEST_PUSH_TOKEN for release orchestration."
else
echo "Using GITHUB_TOKEN for release orchestration."
fi
git remote set-url origin "https://x-access-token:${release_token}@github.com/${GITHUB_REPOSITORY}.git"
{
echo "GH_TOKEN=$release_token"
echo "RELEASE_TOKEN=$release_token"
} >> "$GITHUB_ENV"
- name: Prepare canonical version
id: version
env:
BUMP: ${{ inputs.bump }}
SET_VERSION: ${{ inputs.set_version }}
run: |
if [ -n "$SET_VERSION" ]; then
scripts/release/bump-version.sh --set-version "$SET_VERSION" --no-commit
else
scripts/release/bump-version.sh "$BUMP" --no-commit
fi
node scripts/release/sync-version.mjs
version="$(tr -d '[:space:]' < plugins/cad/VERSION)"
echo "version=$version" >> "$GITHUB_OUTPUT"
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Base branch already contains release version $version; skipping the release PR."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Prepared release version: $version"
fi
- name: Check release version metadata
env:
CHANGED: ${{ steps.version.outputs.changed }}
run: |
node scripts/release/sync-version.mjs --check
if [ "$CHANGED" = "true" ]; then
scripts/release/check-version.sh --incremented-from origin/main
latest_tag="$(git tag --list '[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1 || true)"
if [ -n "$latest_tag" ]; then
scripts/release/check-version.sh --incremented-from "refs/tags/$latest_tag"
fi
else
scripts/release/check-version.sh
echo "No version metadata changes; the publish gate decides whether this version still needs to ship."
fi
- name: Check development symlink layout
run: scripts/dev/setup-symlinks.sh --check
- name: Show dry-run diff
if: inputs.dry_run
run: |
git diff --stat
git diff -- plugins/cad/VERSION
- name: Stop after dry run
if: inputs.dry_run
run: echo "Dry run requested; skipping release branch, PR merge, and publish."
- name: Create or update release pull request
if: ${{ !inputs.dry_run && steps.version.outputs.changed == 'true' }}
id: release_pr
env:
BASE_BRANCH: ${{ inputs.base_branch }}
VERSION: ${{ steps.version.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
branch="release/$VERSION"
body="Bumps plugins/cad/VERSION and derived package/plugin metadata to $VERSION. This PR was created by the one-button Release workflow, which merges it into $BASE_BRANCH immediately; the publish job revalidates the full production bundle before anything ships to the target branch."
git checkout -B "$branch"
git add -A
git commit -m "Release $VERSION"
git push --force-with-lease origin "$branch"
if pr_json="$(gh pr view "$branch" --repo "$GITHUB_REPOSITORY" --json number,url 2>/dev/null)"; then
pr_number="$(printf '%s\n' "$pr_json" | jq -r '.number')"
pr_url="$(printf '%s\n' "$pr_json" | jq -r '.url')"
gh pr edit "$pr_number" \
--repo "$GITHUB_REPOSITORY" \
--base "$BASE_BRANCH" \
--title "Release $VERSION" \
--body "$body"
else
pr_url="$(gh pr create \
--repo "$GITHUB_REPOSITORY" \
--base "$BASE_BRANCH" \
--head "$branch" \
--title "Release $VERSION" \
--body "$body")"
pr_number="$(gh pr view "$pr_url" --repo "$GITHUB_REPOSITORY" --json number --jq '.number')"
fi
echo "created=true" >> "$GITHUB_OUTPUT"
echo "number=$pr_number" >> "$GITHUB_OUTPUT"
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
echo "Release PR: $pr_url"
- name: Merge release pull request
if: ${{ !inputs.dry_run && inputs.auto_merge && steps.release_pr.outputs.created == 'true' }}
env:
PR_NUMBER: ${{ steps.release_pr.outputs.number }}
run: |
pr_json="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid)"
head_sha="$(printf '%s\n' "$pr_json" | jq -r '.headRefOid')"
merged=false
for attempt in $(seq 1 6); do
if gh api \
--method PUT \
"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/merge" \
-f merge_method=merge \
-f sha="$head_sha"; then
merged=true
break
fi
echo "Merge attempt $attempt failed; retrying while GitHub computes mergeability..."
sleep 10
done
if [ "$merged" != "true" ]; then
echo "Could not merge release PR #$PR_NUMBER." >&2
exit 1
fi
- name: Stop before publish when auto-merge is disabled
if: ${{ !inputs.dry_run && !inputs.auto_merge }}
run: echo "auto_merge=false; the release PR was prepared but the publish jobs were skipped."
- name: Resolve publish source
if: ${{ !inputs.dry_run && inputs.auto_merge }}
id: publish_source
env:
BASE_BRANCH: ${{ inputs.base_branch }}
run: |
git fetch --no-tags origin "+$BASE_BRANCH:refs/remotes/origin/$BASE_BRANCH"
source_sha="$(git rev-parse "origin/$BASE_BRANCH^{commit}")"
echo "sha=$source_sha" >> "$GITHUB_OUTPUT"
echo "Publishing $BASE_BRANCH at $source_sha."
publish:
name: Publish
needs: release-pr
if: ${{ !inputs.dry_run && inputs.auto_merge }}
runs-on: ubuntu-latest
outputs:
published: ${{ steps.push.outputs.published }}
publish_sha: ${{ steps.commit.outputs.publish_sha }}
steps:
- name: Check out publish source
uses: actions/checkout@v4
with:
ref: ${{ needs.release-pr.outputs.source_sha }}
fetch-depth: 0
persist-credentials: false
- name: Configure publish credentials
env:
PUBLISH_PUSH_TOKEN: ${{ secrets.PUBLISH_PUSH_TOKEN }}
BUILD_TEST_PUSH_TOKEN: ${{ secrets.BUILD_TEST_PUSH_TOKEN }}
GH_TOKEN: ${{ github.token }}
run: |
if [ -n "$PUBLISH_PUSH_TOKEN" ]; then
git remote set-url origin "https://x-access-token:${PUBLISH_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
echo "Using PUBLISH_PUSH_TOKEN for publish push."
elif [ -n "$BUILD_TEST_PUSH_TOKEN" ]; then
git remote set-url origin "https://x-access-token:${BUILD_TEST_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
echo "Using BUILD_TEST_PUSH_TOKEN for publish push."
else
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
echo "Using GITHUB_TOKEN for publish push."
fi
- name: Check canonical release version
run: scripts/release/check-version.sh
- name: Evaluate release gate
id: gate
env:
BASE_BRANCH: ${{ inputs.base_branch }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
source_sha="$(git rev-parse HEAD)"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
if [ "$TARGET_BRANCH" != "main" ]; then
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "Build-test publish requested for $source_sha (CI/CD or build testing only)."
exit 0
fi
if [ "$BASE_BRANCH" != "develop" ]; then
echo "Real releases must publish develop to main; got base_branch=$BASE_BRANCH." >&2
exit 2
fi
current_version="$(tr -d '[:space:]' < plugins/cad/VERSION)"
latest_tag="$(git tag --list '[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | head -n 1 || true)"
if ! scripts/release/check-version.sh --incremented-from origin/main >/tmp/publish-main-version-check.log 2>&1; then
main_version="$(git show origin/main:plugins/cad/VERSION | tr -d '[:space:]')"
if [ "$main_version" = "$current_version" ] && ! git rev-parse --verify --quiet "refs/tags/$current_version" >/dev/null; then
echo "Release version already reached origin/main, but tag $current_version is missing; resuming publish."
else
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "Skipping publish: version has not advanced past origin/main."
exit 0
fi
fi
if [ -n "$latest_tag" ] && ! scripts/release/check-version.sh --incremented-from "refs/tags/$latest_tag" >/tmp/publish-tag-version-check.log 2>&1; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "Skipping publish: version has not advanced past latest tag $latest_tag."
exit 0
fi
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "Release version advanced; publishing $source_sha to $TARGET_BRANCH."
- name: Skip non-release publish
if: steps.gate.outputs.should_publish != 'true'
run: echo "Publish only updates main when the source version is newer than main and the latest release tag."
- name: Validate publish target
if: steps.gate.outputs.should_publish == 'true'
env:
SOURCE_SHA: ${{ steps.gate.outputs.source_sha }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
scripts/release/check-publish-source.sh --source-ref "$SOURCE_SHA" --target-ref origin/main
if [ "$TARGET_BRANCH" = "main" ]; then
echo "Validated main publish target."
else
echo "Validated build-test target against origin/main."
fi
- name: Set up dependencies
if: steps.gate.outputs.should_publish == 'true'
uses: ./.github/actions/setup-deps
- name: Check development symlink layout
if: steps.gate.outputs.should_publish == 'true'
run: scripts/dev/setup-symlinks.sh --check
- name: Bundle production outputs
if: steps.gate.outputs.should_publish == 'true'
run: scripts/bundle/bundle.sh --clean
- name: Validate production bundle layout
if: steps.gate.outputs.should_publish == 'true'
run: scripts/github-workflows/check-builds.sh --skip-bundle-check
- name: Run documentation checks
if: steps.gate.outputs.should_publish == 'true'
run: scripts/test/test-docs.sh
- name: Run code tests
if: steps.gate.outputs.should_publish == 'true'
run: scripts/test/test.sh
- name: Remove models from publish tree
if: steps.gate.outputs.should_publish == 'true'
run: |
rm -rf models
if [ -e models ]; then
echo "Failed to remove models from the publish tree." >&2
exit 1
fi
- name: Commit publish result
if: steps.gate.outputs.should_publish == 'true'
id: commit
env:
BASE_BRANCH: ${{ inputs.base_branch }}
SOURCE_SHA: ${{ steps.gate.outputs.source_sha }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
if [ -n "$(git ls-files -- models)" ]; then
echo "models/ must not be present in the publish commit." >&2
git ls-files -- models >&2
exit 1
fi
version="$(tr -d '[:space:]' < plugins/cad/VERSION)"
if target_base_sha="$(git rev-parse --verify "origin/$TARGET_BRANCH^{commit}" 2>/dev/null)"; then
echo "Using existing $TARGET_BRANCH as publish parent: $target_base_sha"
else
target_base_sha="$(git rev-parse --verify "origin/main^{commit}")"
echo "Target branch $TARGET_BRANCH does not exist; using origin/main as publish parent: $target_base_sha"
fi
release_base_sha="$(git rev-parse --verify "origin/main^{commit}")"
previous_source_sha="$(scripts/release/check-publish-source.sh --target-ref origin/main --print-previous-source)"
included_commits="$(git log --oneline --no-decorate "$previous_source_sha..$SOURCE_SHA" || true)"
message_file="$(mktemp)"
{
echo "Publish $version from $BASE_BRANCH to $TARGET_BRANCH"
echo
echo "Source ref: $BASE_BRANCH"
echo "Source commit: $SOURCE_SHA"
echo "Target branch: $TARGET_BRANCH"
echo "Previous target: $target_base_sha"
echo "Release base: $release_base_sha"
echo "Previous source: $previous_source_sha"
echo
echo "Included commits since previous source:"
if [ -n "$included_commits" ]; then
echo "$included_commits"
else
echo "(no source commits beyond previous published source)"
fi
} > "$message_file"
tree_sha="$(git write-tree)"
commit_parents=(-p "$target_base_sha")
if [ "$SOURCE_SHA" != "$target_base_sha" ]; then
commit_parents+=(-p "$SOURCE_SHA")
fi
publish_commit="$(git commit-tree "$tree_sha" "${commit_parents[@]}" -F "$message_file")"
git reset --hard "$publish_commit"
echo "publish_sha=$publish_commit" >> "$GITHUB_OUTPUT"
echo "Created publish commit: $publish_commit"
- name: Push target branch
if: steps.gate.outputs.should_publish == 'true'
id: push
env:
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
if [ "$TARGET_BRANCH" = "main" ]; then
git push origin HEAD:main
else
git push --force-with-lease origin "HEAD:$TARGET_BRANCH"
fi
echo "published=true" >> "$GITHUB_OUTPUT"
upload-models:
name: Upload Models
needs:
- release-pr
- publish
if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }}
uses: ./.github/workflows/upload-models.yml
with:
ref: ${{ needs.release-pr.outputs.source_sha }}
secrets: inherit
deploy-docs:
name: Deploy Docs
needs: publish
if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }}
uses: ./.github/workflows/deploy-docs.yml
with:
ref: ${{ needs.publish.outputs.publish_sha }}
secrets: inherit
deploy-viewer:
name: Deploy Viewer
needs: publish
if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }}
uses: ./.github/workflows/deploy-viewer.yml
with:
ref: ${{ needs.publish.outputs.publish_sha }}
secrets: inherit
tag-release:
name: Tag and GitHub Release
needs:
- publish
- upload-models
- deploy-docs
- deploy-viewer
if: ${{ needs.publish.outputs.published == 'true' && inputs.target_branch == 'main' }}
runs-on: ubuntu-latest
steps:
- name: Check out publish commit
uses: actions/checkout@v4
with:
ref: ${{ needs.publish.outputs.publish_sha }}
fetch-depth: 0
persist-credentials: false
- name: Configure tag push credentials
env:
PUBLISH_PUSH_TOKEN: ${{ secrets.PUBLISH_PUSH_TOKEN }}
GH_TOKEN: ${{ github.token }}
run: |
if [ -n "$PUBLISH_PUSH_TOKEN" ]; then
git remote set-url origin "https://x-access-token:${PUBLISH_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
echo "Using PUBLISH_PUSH_TOKEN for tag push."
else
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
echo "Using GITHUB_TOKEN for tag push."
fi
- name: Create release tag and GitHub Release
env:
GH_TOKEN: ${{ github.token }}
INPUT_PUBLISH: ${{ inputs.publish }}
INPUT_CREATE_RELEASE: ${{ inputs.create_release }}
run: |
args=(--target HEAD)
if [ "$INPUT_PUBLISH" = "true" ]; then
args+=(--publish)
fi
if [ "$INPUT_CREATE_RELEASE" != "true" ]; then
args+=(--skip-release)
fi
scripts/release/publish-github-release.sh "${args[@]}"
+59
View File
@@ -0,0 +1,59 @@
name: Test
on:
workflow_dispatch:
pull_request:
branches:
- develop
push:
branches:
- develop
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
version:
name: Version Check
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Check release version
run: |
scripts/release/check-version.sh
node scripts/release/sync-version.mjs --check
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Check development layout
run: scripts/github-workflows/check-symlinks.sh
- name: Set up dependencies
uses: ./.github/actions/setup-deps
- name: Bundle production outputs
run: scripts/bundle/bundle.sh --clean
- name: Check production bundle layout
run: scripts/github-workflows/check-builds.sh --skip-bundle-check
- name: Run documentation checks
run: scripts/test/test-docs.sh
- name: Run code tests
run: scripts/test/test.sh
+59
View File
@@ -0,0 +1,59 @@
name: Upload Models
on:
workflow_dispatch:
inputs:
ref:
description: Branch, tag, or SHA to upload models from. Use a source branch; main does not contain models/.
required: true
default: develop
type: string
workflow_call:
inputs:
ref:
description: Branch, tag, or SHA to upload models from.
required: true
type: string
permissions:
contents: read
concurrency:
group: upload-models
cancel-in-progress: false
jobs:
upload:
name: Upload models catalog
runs-on: ubuntu-latest
steps:
- name: Check out upload ref
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: viewer/package-lock.json
- name: Install viewer dependencies
run: npm ci --prefix viewer
- name: Upload viewer models catalog
env:
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: ${{ secrets.VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
run: |
if [ ! -d models ]; then
echo "No models directory at this ref; upload from a source branch such as develop." >&2
exit 1
fi
if [ -z "${VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN:-}" ] && [ -z "${BLOB_READ_WRITE_TOKEN:-}" ]; then
echo "Missing VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN or BLOB_READ_WRITE_TOKEN secret." >&2
exit 1
fi
scripts/viewer/upload-viewer-models-catalog.sh "$PWD/models" --skip-existing --fetch-missing-lfs
+39
View File
@@ -0,0 +1,39 @@
# macOS
.DS_Store
.vscode
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.coverage.*
htmlcov/
.python-version
# Local environments and caches
.venv
.cache
.agents/
.vite
node_modules
dist
coverage
# Production CAD Viewer runtime dist is committed on main. On dev,
# skills/cad-viewer/scripts/viewer must stay a symlink to viewer/.
!skills/cad-viewer/scripts/viewer/dist/
!skills/cad-viewer/scripts/viewer/dist/**
# Temporary files
*.tmp
*.swp
*.step.glb.lock
*.stp.glb.lock
tmp/
# Local printer credentials
/bambu-printers.json
+2
View File
@@ -0,0 +1,2 @@
[lfs]
fetchexclude = assets/**,benchmarks/**,models/**,.agents/skills/**/assets/*.gif
+188
View File
@@ -0,0 +1,188 @@
# AGENTS.md
This repo is a workbench for CAD-related agent skills. Treat `skills/` as the
product and `models/` as the shared fixture/artifact area.
## Branch And Layout First
Before changing code, branch from `develop`, not `main`; PRs should target `develop`.
Do not start development work from `main`. The `develop` branch intentionally uses
symlinks across generated runtime, viewer-local package, and plugin package
paths. When a path is symlinked, follow the link and edit the source target.
Use `main` as the production clone/release branch only. `main` is publish-only:
do not open PRs to `main` or push it directly.
## Release Workflow
Do not bump the canonical release version in `plugins/cad/VERSION` during
normal development work. Ship releases only through the single `Release`
GitHub Actions workflow, which handles the version bump, release PR, publish
commit to `main`, models upload, web-app deploys, semver tag, and GitHub
Release in one run.
When asked to publish, make, or ship a release, dispatch `Release` with its
defaults: build from `develop` (`base_branch=develop`), publish to `main`
(`target_branch=main`), and publish the GitHub Release (`publish=true`, not a
draft). Never pick the semver bump yourself: if the request does not name
patch, minor, major, or an exact version, ask which one before dispatching.
Use `target_branch=build-test` only when the user explicitly asks to test
CI/CD or build-pipeline changes — never by default and never as part of a
requested release. Rerun `Release` with `set_version` pinned to the current
version to resume a failed publish.
The standalone `Deploy Docs` and `Deploy Viewer` workflows redeploy the
individual web apps from `main`, and the standalone `Upload Models` workflow
uploads the `models/` catalog to Vercel Blob from `develop`, all without
running a release. `main` is publish-only; pushing `develop` runs tests but
never publishes. See the Releases section in `CONTRIBUTING.md` for the full
flow, CI/CD-testing and resume options, and local/manual fallbacks.
## Repo Map
- `skills/`: agent skills and their references/scripts.
- `plugins/`: versioned agent plugin packages that bundle repo skills.
- `models/`: sample and durable CAD/robot-description fixtures.
- `viewer/`: editable CAD Viewer source app.
- `packages/cadjs`: shared JS CAD/render/runtime code, UI-framework agnostic.
- `packages/implicitjs`: standalone JS implicit CAD model, shader render,
snapshot, mesh sampling, and export runtime.
- `packages/cadpy`: shared Python STEP/GLB/topology artifact code.
- `packages/cadpy_metadata`: dependency-free Python metadata helpers vendored
into generated URDF/SRDF/SDF skill runtimes.
- `docs/`: documentation site.
- `tests/`: root-owned test suites for skills, packages, viewer services, and
repo-wide policy.
- `scripts/`: durable repo commands grouped by purpose.
## Repo Rules
- Keep root guidance short. Put domain workflows, CLI details, and validation
policy in the relevant `skills/<skill>/SKILL.md` or `references/` file.
- Keep relevant Markdown docs current when changing behavior, commands, or repo
layout, but do not bloat `AGENTS.md`; use it only for durable repo-level
rules and pointers.
- Read `CONTRIBUTING.md` before committing, rebasing, resolving generated-file
conflicts, or bumping release versions.
- Keep the primary local `develop` checkout in symlink layout with
`scripts/dev/setup-symlinks.sh`. Do not auto-repair that layout from
Codex or Claude Code startup hooks in linked worktrees.
- Each skill must be self-contained and independent at runtime. A skill must
not refer to or import or depend on code from another skill, from `skills/`
root, or from repository-root modules. Do not add `skills/`, the repository
root, or sibling skill directories to `sys.path`, `PYTHONPATH`, `NODE_PATH`,
or similar runtime lookup paths. Shared runtime helpers must live under
`packages/` as the source of truth and be vendored/generated from there into
each consuming skill runtime; do not keep shared helper modules directly under
`skills/`.
- Edit the source reached by the `develop` symlink layout first, then regenerate
explicit derived outputs when a production-output task requires it.
- Write all test, sample, permanent, and generated CAD/robot-description
artifacts under `models/`, including STEP/STP, STL, GLB, DXF, URDF, SRDF,
SDF, and G-code outputs. Do not create ad hoc artifact directories elsewhere.
- Reserve `scripts/` for durable repo commands. Do not write temporary,
one-off, or local-only helper scripts there; use `tmp/` or `/tmp` instead.
- Development symlinks mark generated or copied paths. If a file is under a
symlinked runtime, viewer package, or plugin package path, edit the symlink
target/source path instead of treating the copy as independent.
- When source changes affect generated runtimes or plugin packages, refresh or
check them with the master bundle wrapper, `scripts/bundle/bundle.sh`. Use
lower-level bundle scripts only when debugging the wrapper itself.
- `packages/cadjs` must stay reusable/non-React; app UI and workflow state
belong in `viewer/`.
- `packages/implicitjs` must stay reusable/non-React and independent of
`packages/cadjs`; CAD Viewer and snapshot tools should consume its shared
render/export APIs instead of duplicating implicit CAD logic.
- `packages/cadpy` owns reusable Python artifact generation; skills should use
bundled package code, not sibling skill imports.
- Create lightweight shared Python packages under `packages/cadpy_*` when a
helper should not inherit heavier package dependencies.
- Use path-targeted search, validation, and `git status`; avoid broad scans over
generated CAD/LFS artifacts unless the task requires them.
- Treat `plugins/cad/VERSION` as the canonical release version. Do not hand-edit
duplicate package, plugin, lockfile, or Python `pyproject.toml` versions;
release preparation and `scripts/bundle/bundle.sh` stamp them from the
canonical version.
## Environments
- Prefer `./.venv/bin/python` for CAD Python work.
- Keep new branch checkouts and git worktrees lightweight by default. Do not
copy `.venv/` or `models/` through `.worktreeinclude`; recreate `.venv/`
inside the worktree only when Python dependencies are needed for the workflow.
- In Codex or Claude Code worktrees, prefer the skill instructions and scripts
under the current worktree's `skills/` directory over globally installed
skill symlinks from another checkout.
- If a worktree explicitly needs the development symlink layout, run
`scripts/dev/setup-symlinks.sh --check` and then
`scripts/dev/setup-symlinks.sh` intentionally in that worktree.
- Hydrate `models/` only when the user asks for it or when the task targets
specific files under `models/`. In a new worktree, make the relevant model
paths real before using them, preferring the local Git LFS cache with
`git lfs checkout <path>` or `git lfs checkout models`. Download missing LFS
objects only when explicitly requested or required after confirming the local
cache is missing them.
- Install dependencies only for the workflow being changed.
- Do not commit `.venv/`, `node_modules/`, caches, `tmp/`, local credentials, or
printer config.
## Checks
Run the smallest path-targeted check that covers the change. Use broad wrappers
when touching shared surfaces or before handoff:
- Code tests: `scripts/test/test.sh`
- In GitHub Actions, `test.yml` checks the canonical release version as a
separate non-blocking job; its test job verifies the `develop` symlink
layout, bundles temporary production outputs, and runs docs and code tests
against that bundle. `main` writes are validated by the `Release`
workflow's publish job; GitHub branch settings should block PRs and direct
pushes to `main`.
- Focused test runners: `scripts/test/test-js.sh`,
`scripts/test/test-docs.sh`, `scripts/test/test-python.sh`,
`scripts/test/test-global.sh`
- Development symlink layout: `scripts/dev/setup-symlinks.sh --check`
- Canonical release version: `scripts/release/check-version.sh`
- Generated runtime and plugin freshness: `scripts/bundle/bundle.sh --check`
- CAD Viewer, `packages/cadjs`, or `packages/implicitjs`:
`npm --prefix packages/cadjs test`, `npm --prefix packages/implicitjs test`,
`npm --prefix viewer run test`, `npm --prefix viewer run build`
- Docs site: `npm --prefix docs run check`
- Targeted Python tests: `./.venv/bin/python -m unittest <changed test paths>`
When a task intentionally writes production outputs locally, run
`scripts/bundle/bundle.sh`, rerun `scripts/bundle/bundle.sh --check`, and restore
the development symlink layout afterward if you are continuing on `develop`.
## CAD Viewer
When reviewing repo fixtures in CAD Viewer, point the Viewer at the repo
`models/` directory with an absolute `?dir=` path; keep any permanent or
generated CAD/robot-description files in `models/` so the viewer catalog and
artifacts stay in one place.
Start or reuse the Viewer through the `cad-viewer` skill launcher and use the
base URL it prints. The launcher owns port selection, reuses a compatible live
Viewer for the same worktree/branch, and uses the source app in Vite dev mode
when the skill viewer path is a development symlink.
Run from `skills/cad-viewer`:
```bash
npm --prefix scripts/viewer run agent:start -- --host 127.0.0.1 --shutdown-after 12h
```
Every returned Viewer URL must include `?dir=<absolute-model-root>`, commonly
`<repo>/models`, and `file=<path>` values must be relative to `?dir=`. Do not
manually choose or increment ports, do not rely on session-storage `?dir=`
fallbacks, and do not stop an existing Viewer server unless the user asks.
Packaged Viewer runtime and handoff details belong in the `cad-viewer` skill
instructions. Treat packaged Viewer checks as generated-output checks and use
the master bundle wrapper unless you are debugging a lower-level script.
## Git And LFS
CAD exchange files, generated render/topology assets, `assets/**`, and
`benchmarks/**` may be LFS-tracked. Never disable LFS filters for `git add`,
commits, or other object-writing operations. Local hooks live in `.githooks` and
delegate build checks through `scripts/git-hooks/pre-commit`.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+343
View File
@@ -0,0 +1,343 @@
# Contributing
This repository is a local workbench for CAD-related agent skills. Treat
`skills/` as the product under test and `models/` as the shared
fixture/artifact area.
## Local Checkout
For development, branch from `develop` and open PRs back to `develop`:
```bash
git clone --branch develop https://github.com/earthtojake/text-to-cad.git
cd text-to-cad
git switch -c my-change
```
Create the repo-local Python development environment:
```bash
python3.12 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install -r requirements-dev.txt
```
`requirements-dev.txt` installs the source packages from `packages/` and the
small set of Python extras mirrored from skill runtime requirements. This is
the default Python environment for broad repo checks and source-checkout
development. Skill-specific environments may install generated, skill-local
package copies so they match production, but on `develop` you should still edit the
source package under `packages/*`.
For CAD Viewer development:
```bash
npm --prefix viewer install
```
When running a tool manually, use that skill's interpreter:
```bash
.venv/skills/cad/bin/python skills/cad/scripts/step --help
.venv/skills/urdf/bin/python skills/urdf/scripts/urdf --help
```
## Link Skills Into Your Agent
For local development, symlink this checkout's supported skill directories into
your agent. Do not copy skill directories into your agent: symlinks keep edits
in this checkout visible immediately.
Use the installer from the repository root:
```bash
scripts/install/install-skills.sh --agent codex
```
To see supported agents and resolved destination directories:
```bash
scripts/install/install-skills.sh --list-agents
```
The installer discovers each directory under `skills/` that contains
`SKILL.md`, creates one symlink per skill, and leaves existing non-symlink paths
untouched.
Supported local-development agent destinations:
| Agent flag | Destination |
| ----------- | ------------------------------------------------- |
| `codex` | `${CODEX_HOME:-$HOME/.codex}/skills` |
| `claude` | `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills` |
| `gemini` | `$HOME/.gemini/skills` |
| `universal` | `${XDG_CONFIG_HOME:-$HOME/.config}/agents/skills` |
| `project` | `.agents/skills` in this repository |
`claude-code`, `gemini-cli`, `agents`, and `repo` are accepted aliases. Use
`--all` to install into every destination above, or repeat `--agent` for a
smaller set:
```bash
scripts/install/install-skills.sh --agent codex --agent claude
```
Restart or reload the agent after linking so it rescans available skills.
To remove this checkout's skill links while testing provider behavior:
```bash
scripts/install/uninstall-skills.sh --agent codex
```
The uninstaller removes only symlinks that point back at this checkout and
prunes empty destination directories unless `--keep-empty-dirs` is passed.
## Test From This Repository
Run development and test prompts from inside this repository instead of a
separate project checkout. The skills assume this workbench layout while you are
iterating: `models/` contains fixtures and generated CAD artifacts, `viewer/`
contains the editable CAD Viewer source, and repo-relative validation commands
live under `scripts/`.
Write test, sample, and durable CAD/robot-description artifacts under `models/`;
do not create ad hoc artifact directories elsewhere. When you need a scratch
project, create it under this checkout, for example:
```bash
mkdir -p models/experiments/my-test
```
Then start your agent with `/path/to/text-to-cad` as the working directory and
ask it to write files under that scratch path. This keeps skill scripts,
fixtures, generated sidecars, and Viewer links using the same repo-relative
paths that CI and local checks expect.
## Source Boundaries
Each skill must be self-contained and independent when it is installed from a
production branch: it must not import or depend on code from another skill or
from repository-root modules at runtime.
The `develop` branch uses symlinks as a checkout layout convenience. Those symlinks
point generated-output paths back to the canonical sources so contributors can
edit one copy of shared code. They do not relax the runtime self-containment
rule: production branches must be able to replace the symlinks with real copies
that still run without `skills/`, the repository root, or sibling skill
directories on `sys.path`, `PYTHONPATH`, `NODE_PATH`, or similar lookup paths.
Canonical source directories are:
- `skills/*` for skill instructions, references, and skill-owned scripts.
- `viewer/` for CAD Viewer app and server source.
- `packages/*` for shared runtime helpers that are copied into consuming skills
for production.
On `develop`, paths such as `skills/cad-viewer/scripts/viewer`,
`skills/*/scripts/packages/*`, `viewer/packages/*`, and `plugins/cad/skills/*`
should be symlinks when they mirror root sources. Treat those paths as
generated-output aliases, not separate source roots. Edit the canonical source
path instead.
Production-output checks are intentionally centralized. Normal development
should stay in the symlinked `develop` layout. When you specifically need to inspect
production outputs locally, use a temporary checkout or rerun
`scripts/dev/setup-symlinks.sh` afterward, then run:
```bash
scripts/bundle/bundle.sh --clean
scripts/bundle/bundle.sh --check
```
Do not run lower-level bundle scripts as part of routine iteration; use the
script-specific details in `scripts/README.md` only when you are debugging a
production-output check.
## Branch Layouts
Open development PRs against `develop`, not `main`. The `develop` branch keeps
generated copy targets as symlinks so the editable source remains under
`skills/`, `viewer/`, and `packages/`:
```bash
scripts/dev/setup-symlinks.sh
scripts/dev/setup-symlinks.sh --check
```
The `main` production branch must be installable from a plain checkout, so it
contains generated production outputs instead of symlinks. `main` is
publish-only: do not open PRs to `main` or push it directly. The `Test`
workflow runs on `develop` and PRs to `develop`: it starts from the symlink
layout, runs `scripts/bundle/bundle.sh --clean`, checks the production layout
without rebuilding it, runs documentation checks, and runs the code tests
against that generated output.
## Releases
Normal development PRs should not bump `plugins/cad/VERSION`; release versions
are reserved for release PRs so the canonical repo version, Git tag, and GitHub
Release describe the same production commit. PRs that do touch release state
must keep `plugins/cad/VERSION` and derived version metadata valid; the `Test`
workflow checks that metadata in a separate job so code tests still run when it
is wrong.
### Shipping a release
Run the `Release` GitHub Actions workflow. Its defaults are the real-release
settings — build from `develop` (`base_branch=develop`), publish to `main`
(`target_branch=main`), and publish the GitHub Release (`publish=true`, not a
draft) — and the input descriptions in `.github/workflows/release.yml` are
authoritative. Choose the semver bump (`patch`, `minor`, or `major`) or an
exact `set_version` deliberately for every release; if a release request does
not specify one, confirm it rather than assuming:
```bash
gh workflow run release.yml --ref develop -f bump=patch
```
One run bumps `plugins/cad/VERSION` plus derived metadata on a
`release/<version>` branch, opens a release PR, merges it into `develop`
immediately, and then runs the publish, models-upload, web-app deploy, and
tag/GitHub Release jobs in the same run. The release PR does not wait for its own CI checks; the
publish job repeats the full bundle and test validation against exactly what
ships. The publish job ships to `main` only when the
source version is newer than `main` and the latest semver tag, and refuses
sources that do not contain the previous publish source commit. It writes a
generated production merge commit on top of the previous publish target with
the release source as the second parent, which keeps `main` fast-forwardable
while preserving source commits for release notes and contributor attribution.
The GitHub Release is published immediately by default; set `publish=false` to
review it as a draft first. Treat generated outputs as CI products, not edit
targets.
### Testing CI/CD and build changes
Use `target_branch=build-test` only when explicitly testing changes to the
CI/CD pipeline or production build outputs; it is never part of a normal
release and should never be chosen by default. It rehearses the full publish
flow without touching `main`, deploying, or creating a tag/release. Use
`dry_run=true` to preview the version changes only, and `auto_merge=false` to
stop after preparing the release PR.
### Resuming a failed publish
If a run fails partway — including after `main` has moved but before the semver
tag exists — rerun `Release` with `set_version` pinned to the current version.
When `develop` already contains that version, the workflow skips the release PR
and proceeds straight to the publish jobs.
### Redeploying the web apps
The standalone `Deploy Docs` and `Deploy Viewer` workflows redeploy the
individual web apps to Vercel production without running a release. They
default to deploying `main` and expect a production-layout ref:
```bash
gh workflow run deploy-docs.yml -f ref=main
gh workflow run deploy-viewer.yml -f ref=main
```
### Uploading new models
The standalone `Upload Models` workflow uploads the `models/` catalog and CAD
Viewer assets to Vercel Blob without running a release or redeploying the
viewer. It skips assets that already match the remote catalog and fetches only
the missing Git LFS objects. Upload from a source ref — `main` does not
contain `models/`:
```bash
gh workflow run upload-models.yml -f ref=develop
```
### Local and manual fallbacks
For local release preparation, use the same scripts the workflow calls:
```bash
git fetch origin develop
git fetch --tags origin
scripts/release/bump-version.sh patch --no-commit
node scripts/release/sync-version.mjs
scripts/release/check-version.sh --incremented-from origin/main
node scripts/release/sync-version.mjs --check
```
`scripts/release/publish-github-release.sh` is the manual fallback for the tag
and GitHub Release step. Unlike the `Release` workflow, the script creates a
draft release unless `--publish` is passed.
### Repository settings
Configure GitHub branch settings/rulesets so `main` rejects PRs and direct
pushes, leaving the `Release` workflow's publish job as the only writer. Enable
repository tag rulesets for `[0-9]*.[0-9]*.[0-9]*` before publishing from
`main`, and enable immutable releases once the production flow is trusted.
Production users should continue cloning `main`; developers should treat
`develop` plus the `Release` workflow as the only route to `main`.
## Iteration Loop
1. Edit the relevant skill under `skills/<skill-name>/`.
2. Keep skill instructions narrow and executable: say when the skill applies,
what inputs it expects, what it produces, and how to validate the work.
3. Prefer small files in `references/` and reusable scripts in `scripts/` over
long inline instructions.
4. Add or update focused fixtures, tests, or benchmark cases when skill behavior
changes so regressions are measurable.
5. Validate with the smallest relevant check before broad repo checks.
Generated artifacts should not become skill logic unless they are intentional
fixtures. Prefer source files plus deterministic regeneration.
## Common Dev Checks
Use path-targeted validation. Common checks from the repo root:
```bash
scripts/test/test.sh
scripts/dev/setup-symlinks.sh --check
scripts/release/check-version.sh
npm --prefix viewer run test
npm --prefix docs run check
```
Use `AGENTS.md` or `scripts/README.md` for path-specific validation when you are
working in a particular package, skill, plugin, docs site, or production-output
path.
For targeted Python skill-script tests, run the relevant unittest files with the
repo-local Python runtime, for example:
```bash
./.venv/bin/python -m unittest tests/python/skills/urdf/test_cli.py
```
Python tests live under `tests/python/`, grouped by tested surface:
`skills/<skill>`, `packages/<package>`, `viewer/<service>`, and `global`.
For fast CAD Viewer source iteration, run the root viewer app in dev mode. Do
not run the generated viewer from the cad-viewer skill while modifying Viewer
behavior:
```bash
npm --prefix viewer run dev -- --host 127.0.0.1
```
Use the printed URL with an absolute `?dir=/path/to/root` and any absolute
`?file=/path/to/model.step`. Do not assume a fixed dev port unless you pass
Vite's standard `--port` flag. Packaged Viewer runtime checks are
production-output checks; use `scripts/README.md` when you specifically need
that path.
## Git Hygiene
Do not commit local environments, dependency folders, caches, or temp files such
as `.venv/`, `node_modules/`, `.vite/`, `dist/`, `tmp/`, or local credentials.
Generated runtime changes should come from the production-output workflow, not
manual edits inside generated runtime folders.
CAD exchange files, generated render/topology assets, `assets/**`, and
`benchmarks/**` may be LFS-tracked. Never disable LFS filters for `git add`,
commits, or other object-writing operations.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+206
View File
@@ -0,0 +1,206 @@
<div align="center">
<img src="assets/text-to-cad-demo.gif" alt="Demo of the CAD skill generating and previewing CAD geometry" width="100%">
<br>
<pre>
██████╗ █████╗ ██████╗ ███████╗██╗ ██╗██╗██╗ ██╗ ███████╗
██╔════╝██╔══██╗██╔══██╗ ██╔════╝██║ ██╔╝██║██║ ██║ ██╔════╝
██║ ███████║██║ ██║ ███████╗█████╔╝ ██║██║ ██║ ███████╗
██║ ██╔══██║██║ ██║ ╚════██║██╔═██╗ ██║██║ ██║ ╚════██║
╚██████╗██║ ██║██████╔╝ ███████║██║ ██╗██║███████╗███████╗███████║
╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚══════╝
</pre>
A skills library for CAD, robotics, and hardware design agents
[Docs](https://www.cadskills.xyz) | [Demo](https://demo.cadskills.xyz)
[![Join Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/5FGB9DwJYU)
[![GitHub stars](https://img.shields.io/github/stars/earthtojake/text-to-cad?style=for-the-badge&logo=github&label=Stars)](https://github.com/earthtojake/text-to-cad/stargazers)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue?style=for-the-badge)](LICENSE)
[![Follow @earthtojake](https://img.shields.io/badge/Follow-%40earthtojake-000000?style=for-the-badge&logo=x)](https://x.com/earthtojake)
[![Python](https://img.shields.io/badge/Python-3.11+-3776AB?style=for-the-badge&logo=python&logoColor=white)](skills/cad/requirements.txt)
[![STEP](https://img.shields.io/badge/STEP-Export-4A5568?style=for-the-badge)](skills/cad/SKILL.md)
[![STL](https://img.shields.io/badge/STL-Export-4A5568?style=for-the-badge)](skills/cad/SKILL.md)
[![3MF](https://img.shields.io/badge/3MF-Export-4A5568?style=for-the-badge)](skills/cad/SKILL.md)
[![URDF](https://img.shields.io/badge/URDF-Robots-6B46C1?style=for-the-badge)](skills/urdf/SKILL.md)
[![SDF](https://img.shields.io/badge/SDF-Simulation-6B46C1?style=for-the-badge)](skills/sdf/SKILL.md)
[![SRDF](https://img.shields.io/badge/SRDF-MoveIt2-6B46C1?style=for-the-badge)](skills/srdf/SKILL.md)
</div>
# CAD Skills
CAD Skills is a library of agent skills for generating, inspecting, sourcing,
slicing, and handing off CAD and robot-description artifacts from local project
files.
## 🧰 Skills
Install the library to give agents focused workflows for CAD, fabrication,
robot description files, simulation, and local review.
| Skill | Summary | Source |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| CAD Router | Combines text-to-cad, SimpleCADAPI, CADAM, and the CADDesigner workflow, then enforces shared validation and a task-scoped Viewer handoff. | [skills/cad-router](skills/cad-router/SKILL.md) |
| CAD | Creates and edits CAD models from plain-language or image requests, with STEP as the main output along with options to export to STL, 3MF and GLB. | [skills/cad](skills/cad/SKILL.md) |
| CAD Viewer | Shows local browser previews for CAD, G-code, and robot files. | [skills/cad-viewer](skills/cad-viewer/SKILL.md) |
| step.parts | Finds off-the-shelf STEP parts like screws, bearings, motors, and connectors. | [skills/step-parts](skills/step-parts/SKILL.md) |
| DXF | Creates 2D DXF drawings like profiles, templates, gaskets, and cut layouts from Python sources or CAD geometry. | [skills/dxf](skills/dxf/SKILL.md) |
| URDF | Writes robot structure files with links, joints, limits, inertials, and meshes. | [skills/urdf](skills/urdf/SKILL.md) |
| SRDF | Adds MoveIt planning groups, end effectors, poses, and collision rules to a URDF. | [skills/srdf](skills/srdf/SKILL.md) |
| SDF | Creates simulator models and worlds with frames, physics, sensors, and lights. | [skills/sdf](skills/sdf/SKILL.md) |
| SendCutSend | Checks DXF and STEP files before upload to SendCutSend. | [skills/sendcutsend](skills/sendcutsend/SKILL.md) |
| G-code | Slices supported mesh files into validated, printer-profiled FDM `.gcode` with real slicer CLIs. | [skills/gcode](skills/gcode/SKILL.md) |
| Bambu Labs | Dry-runs, uploads, and cautiously starts local Bambu Lab print jobs from validated `.gcode`. | [skills/bambu-labs](skills/bambu-labs/SKILL.md) |
| Implicit CAD | Creates browser-native implicit CAD models using GLSL signed-distance fields and CAD Viewer raymarch rendering. Experimental. | [skills/implicit-cad](skills/implicit-cad/SKILL.md) |
## 💻 Installation
For production use, install or clone from `main`; that branch contains the
generated skill/plugin outputs needed by provider installers.
### Skills
Install CAD Skills with the Skills CLI:
```bash
npx skills install earthtojake/text-to-cad
```
This is the preferred installation path. It installs the individual skills
directly for supported agents.
### Plugins
Provider-native plugin installs are also available for Codex and Claude Code:
```bash
# Codex
codex plugin marketplace add earthtojake/text-to-cad
codex plugin add cad@text-to-cad
```
```bash
# Claude Code
claude plugin marketplace add earthtojake/text-to-cad
claude plugin install cad@text-to-cad
```
Restart your agent if newly installed skills do not appear. For local
development, branch from `develop`, open PRs against `develop`, and use the symlink
workflow in [CONTRIBUTING.md](CONTRIBUTING.md).
## 📸 Screenshots
<table>
<tr>
<td width="33%">
<a href="./assets/text-to-cad-demo.gif">
<img src="./assets/text-to-cad-demo.gif" alt="CAD skill demo showing generated geometry in CAD Viewer" width="100%">
</a>
<a href="./skills/cad/SKILL.md"><strong>CAD</strong></a>
</td>
<td width="33%">
<a href="./assets/urdf-demo.gif">
<img src="./assets/urdf-demo.gif" alt="URDF skill demo showing robot description output in CAD Viewer" width="100%">
</a>
<a href="./skills/urdf/SKILL.md"><strong>URDF</strong></a>
</td>
<td width="33%">
<a href="./assets/srdf-moveit2-demo.gif">
<img src="./assets/srdf-moveit2-demo.gif" alt="SRDF MoveIt2 skill demo showing inverse kinematics in CAD Viewer" width="100%">
</a>
<a href="./skills/srdf/SKILL.md"><strong>SRDF / MoveIt2</strong></a>
</td>
</tr>
</table>
## 🧪 Benchmarks
The repo stores heavyweight assets in `assets/**` and `benchmarks/**` through Git LFS and excludes those trees from default LFS pulls so lightweight clones do not fetch GIF assets. Benchmark markdown remains normal Git for readable diffs. To hydrate only the benchmark assets locally, run:
```bash
git lfs pull --include="benchmarks/**"
```
<table>
<thead>
<tr>
<th>#</th>
<th>Target</th>
<th>Prompt</th>
<th>Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><a href="benchmarks/01-rectangular-calibration-block.md">Rectangular calibration block with four holes</a></td>
<td>Create a centered 100 x 60 x 20 mm block with four 8 mm vertical through-holes. Add only a 2 mm chamfer on the top outer perimeter.</td>
<td><img src="benchmarks/benchmark_01_rectangular_calibration_block.gif" alt="Rectangular calibration block orbit gif" width="220"></td>
</tr>
<tr>
<td>2</td>
<td><a href="benchmarks/02-circular-flange.md">Circular flange with bolt-hole pattern</a></td>
<td>Create an 80 mm diameter, 10 mm thick circular flange with a 30 mm central through-bore. Add six 6 mm through-holes on a 60 mm bolt circle and fillet the outside circular edges.</td>
<td><img src="benchmarks/benchmark_02_circular_flange.gif" alt="Circular flange orbit gif" width="220"></td>
</tr>
<tr>
<td>3</td>
<td><a href="benchmarks/03-l-bracket.md">L-bracket with gussets and two hole directions</a></td>
<td>Create an L-bracket from a base plate and rear vertical plate. Add vertical base holes, horizontal back-plate holes, two triangular gussets, and a filleted base/back transition.</td>
<td><img src="benchmarks/benchmark_03_l_bracket.gif" alt="L-bracket orbit gif" width="220"></td>
</tr>
<tr>
<td>4</td>
<td><a href="benchmarks/04-stepped-shaft-keyway.md">Stepped shaft with keyway</a></td>
<td>Create a 120 mm shaft along X with 20/30/20 mm diameter stepped sections. Add end chamfers and a shallow rectangular keyway on top of the middle section.</td>
<td><img src="benchmarks/benchmark_04_stepped_shaft_keyway.gif" alt="Stepped shaft orbit gif" width="220"></td>
</tr>
<tr>
<td>5</td>
<td><a href="benchmarks/05-open-top-electronics-enclosure.md">Open-top electronics enclosure with bosses</a></td>
<td>Create a hollow open-top enclosure with 3 mm walls and floor. Add four internal standoffs with centered blind holes and 2 mm outside vertical corner fillets.</td>
<td><img src="benchmarks/benchmark_05_open_top_electronics_enclosure.gif" alt="Open-top electronics enclosure orbit gif" width="220"></td>
</tr>
<tr>
<td>6</td>
<td><a href="benchmarks/06-clevis-bracket-lightening-cutouts.md">Aerospace-style clevis bracket with lightening cutouts</a></td>
<td>Create a symmetric clevis bracket with a base plate, two rounded lugs, base mounting holes, and a horizontal lug bore. Add triangular lightening cutouts, reinforcing ribs, and rounded transitions.</td>
<td><img src="benchmarks/benchmark_06_clevis_bracket_lightening_cutouts.gif" alt="Clevis bracket orbit gif" width="220"></td>
</tr>
<tr>
<td>7</td>
<td><a href="benchmarks/07-radial-engine-cylinder.md">Radial-engine-style cylinder with cooling fins</a></td>
<td>Create a vertical engine-cylinder form with a central barrel, 12 cooling fins, a base flange, and a top cap. Add a 35 degree angled spark-plug boss with a coaxial through-hole.</td>
<td><img src="benchmarks/benchmark_07_radial_engine_cylinder.gif" alt="Radial-engine-style cylinder orbit gif" width="220"></td>
</tr>
<tr>
<td>8</td>
<td><a href="benchmarks/08-centrifugal-impeller.md">Centrifugal impeller with backward-curved blades</a></td>
<td>Create a centrifugal impeller with a backplate, hub, and through-bore. Add 12 fused backward-curved blades sweeping about 45 degrees from root to tip.</td>
<td><img src="benchmarks/benchmark_08_centrifugal_impeller.gif" alt="Centrifugal impeller orbit gif" width="220"></td>
</tr>
<tr>
<td>9</td>
<td><a href="benchmarks/09-spiral-staircase.md">Spiral staircase with helical handrail</a></td>
<td>Create a miniature spiral staircase with a central column, base disk, and 20 rising wedge treads. Add a one-revolution helical handrail and vertical balusters at the tread outer ends.</td>
<td><img src="benchmarks/benchmark_09_spiral_staircase.gif" alt="Spiral staircase orbit gif" width="220"></td>
</tr>
<tr>
<td>10</td>
<td><a href="benchmarks/10-planetary-gear-stage.md">Simplified planetary gear stage</a></td>
<td>Create a flat planetary gear assembly with separate sun, planet, ring, carrier, and pin bodies. Use simplified trapezoidal teeth and place three planets around the sun on a 42 mm radius circle.</td>
<td><img src="benchmarks/benchmark_10_planetary_gear_stage.gif" alt="Planetary gear stage orbit gif" width="220"></td>
</tr>
</tbody>
</table>
## 🛠️ Contributing
Development happens from the `develop` branch; open PRs against `develop`, not `main`.
For local contribution workflow, skill linking, and validation guidance, see
[CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,29 @@
# 1. Rectangular Calibration Block With Four Holes
![Rectangular calibration block orbit gif](benchmark_01_rectangular_calibration_block.gif)
## Prompt
Create a single solid STEP model in millimeters. The part is a rectangular block, 100 mm long in X, 60 mm wide in Y, and 20 mm tall in Z. Center the block on the XY origin, with the bottom face at Z = 0.
Add four vertical through-holes, each 8 mm in diameter, located at X = +/-35 mm and Y = +/-20 mm.
Add a 2 mm chamfer to the top perimeter edges only. Do not chamfer the holes.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | Exactly one watertight solid |
| Bounding box | 100 x 60 x 20 mm |
| Block placement | Centered on XY origin; bottom at Z = 0 |
| Hole count | Four through-holes |
| Hole diameter | 8 mm each |
| Hole axes | Parallel to Z |
| Hole locations | X = +/-35 mm, Y = +/-20 mm |
| Hole depth | Fully through the 20 mm block |
| Chamfer | 2 mm chamfer on top outer perimeter only |
| Negative check | No chamfers on holes; no extra bosses, slots, text, or decorative geometry |
@@ -0,0 +1,32 @@
# 2. Circular Flange With Bolt-Hole Pattern
![Circular flange orbit gif](benchmark_02_circular_flange.gif)
## Prompt
Create a single solid circular flange as a STEP model in millimeters. The flange is a cylinder with an outside diameter of 80 mm and a thickness of 10 mm. Its axis is vertical along Z, with the bottom face at Z = 0 and the center at X = 0, Y = 0.
Add a central vertical through-bore with diameter 30 mm.
Add six equally spaced vertical through-holes, each 6 mm in diameter, on a 60 mm bolt-circle diameter.
Add a 1.5 mm fillet to the top and bottom outside circular edges.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | Exactly one watertight solid |
| Outer diameter | 80 mm |
| Thickness | 10 mm |
| Central bore | Diameter 30 mm vertical through-hole |
| Coaxiality | Central bore coaxial with flange outer cylinder |
| Bolt-hole count | Six |
| Bolt-hole diameter | 6 mm each |
| Bolt circle | Hole centers lie on 60 mm diameter circle |
| Angular spacing | 60 degrees between adjacent bolt holes |
| Fillets | 1.5 mm fillets on top and bottom outside circular edges |
| Negative check | No missing bolt holes; no misplaced non-circular holes; no extra patterns |
+42
View File
@@ -0,0 +1,42 @@
# 3. L-Bracket With Gussets and Two Hole Directions
![L-bracket orbit gif](benchmark_03_l_bracket.gif)
## Prompt
Create a single solid L-bracket STEP model in millimeters.
The bracket has a horizontal base plate 80 mm long in X, 50 mm wide in Y, and 8 mm thick in Z. Center the base plate on the XY origin, with its bottom at Z = 0.
Add a vertical back plate along the rear long edge of the base. The back plate is 80 mm long in X, 8 mm thick in Y, and 50 mm tall in Z, rising from the top of the base plate. The back plate should sit along the rear edge at positive Y.
Add two vertical through-holes in the base plate, each 6 mm in diameter, located at X = +/-25 mm and Y = -10 mm.
Add two horizontal through-holes in the vertical plate, each 6 mm in diameter, located at X = +/-25 mm and Z = 30 mm, passing through the 8 mm thickness of the vertical plate.
Add two triangular gussets, each 8 mm thick in X, located at X = +/-20 mm. Each gusset should connect the base plate to the back plate with a right-triangle side profile 30 mm tall and 30 mm deep.
Add 2 mm fillets to the outside corner where the base and back plate meet.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | Exactly one fused watertight solid |
| Base dimensions | 80 x 50 x 8 mm |
| Back plate dimensions | 80 x 8 x 50 mm |
| Back plate placement | Along positive-Y rear edge of base |
| Base hole count | Two |
| Base hole geometry | 6 mm diameter vertical through-holes |
| Base hole locations | X = +/-25 mm, Y = -10 mm |
| Back hole count | Two |
| Back hole geometry | 6 mm diameter horizontal through-holes through Y thickness |
| Back hole locations | X = +/-25 mm, Z = 30 mm |
| Gusset count | Two |
| Gusset geometry | Right-triangle side profile, approximately 30 x 30 mm |
| Gusset placement | At X = +/-20 mm |
| Fillets | 2 mm fillets at base/back outside transition |
| Negative check | No floating gussets; no unfused plates; holes must actually cut through |
@@ -0,0 +1,36 @@
# 4. Stepped Shaft With Keyway
![Stepped shaft orbit gif](benchmark_04_stepped_shaft_keyway.gif)
## Prompt
Create a single solid stepped shaft STEP model in millimeters.
The shaft axis runs along X. The total length is 120 mm. The left end center is at X = 0, Y = 0, Z = 0.
From X = 0 to X = 30, the shaft diameter is 20 mm. From X = 30 to X = 90, the shaft diameter is 30 mm. From X = 90 to X = 120, the shaft diameter is 20 mm.
Add a 1 mm chamfer to both end edges.
Add a rectangular keyway slot on the top of the 30 mm diameter middle section. The keyway is 6 mm wide in Y, 3 mm deep in Z, and runs from X = 40 to X = 80.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | Exactly one watertight solid |
| Shaft axis | Along X, not Z |
| Total length | 120 mm |
| Left section | Diameter 20 mm, length 30 mm |
| Middle section | Diameter 30 mm, length 60 mm |
| Right section | Diameter 20 mm, length 30 mm |
| Coaxiality | All three cylindrical sections share same X-axis |
| End chamfers | 1 mm chamfer at both ends |
| Keyway location | On top of middle section |
| Keyway length | From X = 40 to X = 80 |
| Keyway width | 6 mm in Y |
| Keyway depth | 3 mm downward into shaft |
| Negative check | Keyway should not pass through the shaft; shaft should not be vertical |
@@ -0,0 +1,41 @@
# 5. Open-Top Electronics Enclosure With Bosses
![Open-top electronics enclosure orbit gif](benchmark_05_open_top_electronics_enclosure.gif)
## Prompt
Create a single solid open-top electronics enclosure base as a STEP model in millimeters.
The outer shape is a rectangular box 100 mm long in X, 70 mm wide in Y, and 30 mm tall in Z. Center it on the XY origin, with the bottom face at Z = 0.
The enclosure is open at the top. The wall thickness is 3 mm and the bottom floor thickness is 3 mm.
Add four internal cylindrical standoffs rising from the inside floor. Each standoff has an outside diameter of 10 mm and a height of 12 mm above the inside floor. Place the standoffs at X = +/-35 mm and Y = +/-25 mm.
Add a centered blind hole in each standoff, 3 mm in diameter and 8 mm deep from the top of the standoff.
Add 2 mm radius fillets to the four outside vertical corners of the enclosure.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | One solid body representing the enclosure material |
| Outer dimensions | 100 x 70 x 30 mm |
| Placement | Centered on XY origin; bottom at Z = 0 |
| Top condition | Open top, not capped |
| Wall thickness | 3 mm |
| Floor thickness | 3 mm |
| Interior cavity | Correctly hollowed, with continuous walls and floor |
| Standoff count | Four |
| Standoff diameter | Diameter 10 mm |
| Standoff height | 12 mm above inside floor |
| Standoff locations | X = +/-35 mm, Y = +/-25 mm |
| Blind hole count | Four |
| Blind hole diameter | Diameter 3 mm |
| Blind hole depth | 8 mm from standoff top |
| Corner fillets | 2 mm radius on four outside vertical corners |
| Negative check | Blind holes must not break through floor; standoffs must not float |
@@ -0,0 +1,50 @@
# 6. Aerospace-Style Clevis Bracket With Lightening Cutouts
![Clevis bracket orbit gif](benchmark_06_clevis_bracket_lightening_cutouts.gif)
## Prompt
Create a single solid aerospace-style clevis bracket as a STEP model in millimeters.
The part is symmetric about the XZ plane.
Start with a base plate 120 mm long in X, 60 mm wide in Y, and 10 mm thick in Z, centered on the XY origin, with bottom face at Z = 0.
Add two vertical clevis lugs rising from the top of the base near the center. Each lug is 18 mm thick in Y, 42 mm tall above the base, and extends 36 mm along X. The two lugs are separated by a 16 mm central gap in Y. The top of each lug has a semicircular rounded profile with radius 18 mm when viewed from the side.
Add a horizontal through-hole of diameter 14 mm through both lugs along the Y direction, centered at X = 0 and Z = 34 mm.
Add four base mounting holes, diameter 7 mm, through the base plate, located at X = +/-45 mm and Y = +/-20 mm.
Add two triangular lightening cutouts through the base web, one on each side of the clevis, each with rounded corners of radius 3 mm.
Add two diagonal reinforcing ribs from the base to the outer faces of the lugs, one on each side, thickness 6 mm.
Add 3 mm fillets to the base perimeter and 2 mm fillets at lug-to-base transitions.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | One watertight fused solid |
| Symmetry | Symmetric about XZ plane |
| Base dimensions | 120 x 60 x 10 mm |
| Lug count | Two |
| Lug thickness | 18 mm in Y each |
| Lug height | 42 mm above base |
| Lug X extent | 36 mm |
| Lug gap | 16 mm central gap in Y |
| Lug top profile | Semicircular/rounded top, radius approximately 18 mm |
| Clevis hole | Diameter 14 mm horizontal through-hole |
| Clevis hole axis | Along Y |
| Clevis hole location | Centered at X = 0, Z = 34 mm |
| Base mounting holes | Four 7 mm diameter vertical through-holes |
| Base hole locations | X = +/-45 mm, Y = +/-20 mm |
| Lightening cutouts | Two triangular cutouts through base/web region |
| Cutout corner radius | Approximately 3 mm rounded corners |
| Reinforcing ribs | Two diagonal ribs, 6 mm thick |
| Fillets | 3 mm base perimeter; 2 mm lug-to-base |
| Negative check | Clevis gap must be open; lugs/ribs must not be detached |
@@ -0,0 +1,51 @@
# 7. Radial-Engine-Style Cylinder With Cooling Fins
![Radial-engine-style cylinder orbit gif](benchmark_07_radial_engine_cylinder.gif)
## Prompt
Create a single solid radial-engine-style cylinder as a STEP model in millimeters.
The main cylinder axis is vertical along Z and centered at the origin.
Create a central barrel with diameter 36 mm and height 70 mm, bottom at Z = 0.
Around the barrel, add 12 horizontal circular cooling fins. Each fin is 2 mm thick in Z, has outside diameter 62 mm, and is spaced every 5 mm from Z = 10 mm to Z = 65 mm.
Add a thicker base flange at the bottom, outside diameter 70 mm and thickness 8 mm, with six vertical mounting holes of diameter 5 mm on a 56 mm bolt circle.
Add a top cap cylinder, diameter 44 mm and height 8 mm, from Z = 70 mm to Z = 78 mm.
Add an angled spark-plug boss protruding from the side of the top cap. The boss is a cylinder of diameter 12 mm and length 24 mm, angled upward at 35 degrees from horizontal, with its axis pointing outward in the positive X direction.
Add a 5 mm diameter hole through the boss along its own axis.
Add small 1 mm fillets to the outer fin edges and base flange edges.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | One fused watertight solid |
| Main axis | Vertical along Z |
| Barrel | Diameter 36 mm, 70 mm tall |
| Barrel placement | Centered at origin; bottom at Z = 0 |
| Cooling fin count | 12 |
| Fin diameter | Diameter 62 mm |
| Fin thickness | 2 mm each |
| Fin spacing | Nominally 5 mm from Z = 10 to Z = 65 |
| Base flange | Diameter 70 mm, 8 mm thick |
| Mounting hole count | Six |
| Mounting hole diameter | Diameter 5 mm |
| Mounting bolt circle | Diameter 56 mm |
| Mounting hole spacing | 60 degrees between holes |
| Top cap | Diameter 44 mm, 8 mm tall, Z = 70 to 78 mm |
| Spark-plug boss | Diameter 12 mm cylinder, 24 mm long |
| Boss orientation | Points outward in +X direction |
| Boss angle | 35 degrees upward from horizontal |
| Boss bore | Diameter 5 mm through-hole along boss axis |
| Fillets | 1 mm outer fin-edge and flange-edge fillets |
| Negative check | Boss bore must be coaxial with angled boss; fins must not be missing or merged into one thick cylinder |
@@ -0,0 +1,43 @@
# 8. Centrifugal Impeller With Backward-Curved Blades
![Centrifugal impeller orbit gif](benchmark_08_centrifugal_impeller.gif)
## Prompt
Create a single solid centrifugal impeller as a STEP model in millimeters.
The impeller axis is vertical along Z and centered at the origin.
Add a circular backplate disk with outside diameter 90 mm and thickness 6 mm, with its bottom face at Z = 0.
Add a central hub cylinder on top of the backplate, diameter 26 mm and height 22 mm above the backplate.
Add a vertical through-bore of diameter 8 mm through the entire part.
Add 12 identical backward-curved blades on top of the backplate, equally spaced around the hub. Each blade begins at radius 18 mm and ends at radius 43 mm. Each blade is 3 mm thick, 16 mm tall above the backplate, and curves backward by approximately 45 degrees from root to tip. The blade tips should lean opposite the direction of rotation when viewed from above.
Add 1 mm fillets at the blade roots where they meet the backplate and hub. Add a 1.5 mm fillet to the top and bottom outer circular edges of the backplate.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Solid count | One fused watertight solid |
| Backplate diameter | Diameter 90 mm |
| Backplate thickness | 6 mm |
| Hub diameter | Diameter 26 mm |
| Hub height | 22 mm above backplate |
| Through-bore | Diameter 8 mm through entire part |
| Coaxiality | Backplate, hub, and bore share Z-axis |
| Blade count | 12 |
| Blade spacing | 30 degrees between adjacent blades |
| Blade radial span | Approximately R18 to R43 |
| Blade height | 16 mm above backplate |
| Blade thickness | Approximately 3 mm |
| Blade curvature | Approximately 45 degrees backward from root to tip |
| Blade fusion | Blades joined to backplate and hub |
| Fillets | 1 mm blade-root fillets; 1.5 mm backplate edge fillets |
| Negative check | No straight radial-only blades unless curvature requirement is otherwise approximated; no floating blades |
@@ -0,0 +1,46 @@
# 9. Spiral Staircase With Helical Handrail
![Spiral staircase orbit gif](benchmark_09_spiral_staircase.gif)
## Prompt
Create a single STEP model of a miniature spiral staircase in millimeters.
The staircase is centered on the origin and rises along Z.
Add a central vertical column, diameter 14 mm and height 140 mm, with its bottom at Z = 0.
Add 20 identical wedge-shaped stair treads arranged helically around the column. Each tread is 4 mm thick, has an inner radius of 10 mm, an outer radius of 62 mm, and subtends 24 degrees in plan view. The first tread is at Z = 4 mm, and each subsequent tread rises by 6 mm and rotates by 18 degrees around Z.
Add a helical outer handrail tube of diameter 5 mm following radius 66 mm, starting at Z = 14 mm and ending at Z = 130 mm, making one full revolution around the staircase.
Add 20 vertical balusters, each diameter 3 mm, connecting the outer end of each tread to the handrail.
Add a circular base disk, diameter 90 mm and thickness 5 mm.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Main structure | Valid solid bodies or valid fused assembly |
| Central column | Diameter 14 mm, 140 mm tall |
| Column placement | Centered on Z-axis; bottom at Z = 0 |
| Base disk | Diameter 90 mm, 5 mm thick |
| Tread count | 20 |
| Tread thickness | 4 mm |
| Tread inner radius | R10 mm |
| Tread outer radius | R62 mm |
| Tread angular width | 24 degrees |
| Tread rise | 6 mm per step |
| Tread rotation | 18 degrees per step |
| Helical progression | Treads rise and rotate together, not merely radial-patterned flat |
| Handrail | Diameter 5 mm tube at R66 mm |
| Handrail vertical range | Z = 14 mm to Z = 130 mm |
| Handrail revolution | Approximately one full 360-degree turn |
| Baluster count | 20 |
| Baluster diameter | Diameter 3 mm |
| Baluster relation | Each connects tread outer region to handrail |
| Negative check | No missing rail; no flat circular stair pattern with no rise |
@@ -0,0 +1,48 @@
# 10. Simplified Planetary Gear Stage
![Planetary gear stage orbit gif](benchmark_10_planetary_gear_stage.gif)
## Prompt
Create a visually clear simplified planetary gear assembly as a STEP model in millimeters.
The assembly lies flat in the XY plane with gear axes along Z. Use separate solid bodies for the sun gear, three planet gears, ring gear, carrier plate, and three planet pins.
All gears are 8 mm thick. Use simplified straight-sided trapezoidal teeth rather than true involute teeth.
The sun gear has 24 external teeth, pitch diameter 48 mm, root diameter 42 mm, and outside diameter 54 mm.
The three planet gears each have 18 external teeth, pitch diameter 36 mm, root diameter 31 mm, and outside diameter 41 mm. Place the planet gear centers on a 42 mm radius circle, equally spaced every 120 degrees.
The ring gear is concentric with the sun gear, has 60 internal teeth, internal pitch diameter 120 mm, internal root diameter 126 mm, internal tooth-tip diameter 114 mm, and outside diameter 140 mm.
Add a thin circular carrier plate below the gears, diameter 105 mm and thickness 4 mm, located from Z = -5 mm to Z = -1 mm.
Add three vertical planet pins, each diameter 6 mm and height 14 mm, centered under the planet gears.
Add a central sun bore of diameter 10 mm.
Export as a STEP file.
## Test Cases
| Test | Expected result |
| --- | --- |
| STEP import | File imports successfully |
| Body count | Separate bodies for sun, 3 planets, ring, carrier, and 3 pins |
| Gear axes | All gear axes parallel to Z |
| Gear thickness | 8 mm for sun, planet, and ring gears |
| Sun tooth count | 24 external teeth |
| Sun diameters | Pitch approximately 48 mm diameter; root 42 mm diameter; outside 54 mm diameter |
| Sun bore | 10 mm diameter central through-bore |
| Planet count | Three planet gears |
| Planet tooth count | 18 teeth each |
| Planet diameters | Pitch approximately 36 mm diameter; root 31 mm diameter; outside 41 mm diameter |
| Planet placement | Centers on R42 mm circle |
| Planet spacing | 120 degrees apart |
| Ring gear | 140 mm outside diameter |
| Ring internal teeth | 60 internal teeth |
| Ring diameters | Internal pitch approximately 120 mm diameter; internal root 126 mm diameter; tooth-tip 114 mm diameter |
| Carrier plate | Diameter 105 mm, 4 mm thick, Z = -5 to -1 mm |
| Pins | Three 6 mm diameter pins aligned with planet centers |
| Negative check | Ring teeth must face inward; planet gears should not be fused into sun or ring |
+14
View File
@@ -0,0 +1,14 @@
# Next.js
.next/
out/
# Dependencies
node_modules/
# Build and cache output
dist/
coverage/
*.tsbuildinfo
# Local env
.env*.local
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
".agents/**",
"out/**",
"build/**",
"node_modules/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+35
View File
@@ -0,0 +1,35 @@
import type { NextConfig } from "next";
import path from "node:path";
const repoRoot = path.resolve(process.cwd(), "..");
const cadJsPackageRoot = path.join(repoRoot, "packages/cadjs/src");
const docsThreeRoot = "./node_modules/three";
const docsThreeExamplesRoot = "./node_modules/three/examples";
const docsGltfLoader =
"./node_modules/three/examples/jsm/loaders/GLTFLoader.js";
const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1"],
experimental: {
externalDir: true,
},
images: {
remotePatterns: [
{
hostname: "www.skills.sh",
protocol: "https",
},
],
},
turbopack: {
root: repoRoot,
resolveAlias: {
"cadjs": cadJsPackageRoot,
three: docsThreeRoot,
"three/examples": docsThreeExamplesRoot,
"three/examples/jsm/loaders/GLTFLoader.js": docsGltfLoader,
},
},
};
export default nextConfig;
+11577
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "cad-skills-docs",
"version": "0.3.9",
"license": "MIT",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"check": "node scripts/check-hero-step-assets.mjs && npm run lint && npm run build"
},
"dependencies": {
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"three": "^0.160.0",
"tw-animate-css": "^1.4.0"
},
"overrides": {
"postcss": "^8.5.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.160.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

@@ -0,0 +1,204 @@
const Z_AXIS = [0, 0, 1];
const ORIGIN = [0, 0, 0];
const PLANETS = ["planet1", "planet2", "planet3"];
const PINS = ["pin1", "pin2", "pin3"];
const SUN_TEETH = 24;
const PLANET_TEETH = 18;
const RING_TEETH = 60;
// Fixed-ring planetary kinematics: carrier follows the sun by Ns / (Ns + Nr).
const CARRIER_RATIO_FIXED_RING = SUN_TEETH / (SUN_TEETH + RING_TEETH);
// 3.5 sun revolutions returns the carrier to 360 deg and each gear to an equivalent tooth phase.
const FULL_MESH_CYCLE_DEG = 1260;
let orbitGuide = null;
function finite(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function radialUnit(feature) {
const center = Array.isArray(feature?.center) ? feature.center : ORIGIN;
const length = Math.hypot(center[0] || 0, center[1] || 0);
if (length <= 1e-6) {
return [1, 0, 0];
}
return [(center[0] || 0) / length, (center[1] || 0) / length, 0];
}
function scaled(vector, scale) {
return vector.map((value) => value * scale);
}
function createOrbitGuide(THREE) {
const radius = 42;
const points = [];
for (let index = 0; index < 144; index += 1) {
const angle = (index / 144) * Math.PI * 2;
points.push(new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius, 4));
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color: 0x94a3b8,
transparent: true,
opacity: 0.34,
depthWrite: false
});
const line = new THREE.LineLoop(geometry, material);
line.name = "planetary-gear-orbit-guide";
line.renderOrder = 30;
return line;
}
const planetaryGearAssemblyStep = {
manifest: {
schemaVersion: 1,
units: {
length: "mm",
angle: "deg",
time: "s"
},
features: {
carrier: { ref: "#o1.1.1", label: "Carrier plate", description: "The plate that carries planet pins around the sun axis." },
ring: { ref: "#o1.2.1", label: "Ring gear", description: "Stationary internal gear, visually faded or hidden by controls." },
sun: { ref: "#o1.3.1", label: "Sun gear", description: "Central driven gear." },
planet1: { ref: "#o1.4.1", label: "Planet gear 1" },
pin1: { ref: "#o1.5.1", label: "Planet pin 1" },
planet2: { ref: "#o1.6.1", label: "Planet gear 2" },
pin2: { ref: "#o1.7.1", label: "Planet pin 2" },
planet3: { ref: "#o1.8.1", label: "Planet gear 3" },
pin3: { ref: "#o1.9.1", label: "Planet pin 3" }
},
parameters: {
drive: { type: "number", label: "Drive", description: "Sun gear input angle across one closed mesh cycle.", default: 0, min: 0, max: FULL_MESH_CYCLE_DEG, step: 1, unit: "deg" },
explode: { type: "number", label: "Explode", description: "Radially separates meshing features for inspection.", default: 0, min: 0, max: 1, step: 0.01 },
ringVisible: { type: "boolean", label: "Ring gear", default: true },
orbitGuides: { type: "boolean", label: "Orbit guide", default: false },
highlightMeshing: { type: "boolean", label: "Mesh highlight", default: false },
viewMode: {
type: "select",
label: "View",
default: "mesh",
options: [
{ value: "mesh", label: "Mesh study" },
{ value: "cutaway", label: "Cutaway" },
{ value: "carrier", label: "Carrier focus" }
]
}
},
animations: {
meshCycle: {
label: "Mesh cycle",
duration: 6,
loop: true,
update({ cycle, set }) {
set("drive", (cycle % 1) * FULL_MESH_CYCLE_DEG);
}
},
inspectExplode: {
label: "Explode inspect",
duration: 5,
loop: true,
update({ progress, set }) {
set("drive", progress * FULL_MESH_CYCLE_DEG);
set("explode", Math.sin(progress * Math.PI));
}
}
}
},
setup({ THREE, modelGroup, cleanup }) {
if (!THREE || !modelGroup) {
return;
}
orbitGuide = createOrbitGuide(THREE);
modelGroup.add(orbitGuide);
cleanup(() => {
modelGroup.remove(orbitGuide);
orbitGuide.geometry?.dispose?.();
orbitGuide.material?.dispose?.();
orbitGuide = null;
});
},
update({ params, features, effects, time }) {
const drive = finite(params.drive);
const explode = finite(params.explode);
const sunAngle = drive;
const carrierAngle = sunAngle * CARRIER_RATIO_FIXED_RING;
const planetSpinRelativeToCarrier = -(SUN_TEETH / PLANET_TEETH) * (sunAngle - carrierAngle);
const lift = explode * 7;
const cutaway = params.viewMode === "cutaway";
const carrierFocus = params.viewMode === "carrier";
const highlight = params.highlightMeshing === true &&
(!time.playing || (time.progress > 0.45 && time.progress < 0.6));
if (orbitGuide) {
orbitGuide.visible = params.orbitGuides !== false;
orbitGuide.rotation.z = (carrierAngle * Math.PI) / 180;
}
effects.visible("ring", params.ringVisible !== false);
effects.style("ring", {
opacity: cutaway ? 0.22 : 1,
edgeOpacity: cutaway ? 0.38 : 1
});
effects.style("sun", {
emissive: highlight ? "#7c2d12" : "",
emissiveIntensity: highlight ? 0.32 : 0
});
effects.style(PLANETS, {
emissive: highlight ? "#075985" : "",
emissiveIntensity: highlight ? 0.22 : 0
});
effects.style(PINS, {
opacity: 1
});
effects.style("carrier", {
opacity: 1,
emissive: carrierFocus ? "#14532d" : "",
emissiveIntensity: carrierFocus ? 0.2 : 0
});
if (highlight) {
effects.highlight(["sun", ...PLANETS], true);
}
effects.transform("sun", {
transforms: [
{ rotate: { axis: Z_AXIS, origin: features.sun.center, angleDeg: sunAngle } },
{ translate: [0, 0, lift] }
]
});
effects.transform("carrier", {
transforms: [
{ rotate: { axis: Z_AXIS, origin: ORIGIN, angleDeg: carrierAngle } },
{ translate: [0, 0, -explode * 4] }
]
});
for (const planetId of PLANETS) {
const radial = radialUnit(features[planetId]);
effects.transform(planetId, {
transforms: [
{ rotate: { axis: Z_AXIS, origin: features[planetId].center, angleDeg: planetSpinRelativeToCarrier } },
{ translate: scaled(radial, explode * 16) },
{ rotate: { axis: Z_AXIS, origin: ORIGIN, angleDeg: carrierAngle } }
]
});
}
for (const pinId of PINS) {
const radial = radialUnit(features[pinId]);
effects.transform(pinId, {
transforms: [
{ translate: scaled(radial, explode * 16) },
{ rotate: { axis: Z_AXIS, origin: ORIGIN, angleDeg: carrierAngle } },
{ translate: [0, 0, -explode * 4] }
]
});
}
}
};
export default planetaryGearAssemblyStep;
Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

@@ -0,0 +1,105 @@
{
"id": "workbench-transparent",
"settings": {
"materials": {
"defaultColor": "#d7dce0",
"fillColors": ["#d7dce0", "#cdd3d8", "#e4e7ea", "#bfc7ce"],
"overrideSourceColors": true,
"tintMode": "blend",
"tintStrength": 0,
"saturation": 0,
"contrast": 1,
"brightness": 1,
"roughness": 0.92,
"metalness": 0,
"clearcoat": 0,
"clearcoatRoughness": 0.9,
"opacity": 1,
"envMapIntensity": 0,
"emissiveIntensity": 0
},
"edges": {
"enabled": true,
"contrastMode": "manual",
"source": "topology",
"topologyFilter": "feature",
"topologyMinRelevance": 1,
"color": "#111827",
"opacity": 0.96,
"thickness": 1,
"silhouette": true,
"silhouetteScale": 0.0025
},
"background": {
"type": "transparent",
"solidColor": "#f7f7f7",
"linearStart": "#f7f7f7",
"linearEnd": "#eeeeee",
"linearAngle": 0,
"radialInner": "#ffffff",
"radialOuter": "#eeeeee"
},
"floor": {
"mode": "none",
"color": "#f7f7f7",
"roughness": 1,
"reflectivity": 0,
"shadowOpacity": 0,
"horizonBlend": 0
},
"environment": {
"enabled": false,
"presetId": "studio-hdri-43",
"intensity": 0,
"rotationY": 0,
"useAsBackground": false
},
"lighting": {
"toneMappingExposure": 1,
"directional": {
"enabled": true,
"color": "#ffffff",
"intensity": 0.75,
"position": {
"x": -160,
"y": 220,
"z": 260
}
},
"spot": {
"enabled": false,
"color": "#ffffff",
"intensity": 0,
"angle": 0.74,
"distance": 0,
"position": {
"x": 190,
"y": 210,
"z": 170
}
},
"point": {
"enabled": false,
"color": "#ffffff",
"intensity": 0,
"distance": 0,
"position": {
"x": -240,
"y": 110,
"z": -210
}
},
"ambient": {
"enabled": true,
"color": "#ffffff",
"intensity": 0.82
},
"hemisphere": {
"enabled": true,
"skyColor": "#ffffff",
"groundColor": "#e5e7eb",
"intensity": 0.9
}
}
}
}
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { STEP_TOPOLOGY_SCHEMA_VERSION } from "../../packages/cadjs/src/common/stepTopology.mjs";
const docsRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const heroGlbPath = path.join(docsRoot, "public/hero/planetary_gear_assembly.step.glb");
function readGlbJson(filePath) {
const buffer = fs.readFileSync(filePath);
assert.equal(buffer.toString("ascii", 0, 4), "glTF", `${filePath} is not a GLB`);
const jsonLength = buffer.readUInt32LE(12);
const jsonChunkType = buffer.toString("ascii", 16, 20);
assert.equal(jsonChunkType, "JSON", `${filePath} is missing its JSON chunk`);
return JSON.parse(buffer.subarray(20, 20 + jsonLength).toString("utf8"));
}
const gltf = readGlbJson(heroGlbPath);
const topology = gltf.extensions?.STEP_topology;
assert.ok(topology, "Hero STEP GLB is missing the STEP_topology extension");
assert.equal(
topology.schemaVersion,
STEP_TOPOLOGY_SCHEMA_VERSION,
`Hero STEP GLB uses STEP_topology schemaVersion ${topology.schemaVersion}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`
);
console.log("Hero STEP asset schema is current.");
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
export function GET(request: Request) {
return NextResponse.redirect(
new URL("/hero/planetary_gear_assembly.step.js", request.url),
307
);
}
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
export function GET(request: Request) {
return NextResponse.redirect(
new URL("/hero/planetary_gear_assembly.step.glb", request.url),
307
);
}
+356
View File
@@ -0,0 +1,356 @@
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
color-scheme: light;
--background: hsl(218 27% 94%);
--foreground: hsl(220 16% 22%);
--card: hsl(218 27% 89%);
--card-foreground: hsl(220 16% 22%);
--popover: hsl(218 27% 89%);
--popover-foreground: hsl(220 16% 22%);
--primary: hsl(213 32% 40%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(219 28% 84%);
--secondary-foreground: hsl(220 16% 22%);
--muted: hsl(219 28% 84%);
--muted-foreground: hsl(220 17% 28%);
--accent: hsl(213 20% 86%);
--accent-foreground: hsl(213 32% 40%);
--destructive: hsl(355 60% 45%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(219 18% 72%);
--input: hsl(219 18% 72%);
--ring: hsl(213 32% 40%);
--chart-1: hsl(213 32% 44%);
--chart-2: hsl(210 34% 42%);
--chart-3: hsl(92 30% 36%);
--chart-4: hsl(40 70% 35%);
--chart-5: hsl(311 28% 42%);
--sidebar-background: hsl(218 27% 89%);
--sidebar: var(--sidebar-background);
--sidebar-foreground: hsl(220 16% 22%);
--sidebar-primary: hsl(213 32% 40%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(213 20% 86%);
--sidebar-accent-foreground: hsl(213 32% 40%);
--sidebar-border: hsl(219 18% 72%);
--sidebar-ring: hsl(213 32% 40%);
--smui-frost-1: 176 30% 35%;
--smui-frost-2: 193 40% 38%;
--smui-frost-3: 210 34% 40%;
--smui-frost-4: 213 32% 36%;
--smui-red: 355 55% 44%;
--smui-orange: 14 55% 44%;
--smui-yellow: 40 70% 34%;
--smui-green: 92 35% 34%;
--smui-purple: 311 28% 40%;
--smui-surface-0: 218 27% 94%;
--smui-surface-1: 218 27% 89%;
--smui-surface-2: 219 28% 84%;
--smui-surface-3: 219 20% 76%;
--smui-border-hover: 220 17% 50%;
--font-sans: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-serif: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-heading: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--radius: 0rem;
--shadow-x: 0;
--shadow-y: 0;
--shadow-blur: 0;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: none;
--shadow-xs: none;
--shadow-sm: none;
--shadow: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
--shadow-2xl: none;
--tracking-normal: 0em;
--spacing: 0.25rem;
--text-label: 11px;
--text-ui: 13px;
--text-heading: 22px;
--text-stat: 26px;
--text-hero: 42px;
}
.dark {
color-scheme: dark;
--background: hsl(213 16% 12%);
--foreground: hsl(213 27% 88%);
--card: hsl(217 16% 15.5%);
--card-foreground: hsl(213 27% 88%);
--popover: hsl(217 16% 15.5%);
--popover-foreground: hsl(213 27% 88%);
--primary: hsl(193 44% 67%);
--primary-foreground: hsl(213 16% 12%);
--secondary: hsl(216 15% 19%);
--secondary-foreground: hsl(213 27% 88%);
--muted: hsl(216 15% 19%);
--muted-foreground: hsl(213 14% 65%);
--accent: hsl(193 10% 16%);
--accent-foreground: hsl(193 44% 67%);
--destructive: hsl(355 52% 64%);
--destructive-foreground: hsl(219 28% 94%);
--border: hsl(217 17% 28%);
--input: hsl(217 17% 28%);
--ring: hsl(193 44% 67%);
--chart-1: hsl(193 44% 67%);
--chart-2: hsl(213 32% 52%);
--chart-3: hsl(92 28% 65%);
--chart-4: hsl(40 71% 73%);
--chart-5: hsl(311 24% 63%);
--sidebar-background: hsl(217 16% 15.5%);
--sidebar: var(--sidebar-background);
--sidebar-foreground: hsl(213 27% 88%);
--sidebar-primary: hsl(193 44% 67%);
--sidebar-primary-foreground: hsl(213 16% 12%);
--sidebar-accent: hsl(193 10% 16%);
--sidebar-accent-foreground: hsl(193 44% 67%);
--sidebar-border: hsl(217 17% 28%);
--sidebar-ring: hsl(193 44% 67%);
--smui-frost-1: 176 25% 65%;
--smui-frost-2: 193 44% 67%;
--smui-frost-3: 210 34% 63%;
--smui-frost-4: 213 32% 52%;
--smui-red: 355 52% 64%;
--smui-orange: 14 51% 63%;
--smui-yellow: 40 71% 73%;
--smui-green: 92 28% 65%;
--smui-purple: 311 24% 63%;
--smui-surface-0: 213 16% 12%;
--smui-surface-1: 217 16% 15.5%;
--smui-surface-2: 216 15% 19%;
--smui-surface-3: 215 14% 22%;
--smui-border-hover: 216 12% 37%;
--font-sans: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-serif: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-heading: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--radius: 0rem;
--shadow-x: 0;
--shadow-y: 0;
--shadow-blur: 0;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: none;
--shadow-xs: none;
--shadow-sm: none;
--shadow: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
--shadow-2xl: none;
--text-label: 11px;
--text-ui: 13px;
--text-heading: 22px;
--text-stat: 26px;
--text-hero: 42px;
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar-background: var(--sidebar-background);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-serif: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-heading: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--radius-sm: var(--radius);
--radius-md: var(--radius);
--radius-lg: var(--radius);
--radius-xl: var(--radius);
--shadow-2xs: none;
--shadow-xs: none;
--shadow-sm: none;
--shadow: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
--shadow-2xl: none;
--text-label: 11px;
--text-ui: 13px;
--text-heading: 22px;
--text-stat: 26px;
--text-hero: 42px;
}
@theme inline {
--color-smui-frost-1: hsl(var(--smui-frost-1));
--color-smui-frost-2: hsl(var(--smui-frost-2));
--color-smui-frost-3: hsl(var(--smui-frost-3));
--color-smui-frost-4: hsl(var(--smui-frost-4));
--color-smui-red: hsl(var(--smui-red));
--color-smui-orange: hsl(var(--smui-orange));
--color-smui-yellow: hsl(var(--smui-yellow));
--color-smui-green: hsl(var(--smui-green));
--color-smui-purple: hsl(var(--smui-purple));
--color-smui-surface-0: hsl(var(--smui-surface-0));
--color-smui-surface-1: hsl(var(--smui-surface-1));
--color-smui-surface-2: hsl(var(--smui-surface-2));
--color-smui-surface-3: hsl(var(--smui-surface-3));
--color-smui-border-hover: hsl(var(--smui-border-hover));
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply min-h-full scroll-smooth font-sans;
}
body {
@apply min-h-screen bg-background text-foreground antialiased;
letter-spacing: var(--tracking-normal);
}
::selection {
background: hsl(var(--smui-frost-2) / 0.2);
color: hsl(var(--smui-frost-2));
}
}
@layer components {
@keyframes ascii-terminal-reveal {
0% {
clip-path: inset(0 100% 0 0);
opacity: 1;
}
82% {
clip-path: inset(0 0 0 0);
opacity: 1;
}
100% {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
@keyframes ascii-terminal-sheen {
0% {
transform: translateX(-115%);
}
72% {
transform: translateX(115%);
}
100% {
transform: translateX(115%);
}
}
@keyframes agent-carousel {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-50% - 0.25rem));
}
}
.ascii-terminal-reveal {
animation: ascii-terminal-reveal 1.05s steps(16, end) both;
overflow: hidden;
}
.ascii-terminal-reveal::after {
animation: ascii-terminal-sheen 1.05s ease-out both;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--primary) 8%, transparent) 34%,
var(--primary) 50%,
color-mix(in srgb, var(--foreground) 45%, transparent) 62%,
transparent 100%
);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
}
.agent-carousel-track {
animation: agent-carousel 72s linear infinite;
}
.agent-carousel:hover .agent-carousel-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.ascii-terminal-reveal,
.ascii-terminal-reveal::after,
.agent-carousel-track {
animation: none;
}
.agent-carousel {
overflow-x: auto;
}
}
.card-glow {
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.card-glow:hover {
border-color: hsl(var(--smui-border-hover));
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Analytics } from "@vercel/analytics/next";
import { TooltipProvider } from "@/components/ui/tooltip";
import { siteConfig } from "@/lib/site";
import "./globals.css";
const socialPreview = {
url: "/social-preview-gear.png",
width: 1200,
height: 630,
alt: "CAD Skills homepage showing a planetary gear CAD model",
};
const themeScript = `
(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const storageKey = "cad-skills-theme-v2";
const applyTheme = (theme) => {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
};
const getStoredTheme = () => {
try {
const storedTheme = window.localStorage.getItem(storageKey);
return storedTheme === "dark" || storedTheme === "light"
? storedTheme
: null;
} catch {
return null;
}
};
applyTheme(getStoredTheme() ?? "dark");
const handleSystemThemeChange = () => {
if (!getStoredTheme()) {
applyTheme("dark");
}
};
try {
media.addEventListener("change", handleSystemThemeChange);
} catch {
media.addListener(handleSystemThemeChange);
}
})();
`;
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.origin),
applicationName: siteConfig.name,
title: {
default: siteConfig.title,
template: `%s | ${siteConfig.name}`,
},
description: siteConfig.description,
keywords: siteConfig.keywords,
alternates: {
canonical: "/",
},
openGraph: {
title: siteConfig.title,
description: siteConfig.description,
url: "/",
siteName: siteConfig.name,
images: [socialPreview],
type: "website",
},
twitter: {
card: "summary_large_image",
title: siteConfig.title,
description: siteConfig.description,
images: [socialPreview],
},
icons: {
icon: [
{ url: "/favicon.ico?v=planetary-gear-workbench", type: "image/x-icon" },
],
shortcut: [
{ url: "/favicon.ico?v=planetary-gear-workbench", type: "image/x-icon" },
],
apple: [
{ url: "/favicon.png?v=planetary-gear-workbench", type: "image/png" },
],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
category: "engineering",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Script
id="theme-script"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: themeScript }}
/>
<TooltipProvider>{children}</TooltipProvider>
<Analytics />
</body>
</html>
);
}
+358
View File
@@ -0,0 +1,358 @@
import Image from "next/image";
import { ExternalLink } from "lucide-react";
import { CopyButton } from "@/components/copy-button";
import { HeroSection } from "@/components/hero-section";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
const skillsInstallCommand = "npx skills install earthtojake/text-to-cad";
const pluginInstallCommands = [
{
agent: "Codex",
command:
"codex plugin marketplace add earthtojake/text-to-cad\ncodex plugin add cad@text-to-cad",
},
{
agent: "Claude Code",
command:
"claude plugin marketplace add earthtojake/text-to-cad\nclaude plugin install cad@text-to-cad",
},
];
const supportedAgents = [
{ name: "Claude Code", slug: "claude-code", icon: "claude-code.svg" },
{ name: "Codex", slug: "codex", icon: "codex.svg" },
];
const skillGroups = [
{
name: "CAD Router",
path: "skills/cad-router",
summary:
"Combines text-to-cad, SimpleCADAPI, CADAM, and the CADDesigner workflow with shared validation and a task-scoped Viewer handoff.",
},
{
name: "CAD",
path: "skills/cad",
summary:
"Creates and edits CAD models from plain-language or image requests, with STEP as the main output along with options to export to STL, 3MF and GLB.",
},
{
name: "CAD Viewer",
path: "skills/cad-viewer",
summary: "Shows local browser previews for CAD, G-code, and robot files.",
},
{
name: "step.parts",
path: "skills/step-parts",
summary:
"Finds off-the-shelf STEP parts like screws, bearings, motors, and connectors.",
},
{
name: "DXF",
path: "skills/dxf",
summary:
"Creates 2D DXF drawings like profiles, templates, gaskets, and cut layouts from Python sources or CAD geometry.",
},
{
name: "URDF",
path: "skills/urdf",
summary:
"Writes robot structure files with links, joints, limits, inertials, and meshes.",
},
{
name: "SRDF",
path: "skills/srdf",
summary:
"Adds MoveIt planning groups, end effectors, poses, and collision rules to a URDF.",
},
{
name: "SDF",
path: "skills/sdf",
summary:
"Creates simulator models and worlds with frames, physics, sensors, and lights.",
},
{
name: "SendCutSend",
path: "skills/sendcutsend",
summary: "Checks DXF and STEP files before upload to SendCutSend.",
},
{
name: "G-code",
path: "skills/gcode",
summary:
"Slices supported mesh files into validated, printer-profiled FDM .gcode with real slicer CLIs.",
},
{
name: "Bambu Labs",
path: "skills/bambu-labs",
summary:
"Dry-runs, uploads, and cautiously starts local Bambu Lab print jobs from validated .gcode.",
},
{
name: "Implicit CAD",
path: "skills/implicit-cad",
summary:
"Creates browser-native implicit CAD models using GLSL signed-distance fields and CAD Viewer raymarch rendering. Experimental.",
},
];
function InstallCommand({
item,
}: {
item: (typeof pluginInstallCommands)[number];
}) {
return (
<div className="min-w-0 max-w-full overflow-hidden border border-border bg-card">
<div className="border-b border-border px-3 py-2 text-label uppercase tracking-[1.5px] text-muted-foreground">
{item.agent}
</div>
<div className="flex min-h-[54px] min-w-0 max-w-full items-stretch">
<code className="flex min-w-0 flex-1 items-center overflow-x-auto whitespace-pre px-3 py-2 text-sm leading-6 text-foreground">
{item.command}
</code>
<CopyButton
text={item.command}
label={`Copy ${item.agent} install command`}
compact
/>
</div>
</div>
);
}
function InstallCommands() {
return (
<div className="grid min-w-0 gap-2">
{pluginInstallCommands.map((item) => (
<InstallCommand key={item.agent} item={item} />
))}
</div>
);
}
function SkillsInstallCommand() {
return (
<div className="min-w-0 max-w-full overflow-hidden border border-border bg-card">
<div className="flex min-h-[54px] min-w-0 max-w-full items-stretch">
<code className="flex min-w-0 flex-1 items-center overflow-x-auto whitespace-pre px-3 py-2 text-sm leading-6 text-foreground">
{skillsInstallCommand}
</code>
<CopyButton
text={skillsInstallCommand}
label="Copy Skills CLI install command"
compact
/>
</div>
</div>
);
}
function AgentTile({
agent,
hidden = false,
}: {
agent: (typeof supportedAgents)[number];
hidden?: boolean;
}) {
return (
<a
aria-hidden={hidden ? "true" : undefined}
aria-label={`Skills for ${agent.name}`}
className="group flex h-[54px] w-[150px] shrink-0 items-center gap-2.5 border border-border bg-card px-2.5 text-muted-foreground transition hover:bg-secondary hover:text-foreground sm:w-[168px]"
href={`https://www.skills.sh/agent/${agent.slug}`}
rel="noreferrer"
tabIndex={hidden ? -1 : undefined}
target="_blank"
>
<span className="flex size-7 shrink-0 items-center justify-center">
<Image
alt=""
src={`https://www.skills.sh/agents/${agent.icon}`}
width={44}
height={44}
unoptimized
className="size-5 object-contain opacity-70 grayscale transition group-hover:opacity-100 group-hover:grayscale-0 dark:invert"
/>
</span>
<span className="min-w-0 truncate text-label uppercase tracking-[1.3px]">
{agent.name}
</span>
</a>
);
}
function AgentCarousel() {
return (
<div className="agent-carousel relative min-h-[54px] min-w-0 overflow-hidden">
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-12 bg-gradient-to-r from-background to-transparent sm:w-20" />
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-12 bg-gradient-to-l from-background to-transparent sm:w-20" />
<div className="agent-carousel-track flex w-max gap-2">
{supportedAgents.map((agent) => (
<AgentTile key={agent.slug} agent={agent} />
))}
{supportedAgents.map((agent) => (
<AgentTile key={`${agent.slug}-duplicate`} agent={agent} hidden />
))}
</div>
</div>
);
}
function SectionIntro({
id,
title,
description,
}: {
id?: string;
title: string;
description: string;
}) {
return (
<div>
<h2
id={id}
className="text-heading font-medium tracking-normal text-foreground"
>
{title}
</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
{description}
</p>
</div>
);
}
function SkillLink({ skill }: { skill: (typeof skillGroups)[number] }) {
return (
<a
className="inline-flex min-w-0 items-center gap-1.5 text-label uppercase tracking-[1.5px] text-primary transition hover:text-primary/80"
href={`https://github.com/earthtojake/text-to-cad/blob/main/${skill.path}/SKILL.md`}
target="_blank"
rel="noreferrer"
>
<span className="truncate">{skill.path}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
);
}
export default function Home() {
return (
<main className="min-h-screen bg-background text-foreground">
<SiteHeader />
<div className="mx-auto w-full max-w-[1200px] px-4 py-4 sm:px-6">
<div className="min-w-0 space-y-2">
<HeroSection />
<section
aria-label="Install CAD Skills with supported agents"
className="grid gap-5 py-6 lg:grid-cols-2 lg:items-center lg:gap-12"
>
<div className="min-w-0 space-y-3">
<h2 className="text-sm font-medium uppercase tracking-[1.5px] text-foreground">
Try It Now
</h2>
<SkillsInstallCommand />
</div>
<div className="min-w-0 space-y-3">
<h2 className="text-sm font-medium uppercase tracking-[1.5px] text-foreground">
Supported Agents
</h2>
<AgentCarousel />
</div>
</section>
<section
id="skills"
aria-labelledby="skills-title"
className="scroll-mt-20 space-y-3 py-6"
>
<SectionIntro
id="skills-title"
title="SKILLS"
description="Install the library to give agents focused workflows for CAD, fabrication, robot description files, simulation, and local review."
/>
<div className="border border-border bg-card">
<div className="grid grid-cols-[minmax(0,1fr)] border-b border-border px-3.5 py-2.5 text-xs uppercase tracking-[1.5px] text-muted-foreground md:grid-cols-[minmax(9rem,12rem)_minmax(0,1fr)_max-content] md:gap-5 md:pl-0 md:pr-3.5">
<span className="md:pl-3.5">skill</span>
<span className="hidden md:block">summary</span>
<span className="hidden text-right md:block">source</span>
</div>
<ul className="divide-y divide-border">
{skillGroups.map((skill) => (
<li
key={skill.name}
className="card-glow grid gap-3 px-3.5 py-3 hover:bg-secondary/60 md:grid-cols-[minmax(9rem,12rem)_minmax(0,1fr)_max-content] md:items-center md:gap-5 md:pl-0 md:pr-3.5"
>
<div className="flex min-w-0 items-center md:pl-3.5">
<div className="min-w-0">
<h3 className="text-sm font-medium text-foreground">
{skill.name}
</h3>
<p className="mt-0.5 text-label uppercase tracking-wider text-muted-foreground md:hidden">
{skill.path}
</p>
</div>
</div>
<div className="min-w-0 text-sm leading-6 text-muted-foreground">
<p>{skill.summary}</p>
</div>
<div className="min-w-0 md:justify-self-end md:pt-0.5 md:text-right">
<SkillLink skill={skill} />
</div>
</li>
))}
</ul>
</div>
</section>
<section
id="installation"
aria-labelledby="installation-title"
className="scroll-mt-20 space-y-3 py-6"
>
<SectionIntro
id="installation-title"
title="INSTALL"
description="Install CAD Skills with the Skills CLI. Provider-native plugin installs are available as a secondary path."
/>
<div className="max-w-3xl space-y-3">
<SkillsInstallCommand />
<div className="pt-3">
<h3 className="mb-3 text-sm font-medium uppercase tracking-[1.5px] text-foreground">
Plugin Installs
</h3>
<InstallCommands />
</div>
<p className="text-sm leading-6 text-muted-foreground">
Skills CLI installation is preferred for regular use. Restart
your agent if newly installed skills do not appear.
</p>
<p className="text-sm leading-6 text-muted-foreground">
Local development symlink guidance lives in{" "}
<a
className="inline-flex items-center gap-1 text-primary transition hover:text-primary/80"
href="https://github.com/earthtojake/text-to-cad/blob/main/CONTRIBUTING.md"
rel="noreferrer"
target="_blank"
>
CONTRIBUTING.md
<ExternalLink className="size-3" aria-hidden="true" />
</a>
.
</p>
</div>
</section>
</div>
</div>
<SiteFooter />
</main>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { absoluteUrl } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: absoluteUrl("/sitemap.xml"),
};
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { absoluteUrl } from "@/lib/site";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: absoluteUrl("/"),
changeFrequency: "weekly",
priority: 1,
},
];
}
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
type CopyButtonProps = {
text: string;
label?: string;
compact?: boolean;
};
type CopyStatus = "idle" | "copied" | "error";
export function CopyButton({
text,
label = "Copy command",
compact = false,
}: CopyButtonProps) {
const [status, setStatus] = useState<CopyStatus>("idle");
useEffect(() => {
if (status === "idle") {
return;
}
const timeout = window.setTimeout(() => setStatus("idle"), 1600);
return () => window.clearTimeout(timeout);
}, [status]);
const copyText = async () => {
setStatus("copied");
try {
await window.navigator.clipboard.writeText(text);
} catch {
setStatus("error");
}
};
const isCopied = status === "copied";
const isError = status === "error";
const buttonLabel = isCopied ? "Copied" : isError ? "Copy failed" : label;
return (
<button
type="button"
className={`min-w-[4.25rem] shrink-0 cursor-pointer border-l border-border py-[7px] text-xs uppercase leading-none tracking-wider transition-colors ${
isCopied
? "bg-primary text-primary-foreground hover:text-primary-foreground"
: isError
? "bg-destructive text-destructive-foreground hover:text-destructive-foreground"
: "bg-secondary text-muted-foreground hover:text-foreground"
} ${
compact ? "px-2.5" : "px-3"
}`}
onClick={copyText}
aria-label={buttonLabel}
title={buttonLabel}
aria-live="polite"
>
{isCopied ? "copied" : isError ? "failed" : "copy"}
</button>
);
}
@@ -0,0 +1,141 @@
"use client";
import type { CSSProperties } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { HeroStepRender } from "@/components/hero-step-render";
const cadSkillsAscii = String.raw` ██████╗ █████╗ ██████╗ ███████╗██╗ ██╗██╗██╗ ██╗ ███████╗
██╔════╝██╔══██╗██╔══██╗ ██╔════╝██║ ██╔╝██║██║ ██║ ██╔════╝
██║ ███████║██║ ██║ ███████╗█████╔╝ ██║██║ ██║ ███████╗
██║ ██╔══██║██║ ██║ ╚════██║██╔═██╗ ██║██║ ██║ ╚════██║
╚██████╗██║ ██║██████╔╝ ███████║██║ ██╗██║███████╗███████╗███████║
╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚══════╝`;
const ASCII_MEASURE_FONT_SIZE = 10;
const ASCII_MIN_FONT_SIZE = 7;
const ASCII_MAX_FONT_SIZE = 18;
const ASCII_MAX_HEIGHT = 132;
const ASCII_WIDTH_FILL = 0.995;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function AsciiWord({ ascii }: { ascii: string }) {
const frameRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLPreElement>(null);
const [fontSize, setFontSize] = useState(10.5);
useLayoutEffect(() => {
const frame = frameRef.current;
const measure = measureRef.current;
if (!frame || !measure) {
return;
}
const updateFontSize = () => {
const measuredWidth = measure.scrollWidth;
const measuredHeight = measure.scrollHeight;
const availableWidth = frame.clientWidth * ASCII_WIDTH_FILL;
if (!measuredWidth || !measuredHeight || !availableWidth) {
return;
}
const widthSize =
(availableWidth / measuredWidth) * ASCII_MEASURE_FONT_SIZE;
const heightSize =
(ASCII_MAX_HEIGHT / measuredHeight) * ASCII_MEASURE_FONT_SIZE;
const nextFontSize = clamp(
Math.min(widthSize, heightSize),
ASCII_MIN_FONT_SIZE,
ASCII_MAX_FONT_SIZE
);
setFontSize((current) =>
Math.abs(current - nextFontSize) > 0.05 ? nextFontSize : current
);
};
updateFontSize();
const resizeObserver = new ResizeObserver(updateFontSize);
resizeObserver.observe(frame);
void document.fonts?.ready.then(updateFontSize);
return () => resizeObserver.disconnect();
}, [ascii]);
const visibleClassName =
"inline-block select-none whitespace-pre font-mono leading-[108%]";
const measureClassName =
"pointer-events-none invisible absolute left-0 top-0 inline-block whitespace-pre font-mono text-[10px] leading-[108%]";
const asciiStyle = { fontSize: `${fontSize}px` } as CSSProperties;
return (
<div
ref={frameRef}
className="relative max-h-[132px] w-full overflow-hidden"
>
<pre ref={measureRef} className={measureClassName} aria-hidden="true">
{ascii}
</pre>
<pre
className={`${visibleClassName} text-muted-foreground/55`}
style={asciiStyle}
>
{ascii}
</pre>
<pre
className={`ascii-terminal-reveal absolute left-0 top-0 ${visibleClassName} text-foreground`}
style={asciiStyle}
>
{ascii}
</pre>
</div>
);
}
function HeroAsciiHeading() {
return (
<div aria-hidden="true" className="w-full max-w-full overflow-hidden">
<AsciiWord ascii={cadSkillsAscii} />
</div>
);
}
function HeroSubtitle({ className = "" }: { className?: string }) {
return (
<p
className={`text-[17px] font-medium leading-7 text-foreground sm:text-[20px] sm:leading-8 lg:text-[22px] lg:leading-9 ${className}`}
>
Generate CAD in AI agents like Codex and Claude Code.{" "}
<span className="text-primary">
100% open source, runs locally, free forever.
</span>
</p>
);
}
export function HeroSection() {
return (
<section className="border border-border bg-card">
<div className="grid min-w-0 lg:grid-cols-2">
<div className="flex min-w-0 items-center px-4 py-4 sm:px-5 lg:min-h-[156px] lg:px-6 lg:py-5">
<h1 className="sr-only">CAD Skills</h1>
<HeroAsciiHeading />
</div>
<div className="flex min-w-0 items-center border-t border-border px-4 py-4 sm:px-5 lg:min-h-[156px] lg:border-l lg:border-t-0 lg:px-6 lg:py-5">
<HeroSubtitle className="w-full lg:max-w-[34rem]" />
</div>
</div>
<div className="h-[260px] min-h-0 border-t border-border bg-background sm:h-[300px] lg:h-[340px]">
<HeroStepRender />
</div>
</section>
);
}
@@ -0,0 +1,455 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import {
CAD_SCENE_SCALE,
buildModel,
} from "cadjs/common/cadScene.js";
import { renderModel } from "cadjs/common/renderModel.js";
import { loadSource } from "cadjs/common/source.js";
import {
normalizeParameterValue,
normalizeStepModuleParameterValues as normalizeStepParameterValues,
} from "cadjs/common/stepModule.js";
import { cloneThemeSettings } from "cadjs/common/themeSettings.js";
const HERO_STEP_URL = "/hero/planetary_gear_assembly.step.glb";
const HERO_STEP_PARAMETER_URL = "/hero/planetary_gear_assembly.step.js";
const HERO_STEP_CAD_PATH = "models/fun/planetary_gear_assembly.step";
const HERO_STEP_DEMO_URL =
"https://demo.cadskills.xyz/?file=fun%2Fplanetary_gear_assembly.step";
const HERO_STEP_LABEL = "PLANETARY_GEAR_ASSEMBLY.STEP";
const GEAR_MESH_ANIMATION_SPEED = 0.14;
const HERO_STEP_PARAMETER_VALUES = {
drive: 0,
explode: 0,
highlightMeshing: false,
orbitGuides: false,
ringVisible: true,
viewMode: "mesh",
};
type PreviewScheme = "dark" | "light";
type StepParameterDefinition = NonNullable<
Awaited<ReturnType<typeof loadSource>>["stepParameterSource"]
>["definition"];
type StepParameterAnimation = StepParameterDefinition["animations"][number];
type StepParameterRuntime = {
animation: StepParameterAnimation | null;
animationElapsedSec: number;
animationState: {
activeId: string;
duration: number;
elapsedSec: number;
loop: boolean;
playing: boolean;
speed: number;
};
cadPath: string;
definition: StepParameterDefinition;
parameterValues: Record<string, unknown>;
selectorRuntime: null;
sourceUrl: string;
};
const STEP_PREVIEW_PALETTES = {
dark: {
background: "#111820",
border: "#3b4553",
fill: ["#c7d0d8", "#aeb9c3", "#d9dee3", "#8f9ba7"],
headerBackground: "rgba(17, 24, 32, 0.9)",
headerText: "#c9d3df",
keyLight: "#f6f8fb",
keyLightIntensity: 2.5,
fillLight: "#7f95ad",
fillLightIntensity: 0.8,
ambientLight: "#eef4fa",
ambientLightIntensity: 1.85,
},
light: {
background: "#eef1f5",
border: "#c9cfda",
fill: ["#d7dce0", "#cdd3d8", "#e4e7ea", "#bfc7ce"],
headerBackground: "rgba(238, 241, 245, 0.9)",
headerText: "#4c566a",
keyLight: "#ffffff",
keyLightIntensity: 2.6,
fillLight: "#cfd8e3",
fillLightIntensity: 0.9,
ambientLight: "#ffffff",
ambientLightIntensity: 2.2,
},
} as const;
function currentPreviewScheme(): PreviewScheme {
if (typeof document === "undefined") {
return "dark";
}
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}
function buildWorkbenchTheme(scheme: PreviewScheme) {
const palette = STEP_PREVIEW_PALETTES[scheme];
const theme = cloneThemeSettings("workbench");
const materials =
theme.materials && typeof theme.materials === "object"
? theme.materials
: {};
return {
...theme,
materials: {
...materials,
defaultColor: palette.fill[0],
fillColors: [...palette.fill],
overrideSourceColors: false,
saturation: 1,
contrast: 1,
brightness: 1,
tintStrength: 0,
roughness: 0.92,
metalness: 0,
clearcoat: 0,
envMapIntensity: 0,
},
edges: {
...(theme.edges || {}),
enabled: true,
color: scheme === "dark" ? "#202b38" : "#2f3a4b",
contrastMode: "manual",
opacity: 1,
silhouette: true,
thickness: 1,
},
background: {
type: "solid",
solidColor: palette.background,
},
lighting: {
ambient: {
color: palette.ambientLight,
enabled: true,
intensity: palette.ambientLightIntensity,
},
directionalLights: [
{
color: palette.keyLight,
enabled: true,
intensity: palette.keyLightIntensity,
position: { x: -120, y: 180, z: 240 },
},
{
color: palette.fillLight,
enabled: true,
intensity: palette.fillLightIntensity,
position: { x: 180, y: -120, z: 120 },
},
],
hemisphere: {
enabled: false,
},
},
};
}
function createHeroStepParameterRuntime(
definition: StepParameterDefinition,
parameterUrl: string
): StepParameterRuntime {
const animation =
definition.animations.find((item) => item.id === "meshCycle") ??
definition.animations[0] ??
null;
const duration = Math.max(Number(animation?.duration) || 6, 0.001);
return {
animation,
animationElapsedSec: 0,
animationState: {
activeId: animation?.id ?? "",
duration,
elapsedSec: 0,
loop: animation?.loop !== false,
playing: false,
speed: GEAR_MESH_ANIMATION_SPEED,
},
cadPath: HERO_STEP_CAD_PATH,
definition,
parameterValues: normalizeStepParameterValues(
definition,
HERO_STEP_PARAMETER_VALUES
),
selectorRuntime: null,
sourceUrl: parameterUrl,
};
}
function advanceStepParameterRuntime(
runtime: StepParameterRuntime,
deltaSeconds: number
) {
const animation = runtime.animation;
if (!animation?.update) {
return;
}
const duration = Math.max(Number(animation.duration) || 6, 0.001);
const nextElapsed =
(runtime.animationElapsedSec +
Math.max(deltaSeconds, 0) * GEAR_MESH_ANIMATION_SPEED) %
duration;
const progress = nextElapsed / duration;
const currentValues = normalizeStepParameterValues(
runtime.definition,
runtime.parameterValues
);
const nextValues = { ...currentValues };
const set = (parameterId: string, value: unknown) => {
const id = String(parameterId || "").trim();
const parameter = runtime.definition.parameterMap?.[id];
if (!parameter) {
return;
}
nextValues[id] = normalizeParameterValue(parameter, value);
};
animation.update({
cycle: nextElapsed / duration,
duration,
elapsed: nextElapsed,
elapsedSec: nextElapsed,
loop: animation.loop !== false,
params: currentValues,
progress,
set,
});
runtime.animationElapsedSec = nextElapsed;
// The hero drives sidecar parameter values directly; keep time.playing false so
// sidecar-only inspection highlights do not override native STEP colors.
runtime.animationState = {
activeId: animation.id,
duration,
elapsedSec: nextElapsed,
loop: animation.loop !== false,
playing: false,
speed: GEAR_MESH_ANIMATION_SPEED,
};
runtime.parameterValues = nextValues;
}
export function HeroStepRender() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const [scheme, setScheme] = useState<PreviewScheme>("dark");
const [status, setStatus] = useState("loading step");
const palette = STEP_PREVIEW_PALETTES[scheme];
useEffect(() => {
const syncScheme = () => {
setScheme(currentPreviewScheme());
};
syncScheme();
const observer = new MutationObserver(syncScheme);
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true,
});
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
let disposed = false;
let cadModel: ReturnType<typeof buildModel> | null = null;
let viewport: ReturnType<typeof renderModel> | null = null;
let stepParameterRuntime: StepParameterRuntime | null = null;
const dragState = {
active: false,
lastX: 0,
lastY: 0,
pitch: 0.18,
pointerId: -1,
yaw: -0.18,
};
const beforeRender = ({ deltaSeconds }: { deltaSeconds: number }) => {
if (disposed || !cadModel) {
return;
}
if (stepParameterRuntime) {
advanceStepParameterRuntime(stepParameterRuntime, deltaSeconds);
cadModel.update({ stepParameters: stepParameterRuntime });
}
if (!dragState.active) {
dragState.yaw += 0.0018;
}
cadModel.root.rotation.x = dragState.pitch;
cadModel.root.rotation.z = dragState.yaw;
};
const handlePointerDown = (event: PointerEvent) => {
dragState.active = true;
dragState.lastX = event.clientX;
dragState.lastY = event.clientY;
dragState.pointerId = event.pointerId;
canvas.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragState.active || event.pointerId !== dragState.pointerId) {
return;
}
const dx = event.clientX - dragState.lastX;
const dy = event.clientY - dragState.lastY;
dragState.lastX = event.clientX;
dragState.lastY = event.clientY;
dragState.yaw += dx * 0.008;
dragState.pitch = Math.max(
-0.85,
Math.min(0.85, dragState.pitch + dy * 0.006)
);
};
const handlePointerEnd = (event: PointerEvent) => {
if (event.pointerId !== dragState.pointerId) {
return;
}
dragState.active = false;
dragState.pointerId = -1;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
};
const load = async () => {
try {
setStatus("loading step");
const parameterUrl = `${HERO_STEP_PARAMETER_URL}?v=${Date.now()}`;
const source = await loadSource({
kind: "step",
glbUrl: HERO_STEP_URL,
stepParameterUrl: parameterUrl,
cadPath: HERO_STEP_CAD_PATH,
stepParameters: HERO_STEP_PARAMETER_VALUES,
});
if (disposed) {
return;
}
if (!source.stepParameterSource?.definition) {
throw new Error("missing STEP parameter sidecar");
}
stepParameterRuntime = createHeroStepParameterRuntime(
source.stepParameterSource.definition,
parameterUrl
);
cadModel = buildModel(THREE, source, {
theme: buildWorkbenchTheme(scheme),
displayMode: "solid",
stepParameters: stepParameterRuntime,
scale: CAD_SCENE_SCALE.CAD,
selection: {
showEdges: true,
},
edgeRendering: {
mode: "screen-space",
Line2,
LineGeometry,
LineMaterial,
LineSegments2,
LineSegmentsGeometry,
},
});
cadModel.root.rotation.x = dragState.pitch;
cadModel.root.rotation.z = dragState.yaw;
viewport = renderModel(THREE, cadModel, {
autoStart: true,
beforeRender,
canvas,
direction: [1, -1, 0.65],
hostElement: viewportRef.current ?? canvas,
lockedHalfHeightScale: 0.86,
padding: 0.1,
scale: CAD_SCENE_SCALE.CAD,
theme: buildWorkbenchTheme(scheme),
});
setStatus(HERO_STEP_LABEL);
} catch (error) {
setStatus(error instanceof Error ? error.message : "render failed");
}
};
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerEnd);
canvas.addEventListener("pointercancel", handlePointerEnd);
void load();
return () => {
disposed = true;
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerEnd);
canvas.removeEventListener("pointercancel", handlePointerEnd);
viewport?.dispose();
};
}, [palette, scheme]);
return (
<div
className="flex h-full min-h-0 flex-col overflow-hidden"
style={{ backgroundColor: palette.background }}
>
<div ref={viewportRef} className="relative min-h-0 flex-1 overflow-hidden">
<canvas
ref={canvasRef}
aria-label="Light 3D render of a sample STEP planetary gear assembly"
className="absolute inset-0 h-full w-full cursor-grab touch-none active:cursor-grabbing"
/>
</div>
<div
className="flex min-h-8 shrink-0 items-center justify-between gap-3 border-t px-3 py-[7px] text-label uppercase leading-none tracking-[1.5px]"
style={{
backgroundColor: palette.headerBackground,
borderColor: palette.border,
color: palette.headerText,
}}
>
{status === HERO_STEP_LABEL ? (
<a
className="min-w-0 truncate transition hover:text-primary"
href={HERO_STEP_DEMO_URL}
target="_blank"
rel="noreferrer"
title="Open planetary gear assembly in the CAD Skills demo"
>
{status}
</a>
) : (
<span className="min-w-0 truncate">{status}</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,16 @@
export function SiteFooter() {
return (
<footer className="border-t border-border py-6">
<div className="mx-auto flex w-full max-w-[1200px] justify-end px-4 text-label uppercase tracking-[1.5px] text-muted-foreground sm:px-6">
<a
className="transition hover:text-primary"
href="https://x.com/earthtojake"
target="_blank"
rel="noreferrer"
>
Made by @earthtojake
</a>
</div>
</footer>
);
}
@@ -0,0 +1,178 @@
"use client";
import Link from "next/link";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const GITHUB_REPO_URL = "https://github.com/earthtojake/text-to-cad";
function DiscordLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M20.32 4.37a19.8 19.8 0 0 0-4.89-1.51.07.07 0 0 0-.07.03c-.21.38-.44.86-.61 1.25a18.27 18.27 0 0 0-5.49 0 12.64 12.64 0 0 0-.62-1.25.08.08 0 0 0-.07-.03 19.74 19.74 0 0 0-4.89 1.51.07.07 0 0 0-.03.03C.53 9.05-.32 13.58.1 18.06a.08.08 0 0 0 .03.06 19.9 19.9 0 0 0 5.99 3.03.08.08 0 0 0 .08-.03c.46-.63.87-1.3 1.23-1.99a.08.08 0 0 0-.04-.11 13.1 13.1 0 0 1-1.87-.89.08.08 0 0 1-.01-.13c.13-.09.25-.19.37-.29a.07.07 0 0 1 .08-.01c3.93 1.79 8.18 1.79 12.06 0a.07.07 0 0 1 .08.01c.12.1.25.2.37.29a.08.08 0 0 1-.01.13 12.3 12.3 0 0 1-1.87.89.08.08 0 0 0-.04.11c.36.7.77 1.36 1.23 1.99a.08.08 0 0 0 .08.03 19.84 19.84 0 0 0 6-3.03.08.08 0 0 0 .03-.05c.5-5.18-.84-9.67-3.55-13.66a.06.06 0 0 0-.02-.04ZM8.02 15.33c-1.18 0-2.16-1.09-2.16-2.42s.96-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.33-.96 2.42-2.16 2.42Zm7.98 0c-1.18 0-2.16-1.09-2.16-2.42s.96-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.33-.95 2.42-2.16 2.42Z" />
</svg>
);
}
function GitHubLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.3-1.2-1.6-1.2-1.6-1-.7.1-.7.1-.7 1.1.1 1.7 1.2 1.7 1.2 1 1.7 2.6 1.2 3.3.9.1-.7.4-1.2.7-1.5-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.6.1-3.2 0 0 1-.3 3.3 1.2a11.2 11.2 0 0 1 6 0C17 4.7 18 5 18 5c.7 1.6.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3Z" />
</svg>
);
}
function formatGitHubStars(stars: number) {
return new Intl.NumberFormat("en-US").format(stars);
}
function VersionLink({ version }: { version: string }) {
const normalizedVersion = version.trim();
if (!normalizedVersion) {
return null;
}
return (
<a
className="hidden px-2.5 py-1.5 text-ui text-muted-foreground transition hover:bg-secondary hover:text-foreground md:inline-flex"
href={`${GITHUB_REPO_URL}/releases`}
target="_blank"
rel="noreferrer"
aria-label={`Open GitHub releases for version ${normalizedVersion}`}
title={`Open GitHub releases for version ${normalizedVersion}`}
>
{normalizedVersion}
</a>
);
}
export function SiteHeaderClient({
githubStars,
discordUrl,
version,
}: {
githubStars: number | null;
discordUrl: string;
version: string;
}) {
const githubLabel =
githubStars === null
? "Open text-to-cad on GitHub"
: `Open text-to-cad on GitHub, ${new Intl.NumberFormat("en-US").format(
githubStars
)} stars`;
return (
<header className="sticky top-0 z-40 h-14 shrink-0 overflow-hidden border-b border-border bg-background">
<div className="mx-auto flex h-full w-full max-w-[1200px] items-center gap-3 px-4 sm:px-6">
<Link
href="/"
className="flex min-w-0 items-center text-foreground transition hover:text-primary"
>
<span className="min-w-0 truncate text-sm font-medium">
CAD Skills
</span>
</Link>
<nav
aria-label="Primary"
className="ml-auto hidden items-center gap-1 sm:flex"
>
<a
className="px-2.5 py-1.5 text-ui text-muted-foreground transition hover:bg-secondary hover:text-foreground"
href="#skills"
>
SKILLS
</a>
<a
className="px-2.5 py-1.5 text-ui text-muted-foreground transition hover:bg-secondary hover:text-foreground"
href="#installation"
>
INSTALL
</a>
<VersionLink version={version} />
</nav>
<div className="ml-auto flex shrink-0 items-center gap-1 sm:ml-0">
<Button
asChild
variant="outline"
className="card-glow h-8 border-border bg-card px-2 text-ui text-foreground hover:bg-secondary hover:text-primary"
>
<a
href="https://demo.cadskills.xyz"
target="_blank"
rel="noreferrer"
aria-label="Open demo in a new tab"
>
DEMO
</a>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="outline"
size="icon"
className="card-glow h-8 w-8 border-border bg-card text-foreground hover:bg-secondary hover:text-primary"
>
<a
href={discordUrl}
target="_blank"
rel="noreferrer"
aria-label="Join the CAD Skills Discord"
>
<DiscordLogo className="size-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Discord</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="outline"
className="card-glow h-8 border-border bg-card px-2 text-foreground hover:bg-secondary hover:text-primary"
>
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noreferrer"
aria-label={githubLabel}
>
<GitHubLogo className="size-3.5" />
{githubStars !== null ? (
<span className="text-label font-medium tabular-nums tracking-wider">
{formatGitHubStars(githubStars)}
</span>
) : null}
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">GitHub</TooltipContent>
</Tooltip>
<ThemeToggle />
</div>
</div>
</header>
);
}
@@ -0,0 +1,60 @@
import { SiteHeaderClient } from "@/components/site-header-client";
import packageJson from "../../package.json";
const GITHUB_REPO_API_URL =
"https://api.github.com/repos/earthtojake/text-to-cad";
const DEFAULT_DISCORD_URL = "https://discord.gg/5FGB9DwJYU";
function normalizeDiscordUrl(value: string | undefined) {
const rawValue = String(value || "").trim();
if (!rawValue) {
return DEFAULT_DISCORD_URL;
}
const urlValue = /^[a-z][a-z\d+.-]*:\/\//i.test(rawValue)
? rawValue
: `https://${rawValue.replace(/^\/+/, "")}`;
try {
const url = new URL(urlValue);
return ["http:", "https:"].includes(url.protocol)
? url.href
: DEFAULT_DISCORD_URL;
} catch {
return DEFAULT_DISCORD_URL;
}
}
async function getGitHubStars() {
try {
const response = await fetch(GITHUB_REPO_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "cad-skills-docs",
},
next: { revalidate: 60 * 60 },
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as { stargazers_count?: unknown };
const stars = Number(data.stargazers_count);
return Number.isFinite(stars) ? stars : null;
} catch {
return null;
}
}
export async function SiteHeader() {
const githubStars = await getGitHubStars();
return (
<SiteHeaderClient
githubStars={githubStars}
discordUrl={normalizeDiscordUrl(process.env.DISCORD_URL)}
version={packageJson.version}
/>
);
}
@@ -0,0 +1,47 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const THEME_STORAGE_KEY = "cad-skills-theme-v2";
export function ThemeToggle() {
const toggleTheme = () => {
const root = document.documentElement;
const isDark = !root.classList.contains("dark");
const theme = isDark ? "dark" : "light";
root.classList.toggle("dark", isDark);
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The class change still applies when storage is unavailable.
}
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="card-glow h-8 w-8 border-border bg-card text-foreground hover:bg-secondary hover:text-primary"
onClick={toggleTheme}
aria-label="Toggle light and dark mode"
>
<Sun className="size-4 dark:hidden" />
<Moon className="hidden size-4 dark:block" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Light / dark</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+35
View File
@@ -0,0 +1,35 @@
const DEFAULT_SITE_ORIGIN = "https://www.cadskills.xyz";
const SITE_DESCRIPTION =
"A skills library for CAD, robotics, and hardware design agents";
const SITE_TITLE = `CAD Skills | ${SITE_DESCRIPTION}`;
function normalizeOrigin(value: string | undefined, fallback: string) {
const candidate = value?.trim() || fallback;
try {
return new URL(candidate).origin;
} catch {
return fallback;
}
}
export const siteConfig = {
name: "CAD Skills",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
keywords: [
"CAD Skills",
"text-to-cad",
"CAD agents",
"CAD routing",
"agent skills",
"step.parts",
"STEP parts",
"DXF",
],
origin: normalizeOrigin(process.env.NEXT_PUBLIC_SITE_URL, DEFAULT_SITE_ORIGIN),
};
export function absoluteUrl(path: string) {
return new URL(path, `${siteConfig.origin}/`).toString();
}
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from "clsx"
import { extendTailwindMerge } from "tailwind-merge"
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [
"text-label",
"text-ui",
"text-heading",
"text-stat",
"text-hero",
],
},
},
})
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+133
View File
@@ -0,0 +1,133 @@
declare module "cadjs/common/cadScene.js" {
import type { Group, Vector3 } from "three";
type CadBounds = {
min: number[];
max: number[];
};
export const CAD_SCENE_SCALE: {
CAD: string;
URDF: string;
};
export type CadSceneApi = {
root: Group;
modelGroup: Group;
edgesGroup: Group;
displayRecords: unknown[];
records: unknown[];
bounds: CadBounds;
radius: number;
runtime: unknown;
dispose: () => void;
update: (settings?: Record<string, unknown>) => CadSceneApi;
};
export function buildModel(
THREE: unknown,
source: unknown,
settings?: Record<string, unknown>
): CadSceneApi;
export function fitCameraToModel(
THREE: unknown,
camera: unknown,
bounds: CadBounds,
options?: Record<string, unknown>
): {
center: Vector3;
radius: number;
halfHeight: number;
distance: number;
};
}
declare module "cadjs/common/renderModel.js" {
export type RenderViewportApi = {
renderer: unknown;
scene: unknown;
camera: unknown;
ready: Promise<void>;
resize: () => unknown;
render: () => void;
start: () => void;
stop: () => void;
capturePng: () => string;
dispose: () => void;
};
export function renderModel(
THREE: unknown,
model: unknown,
options?: Record<string, unknown>
): RenderViewportApi;
}
declare module "cadjs/common/source.js" {
export type RenderSource = {
kind: string;
meshData: unknown;
stepParameterSource: {
definition: import("cadjs/common/stepModule.js").StepModuleDefinition;
} | null;
};
export function loadSource(
input: unknown,
options?: Record<string, unknown>
): Promise<RenderSource>;
}
declare module "cadjs/common/themeSettings.js" {
export function cloneThemeSettings(themeId: string): Record<
string,
unknown
> & {
materials?: Record<string, unknown>;
};
}
declare module "cadjs/common/stepModule.js" {
export type StepModuleParameter = {
id: string;
type: string;
defaultValue: unknown;
};
export type StepModuleAnimation = {
id: string;
duration: number;
loop: boolean;
update?: (context: {
cycle: number;
duration: number;
elapsed: number;
elapsedSec: number;
loop: boolean;
params: Record<string, unknown>;
progress: number;
set: (parameterId: string, value: unknown) => void;
}) => void;
};
export type StepModuleDefinition = {
animations: StepModuleAnimation[];
parameterMap: Record<string, StepModuleParameter>;
};
export function loadStepModuleDefinition(
url: string,
options?: Record<string, unknown>
): Promise<StepModuleDefinition>;
export function normalizeParameterValue(
definition: StepModuleParameter,
value: unknown
): unknown;
export function normalizeStepModuleParameterValues(
definition: StepModuleDefinition,
values?: Record<string, unknown>
): Record<string, unknown>;
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"cadjs/*": ["../packages/cadjs/src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"git": {
"deploymentEnabled": false
},
"buildCommand": "npm run build"
}
+48
View File
@@ -0,0 +1,48 @@
# Demo Models
Curated model fixtures and generator assets for text-to-cad workflows.
This tree is intended to be committed with Git LFS for large CAD, mesh, robot,
and G-code artifacts. Source generators and concise documentation remain normal
text files.
## Directory Map
- [benchmarks/](benchmarks/README.md): build123d benchmark generators plus STEP
and mesh outputs.
- [fun/](fun/README.md): standalone generated CAD examples and printable
outputs.
- [gcode/](gcode/README.md): small slicer input/output fixtures.
- [implicits/](implicits/README.md): browser-native implicit CAD examples.
- [mechanisms/](mechanisms/README.md): flattened mechanism STEP demos and
generated render sidecars.
- [robots/](robots/README.md): URDF/SRDF robot fixtures, meshes, printable
outputs, and selected robot STEP sources.
- [simple/](simple/README.md): compact build123d generators and STEP outputs
for basic parts.
The larger `mechbench/` and `mechbench2/` external datasets are intentionally
not included in this committed fixture tree.
## Git LFS Fetching
Repository LFS config excludes `models/**` from default LFS fetches so ordinary
checkout and publish jobs can avoid downloading every model blob. Fetch the
model artifacts explicitly when you need local bytes:
```bash
git lfs pull --include="models/**" --exclude=""
```
## Cleanup Policy
- Keep canonical sources (`*.py`, `*.implicit.js`, `*.urdf`, `*.srdf`, and docs)
readable in normal Git.
- Keep durable generated fixtures (`*.step`, `*.stl`, `*.3mf`, `*.glb`,
`*.gcode`, and `*.dxf`) in Git LFS.
- Do not commit supplementary media or sidecar metadata such as `*.png`,
`*.mp4`, `*.gif`, or `*.json` unless a future workflow defines them as a
required model artifact.
- Do not commit local runtime debris such as `.DS_Store`, `__pycache__/`,
`.cache/`, logs, or one-off timestamped review snapshots.
- Put temporary scratch artifacts under ignored local paths, not in this tree.
+24
View File
@@ -0,0 +1,24 @@
# Benchmark Models
Benchmark CAD fixtures for repeatable geometry generation, import/export, and
viewer behavior checks. Each benchmark is intentionally more structured than the
small examples in `../simple/` and is meant to exercise a distinct modeling
surface.
## Contents
- `benchmark_*.py`: build123d generator source for each benchmark.
- `benchmark_*.step`: canonical generated STEP output for each benchmark.
- `.benchmark_*.step.glb`: generated render/selector sidecar paired with the
STEP file.
- `benchmark_common.py`: shared helper functions for benchmark generators.
- `validate_benchmark.py`: local benchmark validation helper.
## Coverage
The benchmark set covers calibration blocks, flanges, brackets, shafts,
enclosures, clevises, engine-like fins, impellers, spiral stairs, and planetary
gear stages.
Keep benchmark examples here when they are intended as regression fixtures or
represent a richer version of a simpler shape.
@@ -0,0 +1,31 @@
from build123d import Align, Box, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import line_edges_at_z, safe_chamfer
LENGTH = 100.0
WIDTH = 60.0
HEIGHT = 20.0
HOLE_DIAMETER = 8.0
HOLE_LOCATIONS = ((-35.0, -20.0), (-35.0, 20.0), (35.0, -20.0), (35.0, 20.0))
TOP_CHAMFER = 2.0
def gen_step():
"""Return the rectangular calibration block benchmark model in millimeters."""
with BuildPart() as block:
Box(LENGTH, WIDTH, HEIGHT, align=(Align.CENTER, Align.CENTER, Align.MIN))
for x_pos, y_pos in HOLE_LOCATIONS:
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=HOLE_DIAMETER / 2.0,
height=HEIGHT + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = block.part
part = safe_chamfer(part, line_edges_at_z(part, HEIGHT), length=TOP_CHAMFER)
part.label = "benchmark_01_rectangular_calibration_block"
return part
@@ -0,0 +1,50 @@
from math import tau
from build123d import Align, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import circular_edges, polar_point, safe_fillet
OUTER_DIAMETER = 80.0
THICKNESS = 10.0
CENTRAL_BORE_DIAMETER = 30.0
BOLT_HOLE_COUNT = 6
BOLT_HOLE_DIAMETER = 6.0
BOLT_CIRCLE_DIAMETER = 60.0
OUTER_FILLET = 1.5
def gen_step():
"""Return the circular flange benchmark model in millimeters."""
with BuildPart() as flange:
Cylinder(
radius=OUTER_DIAMETER / 2.0,
height=THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, -1.0))):
Cylinder(
radius=CENTRAL_BORE_DIAMETER / 2.0,
height=THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for index in range(BOLT_HOLE_COUNT):
x_pos, y_pos = polar_point(BOLT_CIRCLE_DIAMETER / 2.0, tau * index / BOLT_HOLE_COUNT)
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=BOLT_HOLE_DIAMETER / 2.0,
height=THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = flange.part
edges = []
edges.extend(circular_edges(part, radius=OUTER_DIAMETER / 2.0, axis="z", coordinate=0.0))
edges.extend(circular_edges(part, radius=OUTER_DIAMETER / 2.0, axis="z", coordinate=THICKNESS))
part = safe_fillet(part, edges, radius=OUTER_FILLET)
part.label = "benchmark_02_circular_flange_bolt_pattern"
return part
@@ -0,0 +1,113 @@
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
add,
extrude,
)
from benchmark_common import safe_fillet
BASE_LENGTH = 80.0
BASE_WIDTH = 50.0
BASE_THICKNESS = 8.0
BACK_LENGTH = 80.0
BACK_THICKNESS = 8.0
BACK_HEIGHT = 50.0
BACK_CENTER_Y = BASE_WIDTH / 2.0 - BACK_THICKNESS / 2.0
HOLE_DIAMETER = 6.0
BASE_HOLE_LOCATIONS = ((-25.0, -10.0), (25.0, -10.0))
BACK_HOLE_LOCATIONS = ((-25.0, 30.0), (25.0, 30.0))
GUSSET_X_THICKNESS = 8.0
GUSSET_X_LOCATIONS = (-20.0, 20.0)
GUSSET_DEPTH = 30.0
GUSSET_HEIGHT = 30.0
TRANSITION_FILLET = 2.0
def _make_gusset(x_center: float):
back_front_y = BACK_CENTER_Y - BACK_THICKNESS / 2.0
front_y = back_front_y - GUSSET_DEPTH
top_z = BASE_THICKNESS + GUSSET_HEIGHT
with BuildPart() as gusset:
with BuildSketch(Plane.YZ):
Polygon(
[
(back_front_y, BASE_THICKNESS),
(front_y, BASE_THICKNESS),
(back_front_y, top_z),
],
align=None,
)
extrude(amount=GUSSET_X_THICKNESS)
part = gusset.part.moved(Location((x_center - GUSSET_X_THICKNESS / 2.0, 0.0, 0.0)))
part.label = f"gusset_x_{x_center:.0f}"
return part
def _transition_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
if str(edge.geom_type).endswith("LINE"):
if abs(bbox.min.Z - BASE_THICKNESS) < 0.05 and abs(bbox.max.Z - BASE_THICKNESS) < 0.05:
center_y = bbox.center().Y
if BACK_CENTER_Y - BACK_THICKNESS / 2.0 - 0.05 <= center_y <= BASE_WIDTH / 2.0 + 0.05:
if bbox.size.X > 20.0:
edges.append(edge)
return edges
def gen_step():
"""Return the L-bracket benchmark model in millimeters."""
with BuildPart() as bracket:
Box(BASE_LENGTH, BASE_WIDTH, BASE_THICKNESS, align=(Align.CENTER, Align.CENTER, Align.MIN))
with Locations(Location((0.0, BACK_CENTER_Y, BASE_THICKNESS))):
Box(
BACK_LENGTH,
BACK_THICKNESS,
BACK_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
for x_pos, y_pos in BASE_HOLE_LOCATIONS:
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=HOLE_DIAMETER / 2.0,
height=BASE_THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for x_pos, z_pos in BACK_HOLE_LOCATIONS:
with Locations(Location((x_pos, BACK_CENTER_Y, z_pos))):
Cylinder(
radius=HOLE_DIAMETER / 2.0,
height=BACK_THICKNESS + 2.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
for x_center in GUSSET_X_LOCATIONS:
add(_make_gusset(x_center), mode=Mode.ADD)
part = bracket.part
part = safe_fillet(part, _transition_edges(part), radius=TRANSITION_FILLET)
part.label = "benchmark_03_l_bracket_gussets_two_hole_directions"
return part
@@ -0,0 +1,53 @@
from build123d import Align, Box, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import circular_edges, safe_chamfer
TOTAL_LENGTH = 120.0
LEFT_LENGTH = 30.0
MIDDLE_LENGTH = 60.0
RIGHT_LENGTH = 30.0
SMALL_DIAMETER = 20.0
MIDDLE_DIAMETER = 30.0
END_CHAMFER = 1.0
KEYWAY_X_START = 40.0
KEYWAY_X_END = 80.0
KEYWAY_WIDTH = 6.0
KEYWAY_DEPTH = 3.0
def _add_shaft_section(x_start: float, x_end: float, diameter: float):
with Locations(Location(((x_start + x_end) / 2.0, 0.0, 0.0))):
Cylinder(
radius=diameter / 2.0,
height=x_end - x_start,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.ADD,
)
def gen_step():
"""Return the stepped shaft with keyway benchmark model in millimeters."""
with BuildPart() as shaft:
_add_shaft_section(0.0, LEFT_LENGTH, SMALL_DIAMETER)
_add_shaft_section(LEFT_LENGTH, LEFT_LENGTH + MIDDLE_LENGTH, MIDDLE_DIAMETER)
_add_shaft_section(LEFT_LENGTH + MIDDLE_LENGTH, TOTAL_LENGTH, SMALL_DIAMETER)
with Locations(Location(((KEYWAY_X_START + KEYWAY_X_END) / 2.0, 0.0, MIDDLE_DIAMETER / 2.0 - KEYWAY_DEPTH))):
Box(
KEYWAY_X_END - KEYWAY_X_START,
KEYWAY_WIDTH,
KEYWAY_DEPTH + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = shaft.part
end_edges = []
end_edges.extend(circular_edges(part, radius=SMALL_DIAMETER / 2.0, axis="x", coordinate=0.0))
end_edges.extend(circular_edges(part, radius=SMALL_DIAMETER / 2.0, axis="x", coordinate=TOTAL_LENGTH))
part = safe_chamfer(part, end_edges, length=END_CHAMFER)
part.label = "benchmark_04_stepped_shaft_keyway"
return part
@@ -0,0 +1,75 @@
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
RectangleRounded,
extrude,
)
OUTER_LENGTH = 100.0
OUTER_WIDTH = 70.0
OUTER_HEIGHT = 30.0
WALL_THICKNESS = 3.0
FLOOR_THICKNESS = 3.0
OUTER_CORNER_RADIUS = 2.0
STANDOFF_DIAMETER = 10.0
STANDOFF_HEIGHT = 12.0
STANDOFF_HOLE_DIAMETER = 3.0
STANDOFF_HOLE_DEPTH = 8.0
STANDOFF_LOCATIONS = ((-35.0, -25.0), (-35.0, 25.0), (35.0, -25.0), (35.0, 25.0))
def gen_step():
"""Return the open-top electronics enclosure benchmark model in millimeters."""
inner_length = OUTER_LENGTH - 2.0 * WALL_THICKNESS
inner_width = OUTER_WIDTH - 2.0 * WALL_THICKNESS
cavity_height = OUTER_HEIGHT - FLOOR_THICKNESS
standoff_top_z = FLOOR_THICKNESS + STANDOFF_HEIGHT
with BuildPart() as enclosure:
with BuildSketch(Plane.XY):
RectangleRounded(
OUTER_LENGTH,
OUTER_WIDTH,
OUTER_CORNER_RADIUS,
align=(Align.CENTER, Align.CENTER),
)
extrude(amount=OUTER_HEIGHT)
with Locations(Location((0.0, 0.0, FLOOR_THICKNESS))):
Box(
inner_length,
inner_width,
cavity_height,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for x_pos, y_pos in STANDOFF_LOCATIONS:
with Locations(Location((x_pos, y_pos, FLOOR_THICKNESS))):
Cylinder(
radius=STANDOFF_DIAMETER / 2.0,
height=STANDOFF_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location((x_pos, y_pos, standoff_top_z))):
Cylinder(
radius=STANDOFF_HOLE_DIAMETER / 2.0,
height=STANDOFF_HOLE_DEPTH,
align=(Align.CENTER, Align.CENTER, Align.MAX),
mode=Mode.SUBTRACT,
)
part = enclosure.part
part.label = "benchmark_05_open_top_electronics_enclosure_bosses"
return part
@@ -0,0 +1,164 @@
from math import cos, radians, sin
from build123d import (
Align,
Box,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
add,
extrude,
)
from benchmark_common import rounded_triangle_points, safe_fillet
BASE_LENGTH = 120.0
BASE_WIDTH = 60.0
BASE_THICKNESS = 10.0
BASE_FILLET = 3.0
LUG_X_EXTENT = 36.0
LUG_RADIUS = 18.0
LUG_HEIGHT_ABOVE_BASE = 42.0
LUG_THICKNESS_Y = 18.0
LUG_GAP_Y = 16.0
LUG_FILLET = 2.0
CLEVIS_HOLE_DIAMETER = 14.0
CLEVIS_HOLE_CENTER_Z = 34.0
BASE_HOLE_DIAMETER = 7.0
BASE_HOLE_LOCATIONS = ((-45.0, -20.0), (-45.0, 20.0), (45.0, -20.0), (45.0, 20.0))
CUTOUT_CORNER_RADIUS = 3.0
RIB_THICKNESS_X = 6.0
def _lug_profile_points() -> list[tuple[float, float]]:
half_x = LUG_X_EXTENT / 2.0
arc_center_z = BASE_THICKNESS + LUG_HEIGHT_ABOVE_BASE - LUG_RADIUS
points = [(-half_x, BASE_THICKNESS), (half_x, BASE_THICKNESS), (half_x, arc_center_z)]
for step in range(1, 12):
angle = radians(180.0 * step / 12.0)
points.append((LUG_RADIUS * cos(angle), arc_center_z + LUG_RADIUS * sin(angle)))
points.append((-half_x, arc_center_z))
return points
def _make_lug(y_min: float, label: str):
with BuildPart() as lug:
with BuildSketch(Plane.XZ):
Polygon(_lug_profile_points(), align=None)
extrude(amount=LUG_THICKNESS_Y)
part = lug.part.moved(Location((0.0, y_min, 0.0)))
part.label = label
return part
def _make_rib(sign: float):
outer_y = sign * (LUG_GAP_Y / 2.0 + LUG_THICKNESS_Y)
foot_y = sign * (BASE_WIDTH / 2.0)
top_z = BASE_THICKNESS + 28.0
with BuildPart() as rib:
with BuildSketch(Plane.YZ):
Polygon(
[
(outer_y, BASE_THICKNESS),
(foot_y, BASE_THICKNESS),
(outer_y, top_z),
],
align=None,
)
extrude(amount=RIB_THICKNESS_X)
part = rib.part.moved(Location((-RIB_THICKNESS_X / 2.0, 0.0, 0.0)))
part.label = "positive_y_diagonal_rib" if sign > 0.0 else "negative_y_diagonal_rib"
return part
def _outer_base_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
on_outer_x = abs(abs(center.X) - BASE_LENGTH / 2.0) < 0.05
on_outer_y = abs(abs(center.Y) - BASE_WIDTH / 2.0) < 0.05
on_top_or_bottom = abs(center.Z - 0.0) < 0.05 or abs(center.Z - BASE_THICKNESS) < 0.05
if (on_outer_x or on_outer_y) and on_top_or_bottom:
edges.append(edge)
return edges
def _lug_base_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
if abs(center.Z - BASE_THICKNESS) > 0.05:
continue
if abs(abs(center.Y) - LUG_GAP_Y / 2.0) < 0.05 and bbox.size.X > LUG_X_EXTENT - 1.0:
edges.append(edge)
return edges
def _subtract_rounded_triangle(vertices: list[tuple[float, float]]):
with BuildSketch(Plane.XY) as cutout:
Polygon(
rounded_triangle_points(vertices, radius=CUTOUT_CORNER_RADIUS, segments=5),
align=None,
)
extrude(cutout.sketch, amount=BASE_THICKNESS + 2.0, mode=Mode.SUBTRACT)
def gen_step():
"""Return the clevis bracket benchmark model in millimeters."""
positive_lug_y_min = LUG_GAP_Y / 2.0 + LUG_THICKNESS_Y
negative_lug_y_min = -LUG_GAP_Y / 2.0
with BuildPart() as bracket:
Box(BASE_LENGTH, BASE_WIDTH, BASE_THICKNESS, align=(Align.CENTER, Align.CENTER, Align.MIN))
base = safe_fillet(bracket.part, _outer_base_edges(bracket.part), radius=BASE_FILLET)
with BuildPart() as bracket:
add(base, mode=Mode.ADD)
add(_make_lug(positive_lug_y_min, "positive_y_clevis_lug"), mode=Mode.ADD)
add(_make_lug(negative_lug_y_min, "negative_y_clevis_lug"), mode=Mode.ADD)
add(_make_rib(1.0), mode=Mode.ADD)
add(_make_rib(-1.0), mode=Mode.ADD)
with Locations(Location((0.0, 0.0, CLEVIS_HOLE_CENTER_Z))):
Cylinder(
radius=CLEVIS_HOLE_DIAMETER / 2.0,
height=BASE_WIDTH + 8.0,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
for x_pos, y_pos in BASE_HOLE_LOCATIONS:
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=BASE_HOLE_DIAMETER / 2.0,
height=BASE_THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
_subtract_rounded_triangle([(-54.0, -13.0), (-25.0, 0.0), (-54.0, 13.0)])
_subtract_rounded_triangle([(54.0, -13.0), (25.0, 0.0), (54.0, 13.0)])
part = bracket.part
part = safe_fillet(part, _lug_base_edges(part), radius=LUG_FILLET)
part.label = "benchmark_06_aerospace_clevis_bracket_lightening_cutouts"
return part
@@ -0,0 +1,122 @@
from math import cos, radians, sin, tau
from build123d import Align, BuildPart, Cylinder, Location, Locations, Mode
from benchmark_common import circular_edges, polar_point, safe_fillet
BARREL_DIAMETER = 36.0
BARREL_HEIGHT = 70.0
FIN_COUNT = 12
FIN_DIAMETER = 62.0
FIN_THICKNESS = 2.0
FIN_Z_VALUES = tuple(10.0 + 5.0 * index for index in range(FIN_COUNT))
BASE_FLANGE_DIAMETER = 70.0
BASE_FLANGE_THICKNESS = 8.0
MOUNT_HOLE_COUNT = 6
MOUNT_HOLE_DIAMETER = 5.0
MOUNT_BOLT_CIRCLE_DIAMETER = 56.0
TOP_CAP_DIAMETER = 44.0
TOP_CAP_BOTTOM_Z = 70.0
TOP_CAP_HEIGHT = 8.0
BOSS_DIAMETER = 12.0
BOSS_LENGTH = 24.0
BOSS_ANGLE_DEGREES = 35.0
BOSS_BORE_DIAMETER = 5.0
EDGE_FILLET = 0.95
def _boss_center() -> tuple[float, float, float]:
angle = radians(BOSS_ANGLE_DEGREES)
start = (TOP_CAP_DIAMETER / 2.0 - 1.0, 0.0, TOP_CAP_BOTTOM_Z + TOP_CAP_HEIGHT / 2.0)
direction = (cos(angle), 0.0, sin(angle))
return (
start[0] + direction[0] * BOSS_LENGTH / 2.0,
0.0,
start[2] + direction[2] * BOSS_LENGTH / 2.0,
)
def _edge_fillets(part):
edges = []
for z_pos in FIN_Z_VALUES:
edges.extend(circular_edges(part, radius=FIN_DIAMETER / 2.0, axis="z", coordinate=z_pos))
edges.extend(circular_edges(part, radius=FIN_DIAMETER / 2.0, axis="z", coordinate=z_pos + FIN_THICKNESS))
edges.extend(circular_edges(part, radius=BASE_FLANGE_DIAMETER / 2.0, axis="z", coordinate=0.0))
edges.extend(circular_edges(part, radius=BASE_FLANGE_DIAMETER / 2.0, axis="z", coordinate=BASE_FLANGE_THICKNESS))
return edges
def gen_step():
"""Return the radial-engine-style cylinder benchmark model in millimeters."""
boss_rotation_y = 90.0 - BOSS_ANGLE_DEGREES
boss_center = _boss_center()
with BuildPart() as cylinder:
Cylinder(
radius=BARREL_DIAMETER / 2.0,
height=BARREL_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
Cylinder(
radius=BASE_FLANGE_DIAMETER / 2.0,
height=BASE_FLANGE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
for z_pos in FIN_Z_VALUES:
with Locations(Location((0.0, 0.0, z_pos))):
Cylinder(
radius=FIN_DIAMETER / 2.0,
height=FIN_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location((0.0, 0.0, TOP_CAP_BOTTOM_Z))):
Cylinder(
radius=TOP_CAP_DIAMETER / 2.0,
height=TOP_CAP_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location(boss_center)):
Cylinder(
radius=BOSS_DIAMETER / 2.0,
height=BOSS_LENGTH,
rotation=(0.0, boss_rotation_y, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.ADD,
)
for index in range(MOUNT_HOLE_COUNT):
x_pos, y_pos = polar_point(MOUNT_BOLT_CIRCLE_DIAMETER / 2.0, tau * index / MOUNT_HOLE_COUNT)
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=MOUNT_HOLE_DIAMETER / 2.0,
height=BASE_FLANGE_THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with Locations(Location(boss_center)):
Cylinder(
radius=BOSS_BORE_DIAMETER / 2.0,
height=BOSS_LENGTH + 6.0,
rotation=(0.0, boss_rotation_y, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=Mode.SUBTRACT,
)
part = cylinder.part
part = safe_fillet(part, _edge_fillets(part), radius=EDGE_FILLET)
part.label = "benchmark_07_radial_engine_cylinder_cooling_fins"
return part
@@ -0,0 +1,152 @@
from math import asin, cos, hypot, radians, sin, tau
from build123d import (
Align,
BuildPart,
BuildSketch,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
add,
extrude,
)
from benchmark_common import circular_edges, polar_point, safe_fillet
BACKPLATE_DIAMETER = 90.0
BACKPLATE_THICKNESS = 6.0
BACKPLATE_FILLET = 1.5
HUB_DIAMETER = 26.0
HUB_HEIGHT = 22.0
BORE_DIAMETER = 8.0
BLADE_COUNT = 12
BLADE_ROOT_RADIUS = 18.0
BLADE_TIP_RADIUS = 43.0
BLADE_THICKNESS = 3.0
BLADE_HEIGHT = 16.0
BLADE_BACKWARD_SWEEP = radians(45.0)
BLADE_SEGMENTS = 5
BLADE_ROOT_FILLET = 1.0
def _clamp_radius(point: tuple[float, float]) -> tuple[float, float]:
radius = hypot(point[0], point[1])
if radius < BLADE_ROOT_RADIUS:
scale = BLADE_ROOT_RADIUS / radius
return (point[0] * scale, point[1] * scale)
if radius > BLADE_TIP_RADIUS:
scale = BLADE_TIP_RADIUS / radius
return (point[0] * scale, point[1] * scale)
return point
def _blade_center(base_angle: float, t: float) -> tuple[float, float]:
radius = BLADE_ROOT_RADIUS + (BLADE_TIP_RADIUS - BLADE_ROOT_RADIUS) * t
angle = base_angle - BLADE_BACKWARD_SWEEP * t
return polar_point(radius, angle)
def _blade_normal(base_angle: float, t: float) -> tuple[float, float]:
radius = BLADE_ROOT_RADIUS + (BLADE_TIP_RADIUS - BLADE_ROOT_RADIUS) * t
dr_dt = BLADE_TIP_RADIUS - BLADE_ROOT_RADIUS
dtheta_dt = -BLADE_BACKWARD_SWEEP
angle = base_angle + dtheta_dt * t
dx_dt = dr_dt * cos(angle) - radius * sin(angle) * dtheta_dt
dy_dt = dr_dt * sin(angle) + radius * cos(angle) * dtheta_dt
length = hypot(dx_dt, dy_dt)
return (-dy_dt / length, dx_dt / length)
def _blade_end_points(base_angle: float, t: float) -> tuple[tuple[float, float], tuple[float, float]]:
radius = BLADE_ROOT_RADIUS if t == 0.0 else BLADE_TIP_RADIUS
angle = base_angle - BLADE_BACKWARD_SWEEP * t
half_angle = asin((BLADE_THICKNESS / 2.0) / radius)
return (polar_point(radius, angle + half_angle), polar_point(radius, angle - half_angle))
def _blade_outline(base_angle: float) -> list[tuple[float, float]]:
left_side = []
right_side = []
half_thickness = BLADE_THICKNESS / 2.0
for index in range(BLADE_SEGMENTS + 1):
t = index / BLADE_SEGMENTS
if index == 0 or index == BLADE_SEGMENTS:
left, right = _blade_end_points(base_angle, t)
else:
center = _blade_center(base_angle, t)
normal = _blade_normal(base_angle, t)
left = _clamp_radius((center[0] + normal[0] * half_thickness, center[1] + normal[1] * half_thickness))
right = _clamp_radius((center[0] - normal[0] * half_thickness, center[1] - normal[1] * half_thickness))
left_side.append(left)
right_side.append(right)
return left_side + list(reversed(right_side))
def _make_blade(index: int):
base_angle = tau * index / BLADE_COUNT
with BuildPart() as blade:
with BuildSketch(Plane.XY):
Polygon(_blade_outline(base_angle), align=None)
extrude(amount=BLADE_HEIGHT)
part = blade.part.moved(Location((0.0, 0.0, BACKPLATE_THICKNESS)))
part.label = f"backward_curved_blade_{index + 1:02d}"
return part
def _blade_root_edges(part):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
center = bbox.center()
if abs(center.Z - BACKPLATE_THICKNESS) > 0.05:
continue
radial_distance = hypot(center.X, center.Y)
if BLADE_ROOT_RADIUS - 3.0 <= radial_distance <= 30.0:
if str(edge.geom_type).endswith("LINE"):
edges.append(edge)
return edges
def gen_step():
"""Return the centrifugal impeller benchmark model in millimeters."""
total_height = BACKPLATE_THICKNESS + HUB_HEIGHT
with BuildPart() as impeller:
Cylinder(
radius=BACKPLATE_DIAMETER / 2.0,
height=BACKPLATE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
for blade_index in range(BLADE_COUNT):
add(_make_blade(blade_index), mode=Mode.ADD)
with Locations(Location((0.0, 0.0, BACKPLATE_THICKNESS))):
Cylinder(
radius=HUB_DIAMETER / 2.0,
height=HUB_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.ADD,
)
with Locations(Location((0.0, 0.0, -1.0))):
Cylinder(
radius=BORE_DIAMETER / 2.0,
height=total_height + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = impeller.part
backplate_edges = []
backplate_edges.extend(circular_edges(part, radius=BACKPLATE_DIAMETER / 2.0, axis="z", coordinate=0.0))
backplate_edges.extend(circular_edges(part, radius=BACKPLATE_DIAMETER / 2.0, axis="z", coordinate=BACKPLATE_THICKNESS))
part = safe_fillet(part, backplate_edges, radius=BACKPLATE_FILLET)
part = safe_fillet(part, _blade_root_edges(part), radius=BLADE_ROOT_FILLET)
part.label = "benchmark_08_centrifugal_impeller_backward_curved_blades"
return part
@@ -0,0 +1,149 @@
from math import cos, radians, sin
from build123d import (
Align,
BuildLine,
BuildPart,
BuildSketch,
CenterArc,
Circle,
Color,
Compound,
Cylinder,
Edge,
Line,
Location,
Plane,
extrude,
make_face,
sweep,
)
BASE_DIAMETER = 90.0
BASE_THICKNESS = 5.0
COLUMN_DIAMETER = 14.0
COLUMN_HEIGHT = 140.0
TREAD_COUNT = 20
TREAD_THICKNESS = 4.0
TREAD_INNER_RADIUS = 10.0
TREAD_OUTER_RADIUS = 62.0
TREAD_SWEEP_DEGREES = 24.0
TREAD_FIRST_BOTTOM_Z = 4.0
TREAD_RISE = 6.0
TREAD_ROTATION_DEGREES = 18.0
HANDRAIL_RADIUS = 66.0
HANDRAIL_DIAMETER = 5.0
HANDRAIL_START_Z = 14.0
HANDRAIL_END_Z = 130.0
BALUSTER_DIAMETER = 3.0
def _polar_point(radius: float, angle_degrees: float) -> tuple[float, float]:
angle = radians(angle_degrees)
return (radius * cos(angle), radius * sin(angle))
def _make_base():
with BuildPart() as base:
Cylinder(
radius=BASE_DIAMETER / 2.0,
height=BASE_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = base.part
part.label = "base_disk"
part.color = Color(0.42, 0.43, 0.45, 1.0)
return part
def _make_column():
with BuildPart() as column:
Cylinder(
radius=COLUMN_DIAMETER / 2.0,
height=COLUMN_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = column.part
part.label = "central_column"
part.color = Color(0.70, 0.72, 0.74, 1.0)
return part
def _make_tread_profile():
half_sweep = TREAD_SWEEP_DEGREES / 2.0
with BuildPart() as tread:
with BuildSketch(Plane.XY):
with BuildLine():
CenterArc((0.0, 0.0), TREAD_OUTER_RADIUS, -half_sweep, TREAD_SWEEP_DEGREES)
Line(_polar_point(TREAD_OUTER_RADIUS, half_sweep), _polar_point(TREAD_INNER_RADIUS, half_sweep))
CenterArc((0.0, 0.0), TREAD_INNER_RADIUS, half_sweep, -TREAD_SWEEP_DEGREES)
Line(_polar_point(TREAD_INNER_RADIUS, -half_sweep), _polar_point(TREAD_OUTER_RADIUS, -half_sweep))
make_face()
extrude(amount=TREAD_THICKNESS)
return tread.part
def _make_tread(index: int):
angle = index * TREAD_ROTATION_DEGREES
bottom_z = TREAD_FIRST_BOTTOM_Z + index * TREAD_RISE
part = _make_tread_profile().moved(Location((0.0, 0.0, bottom_z), (0.0, 0.0, angle)))
part.label = f"helical_tread_{index + 1:02d}"
part.color = Color(0.78, 0.58, 0.35, 1.0)
return part
def _handrail_center_z(angle_degrees: float) -> float:
return HANDRAIL_START_Z + (HANDRAIL_END_Z - HANDRAIL_START_Z) * angle_degrees / 360.0
def _make_handrail():
rail_height = HANDRAIL_END_Z - HANDRAIL_START_Z
path = Edge.make_helix(
pitch=rail_height,
height=rail_height,
radius=HANDRAIL_RADIUS,
center=(0.0, 0.0, HANDRAIL_START_Z),
)
profile_plane = Plane(
origin=(HANDRAIL_RADIUS, 0.0, HANDRAIL_START_Z),
x_dir=(1.0, 0.0, 0.0),
z_dir=path.tangent_at(0.0),
)
with BuildPart() as rail:
with BuildSketch(profile_plane):
Circle(HANDRAIL_DIAMETER / 2.0)
sweep(path=path, is_frenet=True)
part = rail.part
part.label = "helical_handrail"
part.color = Color(0.22, 0.28, 0.34, 1.0)
return part
def _make_baluster(index: int):
angle = index * TREAD_ROTATION_DEGREES
tread_top_z = TREAD_FIRST_BOTTOM_Z + index * TREAD_RISE + TREAD_THICKNESS
rail_center_z = _handrail_center_z(angle)
x_pos, y_pos = _polar_point(TREAD_OUTER_RADIUS, angle)
with BuildPart() as baluster:
Cylinder(
radius=BALUSTER_DIAMETER / 2.0,
height=rail_center_z - tread_top_z,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = baluster.part.moved(Location((x_pos, y_pos, tread_top_z)))
part.label = f"baluster_{index + 1:02d}"
part.color = Color(0.30, 0.33, 0.36, 1.0)
return part
def gen_step():
"""Return the miniature spiral staircase benchmark model in millimeters."""
parts = [_make_base(), _make_column()]
parts.extend(_make_tread(index) for index in range(TREAD_COUNT))
parts.append(_make_handrail())
parts.extend(_make_baluster(index) for index in range(TREAD_COUNT))
return Compound(obj=parts, children=parts, label="benchmark_09_spiral_staircase_helical_handrail")
@@ -0,0 +1,181 @@
from math import tau
from build123d import (
Align,
BuildPart,
BuildSketch,
Color,
Compound,
Cylinder,
Location,
Locations,
Mode,
Plane,
Polygon,
extrude,
)
from benchmark_common import polar_point, trapezoid_tooth_profile
GEAR_THICKNESS = 8.0
SUN_TEETH = 24
SUN_PITCH_DIAMETER = 48.0
SUN_ROOT_DIAMETER = 42.0
SUN_OUTSIDE_DIAMETER = 54.0
SUN_BORE_DIAMETER = 10.0
PLANET_COUNT = 3
PLANET_TEETH = 18
PLANET_PITCH_DIAMETER = 36.0
PLANET_ROOT_DIAMETER = 31.0
PLANET_OUTSIDE_DIAMETER = 41.0
PLANET_CENTER_RADIUS = 42.0
PLANET_BORE_DIAMETER = 6.8
RING_TEETH = 60
RING_INTERNAL_PITCH_DIAMETER = 120.0
RING_INTERNAL_ROOT_DIAMETER = 126.0
RING_INTERNAL_TIP_DIAMETER = 114.0
RING_OUTSIDE_DIAMETER = 140.0
CARRIER_DIAMETER = 105.0
CARRIER_BOTTOM_Z = -5.0
CARRIER_TOP_Z = -1.0
CARRIER_THICKNESS = CARRIER_TOP_Z - CARRIER_BOTTOM_Z
PIN_DIAMETER = 6.0
PIN_HEIGHT = 14.0
PIN_BOTTOM_Z = -5.0
def _make_external_gear(
*,
label: str,
teeth: int,
root_diameter: float,
outside_diameter: float,
phase: float,
center: tuple[float, float] = (0.0, 0.0),
bore_diameter: float | None = None,
color: Color | None = None,
):
with BuildPart() as gear:
with BuildSketch(Plane.XY):
Polygon(
trapezoid_tooth_profile(
teeth=teeth,
root_radius=root_diameter / 2.0,
tip_radius=outside_diameter / 2.0,
phase=phase,
),
align=None,
)
extrude(amount=GEAR_THICKNESS)
if bore_diameter is not None:
with Locations(Location((0.0, 0.0, -0.5))):
Cylinder(
radius=bore_diameter / 2.0,
height=GEAR_THICKNESS + 1.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = gear.part.moved(Location((center[0], center[1], 0.0)))
part.label = label
part.color = color
return part
def _make_internal_ring_gear():
with BuildPart() as ring:
Cylinder(
radius=RING_OUTSIDE_DIAMETER / 2.0,
height=GEAR_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with BuildSketch(Plane.XY):
Polygon(
trapezoid_tooth_profile(
teeth=RING_TEETH,
root_radius=RING_INTERNAL_ROOT_DIAMETER / 2.0,
tip_radius=RING_INTERNAL_TIP_DIAMETER / 2.0,
phase=-tau / RING_TEETH / 2.0,
root_span_fraction=0.68,
tip_span_fraction=0.34,
),
align=None,
)
extrude(amount=GEAR_THICKNESS, mode=Mode.SUBTRACT)
part = ring.part
part.label = "ring_gear_60_internal_teeth"
part.color = Color(0.66, 0.68, 0.70, 1.0)
return part
def _make_carrier_plate():
with BuildPart() as carrier:
with Locations(Location((0.0, 0.0, CARRIER_BOTTOM_Z))):
Cylinder(
radius=CARRIER_DIAMETER / 2.0,
height=CARRIER_THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = carrier.part
part.label = "carrier_plate"
part.color = Color(0.52, 0.62, 0.58, 1.0)
return part
def _make_pin(label: str, center: tuple[float, float]):
with BuildPart() as pin:
with Locations(Location((center[0], center[1], PIN_BOTTOM_Z))):
Cylinder(
radius=PIN_DIAMETER / 2.0,
height=PIN_HEIGHT,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
part = pin.part
part.label = label
part.color = Color(0.22, 0.25, 0.29, 1.0)
return part
def _planet_center(index: int) -> tuple[float, float]:
return polar_point(PLANET_CENTER_RADIUS, tau * index / PLANET_COUNT)
def gen_step():
"""Return the simplified planetary gear benchmark assembly in millimeters."""
parts = [
_make_carrier_plate(),
_make_internal_ring_gear(),
_make_external_gear(
label="sun_gear_24_external_teeth",
teeth=SUN_TEETH,
root_diameter=SUN_ROOT_DIAMETER,
outside_diameter=SUN_OUTSIDE_DIAMETER,
phase=-tau / SUN_TEETH / 2.0,
bore_diameter=SUN_BORE_DIAMETER,
color=Color(0.94, 0.60, 0.22, 1.0),
),
]
for index in range(PLANET_COUNT):
center = _planet_center(index)
parts.append(
_make_external_gear(
label=f"planet_gear_{index + 1}_18_external_teeth",
teeth=PLANET_TEETH,
root_diameter=PLANET_ROOT_DIAMETER,
outside_diameter=PLANET_OUTSIDE_DIAMETER,
phase=tau * index / PLANET_COUNT,
center=center,
bore_diameter=PLANET_BORE_DIAMETER,
color=Color(0.24, 0.51, 0.76, 1.0),
)
)
parts.append(_make_pin(f"planet_pin_{index + 1}", center))
return Compound(obj=parts, children=parts, label="benchmark_10_simplified_planetary_gear_stage")
@@ -0,0 +1,177 @@
from __future__ import annotations
from math import cos, hypot, sin, tau
from typing import Iterable
from build123d import Axis, BuildPart, Color, Location, Mode, chamfer, fillet
TOLERANCE = 1e-6
def polar_point(radius: float, angle: float) -> tuple[float, float]:
return (radius * cos(angle), radius * sin(angle))
def safe_fillet(part, edges: Iterable, radius: float):
selected = list(edges)
if not selected:
return part
try:
return fillet(selected, radius=radius)
except Exception:
return part
def safe_chamfer(part, edges: Iterable, length: float):
selected = list(edges)
if not selected:
return part
try:
return chamfer(selected, length=length)
except Exception:
return part
def line_edges_at_z(part, z_value: float, *, tol: float = 0.02):
edges = []
for edge in part.edges():
bbox = edge.bounding_box()
if abs(bbox.min.Z - z_value) <= tol and abs(bbox.max.Z - z_value) <= tol:
if str(edge.geom_type).endswith("LINE"):
edges.append(edge)
return edges
def circular_edges(
part,
*,
radius: float | None = None,
axis: str | None = None,
coordinate: float | None = None,
tol: float = 0.05,
):
edges = []
for edge in part.edges():
try:
edge_radius = edge.radius
edge_center = edge.arc_center
edge_normal = edge.normal()
except Exception:
continue
if radius is not None and abs(edge_radius - radius) > tol:
continue
if axis is not None:
components = {
"x": abs(edge_normal.X),
"y": abs(edge_normal.Y),
"z": abs(edge_normal.Z),
}
if components[axis] < 1.0 - 1e-4:
continue
if coordinate is not None:
coord = {"x": edge_center.X, "y": edge_center.Y, "z": edge_center.Z}[axis or "z"]
if abs(coord - coordinate) > tol:
continue
edges.append(edge)
return edges
def cylinder_axis_x(radius: float, length: float, center: tuple[float, float, float], *, mode=Mode.ADD):
from build123d import Align, Cylinder
return Cylinder(
radius=radius,
height=length,
rotation=(0.0, 90.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=mode,
).locate(Location(center))
def cylinder_axis_y(radius: float, length: float, center: tuple[float, float, float], *, mode=Mode.ADD):
from build123d import Align, Cylinder
return Cylinder(
radius=radius,
height=length,
rotation=(90.0, 0.0, 0.0),
align=(Align.CENTER, Align.CENTER, Align.CENTER),
mode=mode,
).locate(Location(center))
def rounded_triangle_points(
vertices: list[tuple[float, float]],
*,
radius: float,
segments: int = 5,
) -> list[tuple[float, float]]:
"""Approximate a rounded triangular loop with quadratic corner arcs."""
points: list[tuple[float, float]] = []
count = len(vertices)
for index, vertex in enumerate(vertices):
previous = vertices[(index - 1) % count]
next_vertex = vertices[(index + 1) % count]
to_previous = (previous[0] - vertex[0], previous[1] - vertex[1])
to_next = (next_vertex[0] - vertex[0], next_vertex[1] - vertex[1])
previous_length = hypot(*to_previous)
next_length = hypot(*to_next)
offset = min(radius, previous_length * 0.35, next_length * 0.35)
start = (
vertex[0] + to_previous[0] / previous_length * offset,
vertex[1] + to_previous[1] / previous_length * offset,
)
end = (
vertex[0] + to_next[0] / next_length * offset,
vertex[1] + to_next[1] / next_length * offset,
)
for step in range(segments + 1):
t = step / segments
x_pos = (1.0 - t) ** 2 * start[0] + 2.0 * (1.0 - t) * t * vertex[0] + t**2 * end[0]
y_pos = (1.0 - t) ** 2 * start[1] + 2.0 * (1.0 - t) * t * vertex[1] + t**2 * end[1]
points.append((x_pos, y_pos))
return points
def trapezoid_tooth_profile(
*,
teeth: int,
root_radius: float,
tip_radius: float,
phase: float,
root_span_fraction: float = 0.72,
tip_span_fraction: float = 0.38,
) -> list[tuple[float, float]]:
points: list[tuple[float, float]] = []
pitch_angle = tau / teeth
for tooth_index in range(teeth):
center_angle = phase + tooth_index * pitch_angle
points.extend(
(
polar_point(root_radius, center_angle - root_span_fraction * pitch_angle / 2.0),
polar_point(tip_radius, center_angle - tip_span_fraction * pitch_angle / 2.0),
polar_point(tip_radius, center_angle + tip_span_fraction * pitch_angle / 2.0),
polar_point(root_radius, center_angle + root_span_fraction * pitch_angle / 2.0),
)
)
return points
def label(part, name: str, color: Color | None = None):
part.label = name
if color is not None:
part.color = color
return part
@@ -0,0 +1,269 @@
from __future__ import annotations
from math import atan2, degrees, hypot
from pathlib import Path
from build123d import import_step
ROOT = Path(__file__).resolve().parent
BASIC_TOL = 0.2
COMPLEX_TOL = 0.5
ANGLE_TOL = 2.0
class BenchmarkFailure(AssertionError):
pass
def close(actual: float, expected: float, tol: float = BASIC_TOL) -> bool:
return abs(actual - expected) <= tol
def check(condition: bool, message: str) -> None:
if not condition:
raise BenchmarkFailure(message)
def bbox(shape):
box = shape.bounding_box()
return box.min, box.max, (
box.max.X - box.min.X,
box.max.Y - box.min.Y,
box.max.Z - box.min.Z,
)
def assert_bbox(shape, expected_size, expected_min=None, tol: float = BASIC_TOL):
min_v, max_v, size = bbox(shape)
for actual, expected, axis in zip(size, expected_size, "xyz"):
check(close(actual, expected, tol), f"bbox {axis} size {actual:.3f} != {expected:.3f}")
if expected_min is not None:
for actual, expected, axis in zip((min_v.X, min_v.Y, min_v.Z), expected_min, "xyz"):
check(close(actual, expected, tol), f"bbox min {axis} {actual:.3f} != {expected:.3f}")
def axis_name(direction) -> str:
components = {"x": abs(direction.X), "y": abs(direction.Y), "z": abs(direction.Z)}
return max(components, key=components.get)
def cyl_faces(shape, *, radius: float | None = None, axis: str | None = None, tol: float = BASIC_TOL):
faces = []
for face in shape.faces():
if "CYLINDER" not in str(face.geom_type):
continue
if radius is not None and not close(face.radius, radius, tol):
continue
if axis is not None and axis_name(face.axis_of_rotation.direction) != axis:
continue
faces.append(face)
return faces
def torus_count(shape) -> int:
return sum(1 for face in shape.faces() if "TORUS" in str(face.geom_type))
def cone_count(shape) -> int:
return sum(1 for face in shape.faces() if "CONE" in str(face.geom_type))
def cylinder_centers(shape, *, radius: float, axis: str):
centers = []
for face in cyl_faces(shape, radius=radius, axis=axis):
pos = face.axis_of_rotation.position
centers.append((pos.X, pos.Y, pos.Z))
return centers
def expect_cylinder_at(shape, *, radius: float, axis: str, x=None, y=None, z=None, tol: float = BASIC_TOL):
for center in cylinder_centers(shape, radius=radius, axis=axis):
if x is not None and not close(center[0], x, tol):
continue
if y is not None and not close(center[1], y, tol):
continue
if z is not None and not close(center[2], z, tol):
continue
return
raise BenchmarkFailure(f"missing cylinder radius {radius} axis {axis} at x={x} y={y} z={z}")
def plane_faces_at(shape, *, axis: str, coordinate: float, tol: float = BASIC_TOL):
index = "xyz".index(axis)
faces = []
for face in shape.faces():
if "PLANE" not in str(face.geom_type):
continue
box = face.bounding_box()
mins = (box.min.X, box.min.Y, box.min.Z)
maxs = (box.max.X, box.max.Y, box.max.Z)
if close(mins[index], coordinate, tol) and close(maxs[index], coordinate, tol):
faces.append(face)
return faces
def assert_common(shape, *, solids: int | None = None, min_solids: int | None = None):
check(shape.is_valid, "STEP imports but imported B-rep is invalid")
check(shape.is_manifold, "STEP imports but B-rep is non-manifold")
if solids is not None:
check(len(shape.solids()) == solids, f"solid count {len(shape.solids())} != {solids}")
if min_solids is not None:
check(len(shape.solids()) >= min_solids, f"solid count {len(shape.solids())} < {min_solids}")
def validate_01(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (100.0, 60.0, 20.0), (-50.0, -30.0, 0.0))
for x in (-35.0, 35.0):
for y in (-20.0, 20.0):
expect_cylinder_at(shape, radius=4.0, axis="z", x=x, y=y)
check(len(cyl_faces(shape, radius=4.0, axis="z")) == 4, "expected four 8 mm through holes")
top_faces = plane_faces_at(shape, axis="z", coordinate=20.0)
check(any(close(face.bounding_box().size.X, 96.0) and close(face.bounding_box().size.Y, 56.0) for face in top_faces), "top chamfered face is not inset by 2 mm")
check(cone_count(shape) == 0 and torus_count(shape) == 0, "unexpected hole chamfers/roundovers")
def validate_02(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (80.0, 80.0, 10.0), (-40.0, -40.0, 0.0))
expect_cylinder_at(shape, radius=15.0, axis="z", x=0.0, y=0.0)
bolt_centers = cylinder_centers(shape, radius=3.0, axis="z")
check(len(bolt_centers) == 6, "expected six bolt holes")
angles = sorted(degrees(atan2(y, x)) % 360.0 for x, y, _ in bolt_centers)
for x, y, _ in bolt_centers:
check(close(hypot(x, y), 30.0, COMPLEX_TOL), "bolt hole is off 60 mm bolt circle")
gaps = [(angles[(i + 1) % 6] - angles[i]) % 360.0 for i in range(6)]
check(all(close(gap, 60.0, ANGLE_TOL) for gap in gaps), "bolt holes are not spaced 60 degrees apart")
check(torus_count(shape) >= 2, "missing top/bottom outer edge fillets")
def validate_03(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (80.0, 50.0, 58.0), (-40.0, -25.0, 0.0))
for x in (-25.0, 25.0):
expect_cylinder_at(shape, radius=3.0, axis="z", x=x, y=-10.0)
expect_cylinder_at(shape, radius=3.0, axis="y", x=x, z=30.0)
check(len(cyl_faces(shape, radius=3.0, axis="z")) == 2, "base hole count is not two")
check(len(cyl_faces(shape, radius=3.0, axis="y")) == 2, "back plate hole count is not two")
check(len(shape.faces()) >= 20, "missing gusset/fillet detail")
def validate_04(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (120.0, 30.0, 30.0), (0.0, -15.0, -15.0))
check(len(cyl_faces(shape, radius=10.0, axis="x")) == 2, "expected two 20 mm shaft sections")
check(len(cyl_faces(shape, radius=15.0, axis="x")) == 1, "expected one 30 mm shaft section")
keyway_planes = plane_faces_at(shape, axis="z", coordinate=12.0)
check(any(close(face.bounding_box().size.X, 40.0) and close(face.bounding_box().size.Y, 6.0) for face in keyway_planes), "keyway floor is not 40 x 6 mm at 3 mm depth")
check(cone_count(shape) >= 2, "missing 1 mm end chamfers")
def validate_05(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (100.0, 70.0, 30.0), (-50.0, -35.0, 0.0))
check(any(close(face.bounding_box().size.X, 94.0) and close(face.bounding_box().size.Y, 64.0) for face in plane_faces_at(shape, axis="z", coordinate=3.0)), "inside floor/wall thickness check failed")
for x in (-35.0, 35.0):
for y in (-25.0, 25.0):
expect_cylinder_at(shape, radius=5.0, axis="z", x=x, y=y)
expect_cylinder_at(shape, radius=1.5, axis="z", x=x, y=y)
check(len(cyl_faces(shape, radius=5.0, axis="z")) == 4, "standoff count is not four")
check(len(cyl_faces(shape, radius=1.5, axis="z")) == 4, "blind hole count is not four")
check(len(cyl_faces(shape, radius=2.0, axis="z")) >= 4, "missing 2 mm outside vertical corner fillets")
def validate_06(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (120.0, 60.0, 52.0), (-60.0, -30.0, 0.0))
expect_cylinder_at(shape, radius=7.0, axis="y", x=0.0, z=34.0)
for x in (-45.0, 45.0):
for y in (-20.0, 20.0):
expect_cylinder_at(shape, radius=3.5, axis="z", x=x, y=y)
check(len(cyl_faces(shape, radius=7.0, axis="y")) == 2, "clevis through-hole should pass through two separate lugs")
check(len(cyl_faces(shape, radius=3.5, axis="z")) == 4, "base mounting hole count is not four")
check(len(cyl_faces(shape, radius=2.0, axis="x")) >= 2, "missing lug-to-base radius detail")
check(len(shape.faces()) >= 90, "missing cutout/rib/lug detail")
def validate_07(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (79.1, 70.0, 92.7), (-35.0, -35.0, 0.0), tol=COMPLEX_TOL)
check(len(cyl_faces(shape, radius=31.0, axis="z")) == 12, "cooling fin count is not 12")
check(len(cyl_faces(shape, radius=2.5, axis="z")) == 6, "mounting hole count is not six")
check(len(cyl_faces(shape, radius=18.0, axis="z")) >= 1, "missing 36 mm barrel")
check(len(cyl_faces(shape, radius=22.0, axis="z")) == 1, "missing 44 mm top cap")
boss = cyl_faces(shape, radius=6.0)
bore = [face for face in cyl_faces(shape, radius=2.5) if axis_name(face.axis_of_rotation.direction) == "x"]
check(len(boss) == 1 and len(bore) == 1, "missing angled boss or boss bore")
for face in (boss[0], bore[0]):
direction = face.axis_of_rotation.direction
angle = degrees(atan2(abs(direction.Z), abs(direction.X)))
check(close(angle, 35.0, ANGLE_TOL), "boss axis is not 35 degrees upward")
check(torus_count(shape) >= 24, "missing fin/flange edge fillets")
def validate_08(shape):
assert_common(shape, solids=1)
assert_bbox(shape, (90.0, 90.0, 28.0), (-45.0, -45.0, 0.0), tol=COMPLEX_TOL)
expect_cylinder_at(shape, radius=4.0, axis="z", x=0.0, y=0.0)
check(len(cyl_faces(shape, radius=13.0, axis="z")) == 1, "missing 26 mm hub")
check(len(shape.faces()) >= 220, "missing repeated curved blade detail")
check(torus_count(shape) >= 2, "missing backplate edge fillets")
def validate_09(shape):
assert_common(shape, min_solids=43)
assert_bbox(shape, (137.0, 137.0, 140.0), (-68.5, -68.5, 0.0), tol=COMPLEX_TOL)
check(len(cyl_faces(shape, radius=7.0, axis="z")) == 1, "missing central column")
check(len(cyl_faces(shape, radius=45.0, axis="z")) == 1, "missing base disk")
check(len(cyl_faces(shape, radius=62.0, axis="z")) == 20, "tread outer radius/count failed")
check(len(cyl_faces(shape, radius=10.0, axis="z")) == 20, "tread inner radius/count failed")
check(len(cyl_faces(shape, radius=1.5, axis="z")) == 20, "baluster count failed")
check(len(shape.solids()) == 43, "staircase assembly should have 43 solids")
def validate_10(shape):
assert_common(shape, solids=9)
assert_bbox(shape, (140.0, 140.0, 14.0), (-70.0, -70.0, -5.0), tol=COMPLEX_TOL)
check(len(cyl_faces(shape, radius=3.0, axis="z")) == 3, "planet pin count failed")
check(len(cyl_faces(shape, radius=5.0, axis="z")) == 1, "sun bore failed")
check(len(shape.faces()) >= 550 and len(shape.edges()) >= 1600, "gear tooth detail is missing")
VALIDATORS = [
("1. Rectangular calibration block with four holes", "benchmark_01_rectangular_calibration_block.step", validate_01),
("2. Circular flange with bolt-hole pattern", "benchmark_02_circular_flange.step", validate_02),
("3. L-bracket with gussets and two hole directions", "benchmark_03_l_bracket.step", validate_03),
("4. Stepped shaft with keyway", "benchmark_04_stepped_shaft_keyway.step", validate_04),
("5. Open-top electronics enclosure with bosses", "benchmark_05_open_top_electronics_enclosure.step", validate_05),
("6. Aerospace-style clevis bracket with lightening cutouts", "benchmark_06_clevis_bracket_lightening_cutouts.step", validate_06),
("7. Radial-engine-style cylinder with cooling fins", "benchmark_07_radial_engine_cylinder.step", validate_07),
("8. Centrifugal impeller with backward-curved blades", "benchmark_08_centrifugal_impeller.step", validate_08),
("9. Spiral staircase with helical handrail", "benchmark_09_spiral_staircase.step", validate_09),
("10. Simplified planetary gear stage", "benchmark_10_planetary_gear_stage.step", validate_10),
]
def main() -> int:
failures = []
for title, filename, validator in VALIDATORS:
step_path = ROOT / filename
try:
shape = import_step(step_path)
validator(shape)
except Exception as exc:
failures.append((title, exc))
print(f"FAIL {title}: {exc}")
else:
print(f"PASS {title}")
if failures:
print(f"\n{len(failures)} benchmark(s) failed.")
return 1
print("\nAll benchmark checks passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,94 @@
{
"schema_version": "1.0",
"request": "生成中心距120mm、大头孔36mm、小头孔20mm、厚14mm的发动机连杆,导出STEP和STL,后续可以修改参数",
"route": {
"selected_backend": "build123d",
"runner_skill": "cad",
"confidence": "high",
"fallback_order": [
"simplecadapi",
"cadam"
],
"decision_path": "route-decision.json"
},
"source": {
"path": "connecting_rod.py",
"format": "python",
"backend": "build123d"
},
"artifacts": [
{
"path": "connecting_rod.step",
"role": "primary"
},
{
"path": "connecting_rod.stl",
"role": "secondary"
},
{
"path": "connecting_rod_20260722T071141Z.png",
"role": "verification_snapshot"
}
],
"parameters": {
"center_distance_mm": 120.0,
"thickness_mm": 14.0,
"big_end_outer_diameter_mm": 56.0,
"big_end_bore_diameter_mm": 36.0,
"small_end_outer_diameter_mm": 36.0,
"small_end_bore_diameter_mm": 20.0,
"web_width_mm": 24.0,
"pocket_depth_each_side_mm": 3.0
},
"assumptions": [
"This V1 sample demonstrates routing and editability rather than a production-rated engine component.",
"Material, fatigue loads, bearing fits, lubrication, fasteners, tolerances, and split big-end construction are intentionally out of scope."
],
"validation": [
{
"check": "closed solid count",
"result": 1,
"status": "pass"
},
{
"check": "bounding box millimeters",
"result": [
166.0,
56.0,
14.0
],
"status": "pass"
},
{
"check": "volume cubic millimeters",
"result": 50729.813,
"status": "pass"
},
{
"check": "bore radii millimeters",
"result": [
10.0,
18.0
],
"status": "pass"
},
{
"check": "STEP topology",
"result": {
"shape_count": 1,
"face_count": 18,
"edge_count": 42,
"warnings": []
},
"status": "pass"
},
{
"check": "isometric snapshot review",
"result": "Both bores, the connected web, and the top pocket are visibly present; no disconnected body is visible.",
"status": "pass"
}
],
"viewer_links": [
"http://127.0.0.1:4178/?dir=%2FUsers%2Fjerry%2Flinkhand%2FCadSet%2Ftext-to-cad%2Fmodels%2Fcad-router-v1&file=connecting_rod.step"
]
}
@@ -0,0 +1,59 @@
{
"schema_version": "1.0",
"request": "Generate a parameterized perforated vase for 3D printing",
"route": {
"selected_backend": "cadam",
"project": "CADAM",
"runner_skill": "cad-router",
"adapter": "skills/cad-router/scripts/cadam_compile.mjs",
"fallback_order": [
"build123d",
"simplecadapi"
],
"workflow_profiles": [
"caddesigner_ecip"
]
},
"source": {
"path": "cadam_parametric_vase.scad",
"format": "scad",
"backend": "cadam"
},
"artifacts": [
{
"path": "cadam_parametric_vase.stl",
"role": "primary"
}
],
"parameters": {
"height_mm": 80,
"base_radius_mm": 30,
"top_radius_mm": 24,
"wall_mm": 2.4,
"base_mm": 3
},
"assumptions": [
"The repeated perforations are decorative and are not load-bearing features."
],
"validation": [
{
"check": "CADAM OpenSCAD WASM compile",
"status": "passed",
"details": "exit code 0; manifold backend reported NoError; 3816 vertices and 7868 facets"
},
{
"check": "CAD Viewer review",
"status": "passed",
"details": "STL loaded from the task-scoped cad-router-v1 directory"
}
],
"project_contributions": {
"text-to-cad": "router, artifact contract, validation, and CAD Viewer",
"SimpleCADAPI": "available specialist mechanical fallback",
"CADAM": "SCAD source and OpenSCAD-WASM mesh execution",
"CADDesigner-Code": "ECIP brief and bounded execution/visual-repair workflow"
},
"viewer_links": [
"http://127.0.0.1:4178/?dir=%2FUsers%2Fjerry%2Flinkhand%2FCadSet%2Ftext-to-cad%2Fmodels%2Fcad-router-v1&file=cadam_parametric_vase.stl"
]
}
@@ -0,0 +1,27 @@
// CADAM/OpenSCAD-WASM route sample for CAD Router V1.
$fn = 96;
height = 80;
base_radius = 30;
top_radius = 24;
wall = 2.4;
base = 3;
difference() {
cylinder(h = height, r1 = base_radius, r2 = top_radius);
translate([0, 0, base])
cylinder(
h = height,
r1 = base_radius - wall,
r2 = top_radius - wall
);
for (z = [14 : 14 : 70]) {
for (angle = [0 : 30 : 330]) {
rotate([0, 0, angle + z * 0.75])
translate([base_radius - 1.0 - z * 0.07, 0, z])
rotate([0, 90, 0])
cylinder(h = wall + 5, r = 2.2, center = true, $fn = 24);
}
}
}
@@ -0,0 +1,72 @@
"""Parametric V1 connecting-rod sample routed to the build123d backend."""
from build123d import Align, Box, BuildPart, BuildSketch, Cylinder, Location, Locations, Mode, Plane, SlotOverall, extrude
CENTER_DISTANCE = 120.0
THICKNESS = 14.0
BIG_END_OUTER_DIAMETER = 56.0
BIG_END_BORE_DIAMETER = 36.0
SMALL_END_OUTER_DIAMETER = 36.0
SMALL_END_BORE_DIAMETER = 20.0
WEB_WIDTH = 24.0
POCKET_LENGTH = 72.0
POCKET_WIDTH = 12.0
POCKET_DEPTH = 3.0
def gen_step():
"""Return a closed, STEP-ready connecting-rod solid in millimeters."""
half_distance = CENTER_DISTANCE / 2.0
with BuildPart() as rod:
with Locations(Location((-half_distance, 0.0, 0.0))):
Cylinder(
radius=BIG_END_OUTER_DIAMETER / 2.0,
height=THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((half_distance, 0.0, 0.0))):
Cylinder(
radius=SMALL_END_OUTER_DIAMETER / 2.0,
height=THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
Box(
CENTER_DISTANCE,
WEB_WIDTH,
THICKNESS,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((-half_distance, 0.0, -1.0))):
Cylinder(
radius=BIG_END_BORE_DIAMETER / 2.0,
height=THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with Locations(Location((half_distance, 0.0, -1.0))):
Cylinder(
radius=SMALL_END_BORE_DIAMETER / 2.0,
height=THICKNESS + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
with BuildSketch(Plane.XY):
SlotOverall(width=POCKET_LENGTH, height=POCKET_WIDTH)
extrude(amount=POCKET_DEPTH, mode=Mode.SUBTRACT)
with BuildSketch(Plane.XY.offset(THICKNESS - POCKET_DEPTH)):
SlotOverall(width=POCKET_LENGTH, height=POCKET_WIDTH)
extrude(amount=POCKET_DEPTH, mode=Mode.SUBTRACT)
result = rod.part
result.label = "cad_router_v1_connecting_rod"
return result
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

@@ -0,0 +1,69 @@
{
"schema_version": "1.0",
"request": "参考图片生成一个8孔圆法兰,中心孔直径20毫米,外径50毫米,厚度4毫米,螺丝孔使用M3",
"route": {
"selected_backend": "build123d",
"runner_skill": "cad",
"project": "text-to-cad",
"fallback_order": [
"simplecadapi",
"cadam"
],
"workflow_profiles": [
"caddesigner_ecip",
"requirement_refinement",
"visual_repair"
],
"downstream_skills": [
"cad-viewer"
]
},
"source": {
"path": "flange_8x_m3.py",
"format": "python",
"backend": "build123d"
},
"artifacts": [
{
"path": "flange_8x_m3.step",
"role": "primary"
},
{
"path": "flange_8x_m3.stl",
"role": "secondary"
}
],
"parameters": {
"outer_diameter_mm": 50.0,
"inner_diameter_mm": 20.0,
"thickness_mm": 4.0,
"bolt_hole_count": 8,
"bolt_hole_diameter_mm": 3.4,
"bolt_circle_diameter_mm": 40.0,
"angular_spacing_deg": 45.0
},
"assumptions": [
"M3螺丝孔按普通间隙通孔处理,孔径采用3.4毫米,不是M3攻丝底孔。",
"未指定分度圆,采用40毫米分度圆,以保持孔到外圆约3.3毫米的最小径向边距。",
"参考图中的车削纹理作为外观参考,不生成削弱4毫米薄法兰的深槽。"
],
"validation": [
{
"path": "validation.json",
"status": "passed",
"checks": {
"step_valid": true,
"solid_count": 1,
"exact_size_mm": [50.0, 50.0, 4.0],
"m3_clearance_hole_count": 8,
"m3_clearance_hole_diameter_mm": 3.4,
"bolt_circle_diameter_mm": 40.0,
"angular_spacing_deg": 45.0,
"visual_review": "passed_iso_opposite_top_front"
}
}
],
"viewer_links": [
"http://127.0.0.1:4178/?dir=%2FUsers%2Fjerry%2Flinkhand%2FCadSet%2Ftext-to-cad%2Fmodels%2Fcad-router-v1%2Fflange_8x_m3&file=flange_8x_m3.step"
]
}
@@ -0,0 +1,51 @@
"""Parametric eight-hole circular flange generated with build123d."""
from math import cos, radians, sin
from build123d import Align, BuildPart, Cylinder, Location, Locations, Mode
OUTER_DIAMETER_MM = 50.0
INNER_DIAMETER_MM = 20.0
THICKNESS_MM = 4.0
BOLT_HOLE_COUNT = 8
M3_CLEARANCE_DIAMETER_MM = 3.4
BOLT_CIRCLE_DIAMETER_MM = 40.0
def polar_point(radius: float, angle_degrees: float) -> tuple[float, float]:
angle = radians(angle_degrees)
return radius * cos(angle), radius * sin(angle)
def gen_step():
"""Return one STEP-ready flange solid in millimeters."""
with BuildPart() as flange:
Cylinder(
radius=OUTER_DIAMETER_MM / 2.0,
height=THICKNESS_MM,
align=(Align.CENTER, Align.CENTER, Align.MIN),
)
with Locations(Location((0.0, 0.0, -1.0))):
Cylinder(
radius=INNER_DIAMETER_MM / 2.0,
height=THICKNESS_MM + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
for index in range(BOLT_HOLE_COUNT):
angle_degrees = 360.0 * index / BOLT_HOLE_COUNT
x_pos, y_pos = polar_point(BOLT_CIRCLE_DIAMETER_MM / 2.0, angle_degrees)
with Locations(Location((x_pos, y_pos, -1.0))):
Cylinder(
radius=M3_CLEARANCE_DIAMETER_MM / 2.0,
height=THICKNESS_MM + 2.0,
align=(Align.CENTER, Align.CENTER, Align.MIN),
mode=Mode.SUBTRACT,
)
part = flange.part
part.label = "flange_8x_m3_clearance"
return part
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Some files were not shown because too many files have changed in this diff Show More