first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 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.
+205
View File
@@ -0,0 +1,205 @@
<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 | 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,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f3cde0eb314678b6a1f75cee81e1610d2721bee7707a3133fb2f0d9e8c4857b2
size 17437589
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:375f372fe95a513c247fd17d99107e197af24becfc05ce1221f72581856a1a19
size 26292905
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f0c4c65995c6057d80150586362e010a0bb43c38e173b5bed48fa3b7323bbed
size 17952952
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:832fc617575f044d72d7254ed4097ce4ccbb8307c3951da07aa6f0b7abae8f3f
size 5357128
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed92399e0203a795fdd65e7bf64ec765af673ae46891aa3a259668cd07c1edd4
size 5692191
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6c71b9b339c1f53814e398d61ada64e5b2074825e07abe52d4cb5245e5f6145a
size 3543380
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:361bdab7bc7abedc822778aa7b4a1a642bfaa4dec00a2f06cfc88473fba0b345
size 5110970
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:deaa3b8811253a32a6f384305859b1e9a7095fa0dd21db17f8788d96c04c35f8
size 6737267
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:27db600bf49abf9b80932512d5fd3b2d45b5cf01811edaa851033b20d57a68cb
size 5301114
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9090ac1a68d49c15235dd1dcf4b5886c3169f15553fe8b8c86af11c59b55480b
size 5649071
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:85182301f90d963a2999d0681ce64716f42132d3eab4a78b2c33cb8fda1091a9
size 7840205
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cdad8124de0f405cc3cc41250d73d02c54763a344a418eb31d27c6c38da5c8a7
size 4217322
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e5ba4f0f1a1ba61f92128e6b6dc5f07b12139ca1a9ce64b973ae101d2336df38
size 7102814
+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;
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>
);
}
+352
View File
@@ -0,0 +1,352 @@
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",
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 }
+34
View File
@@ -0,0 +1,34 @@
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",
"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"
}
+13
View File
@@ -0,0 +1,13 @@
# Shared Packages
Reusable code that must be available to more than one skill lives here as a
normal Python package. Skills should import the package they bundle, not sibling
skill source directories.
Small shared runtime helpers such as `cadpy_metadata` should still use this
pattern: source lives once under `packages/`, then each consuming skill gets its
own generated `scripts/packages/<package>` copy.
For local development, install packages editable into the repo venv. For
production packaging, build wheels or install into a skill-local Python target
such as `vendor/python`.
+87
View File
@@ -0,0 +1,87 @@
# cadjs
`cadjs` is the shared JavaScript runtime for CAD Viewer, documentation previews,
and generated snapshot browser assets. It owns reusable parsing, scene-building,
explicit-CAD rendering, STEP topology, selector, mesh, robot-description, and
artifact helpers without depending on React or application chrome.
Implicit CAD model loading, shader rendering, snapshots, SDF sampling, and
mesh exports live in the sibling `implicitjs` package. Consumers that need
implicit CAD should depend on `implicitjs` directly alongside `cadjs`.
## Install
In this workbench, consumers link the package directly:
```json
{
"dependencies": {
"cadjs": "file:../packages/cadjs"
}
}
```
The package exports source files so local consumers can alias or install it and
pick up edits without copying generated bundles.
## Layout
- `src/index.js`: public package entrypoint.
- `src/common/`: browser-safe render pipeline used by interactive viewer,
docs previews, and snapshot browser runtime.
- `src/lib/`: lower-level first-party helpers for assets, formats, mesh
decoding, selector runtimes, STEP sidecars, and URDF/SRDF/SDF.
- `src/lib/viewer/`: shared non-React 3D viewer runtime helpers such as picking,
clipping, drawing geometry, reference geometry, stage theme, scene scale, and
visual state.
- `scripts/run-tests.mjs`: package test discovery and Node test runner wrapper.
- `docs/`: reference docs for package-owned APIs.
Tests live beside the modules they cover as `*.test.js` or `*.test.mjs`.
## Boundaries
Keep this package UI-framework agnostic. Do not add React components, Tailwind
helpers, browser workbench state, or CAD Viewer chrome utilities here. Those
belong in consumer apps such as `viewer/`.
Prefer moving logic into `cadjs` when it is reusable across:
- interactive CAD Viewer rendering,
- docs or marketing previews,
- generated snapshot browser runtime,
- testable CAD parsing or sidecar preparation.
Prefer moving implicit model, shader, snapshot, SDF, or mesh-export behavior
into `implicitjs` instead.
Keep app-specific workflows in the app. A useful rule of thumb: `cadjs` should
understand CAD data and rendering state, while `viewer/` should understand user
interface and product workflow.
## Commands
From this package directory:
```bash
npm test
```
From the workbench repository root:
```bash
npm --prefix packages/cadjs test
```
The test command discovers all `*.test.js` and `*.test.mjs` files under
`src/`. To run one file:
```bash
node scripts/run-tests.mjs src/lib/themeSettings.test.js
```
## Reference
- [Render pipeline](./docs/render-pipeline.md): `loadSource`, `buildModel`,
`renderModel`, and `captureModel` stages shared by viewer, docs, and
snapshots.
@@ -0,0 +1,362 @@
# Render Pipeline
`cadjs` exposes a staged render pipeline for shared viewer, docs, and generated
snapshot browser-runtime work:
```js
const source = await loadSource(input, sourceOptions);
const model = buildModel(THREE, source, modelOptions);
const viewport = renderModel(THREE, model, viewportOptions);
const result = await captureModel(viewport, captureOptions);
```
The stages keep ownership narrow:
- `loadSource` owns source and sidecar loading plus file-kind validation.
- `buildModel` owns the CAD object graph, records, selection, clipping,
materials, topology/display edges, and STEP parameter effects.
- `renderModel` owns renderer, scene, camera, lighting, background, floor,
framing, resizing, and render loop concerns.
- `captureModel` owns deterministic snapshot outputs without filesystem writes.
The CAD skill's Python snapshot CLI remains responsible for job parsing, path
resolution, Playwright routing, and writing returned outputs to disk.
## Modules
### `common/source.js`
```js
import {
loadSource,
stepParameterFrameRuntime
} from "cadjs/common/source.js";
```
`loadSource(input, options)` returns a normalized render source:
```js
{
kind,
meshData,
selectorRuntime,
displayEdgeRuntime,
stepParameterSource,
resolved,
url,
glbUrl,
cadPath
}
```
Accepted input fields:
- `kind`: `step`, `stp`, `glb`, `stl`, `3mf`, or inferred from a URL.
- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.
- `url`: source URL for non-STEP GLB loading.
- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.
- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.
- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller
already owns sidecar loading.
- `stepParameters`: raw STEP render values or animation envelope.
- `stepParameterUrl` or `resolved.stepParameterUrl`: `.step.js`/`.stp.js`
parameter sidecar URL.
STEP-only options are rejected for non-STEP sources. The old shared `params`
field is rejected; use `stepParameters`.
Use `stepParameterFrameRuntime(stepParameterSource, frameIndex)` to turn the
loaded STEP parameter source into the runtime object accepted by `buildModel`
for a still or animation frame.
### `common/cadScene.js`
```js
import {
buildModel,
fitCameraToModel
} from "cadjs/common/cadScene.js";
```
`buildModel(THREE, source, settings)` returns a model API:
```js
{
source,
meshData,
root,
modelGroup,
edgesGroup,
displayRecords,
records,
bounds,
radius,
runtime,
update(nextSettings),
dispose()
}
```
`source` can be a `loadSource()` result or raw mesh data. The model owns the
Three.js object graph and its mutable state.
Common settings:
- `theme`: normalized or raw theme settings.
- `displayMode`: `solid`, `rendered`, `transparent`, `hidden_edges`,
`hidden_lines_removed`, `unshaded`, or `wireframe`.
- `scale`/`sceneScale`: CAD or robot scene scale.
- `selection`: internal selection/filtering state. `focus`, `refs`, and `hide`
filter rendered parts before records are built. Viewer-only fields such as
`selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.
- `clip`: normalized clip-plane settings.
- `stepParameters`: STEP parameter runtime object, usually from
`stepParameterFrameRuntime()`.
- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.
- `renderPartsIndividually`: build per-part records instead of a whole mesh.
- `edgeRendering`: declarative edge rendering configuration.
Declarative screen-space edge rendering:
```js
buildModel(THREE, source, {
edgeRendering: {
mode: "screen-space",
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
wireframeEdgeColor: "#111827"
}
});
```
The model keeps screen-space line material bookkeeping internal through
`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.
Callers should not provide callbacks that create edge objects.
`model.update(nextSettings)` merges mutable settings, rebuilds geometry only
when needed, reapplies material/selection/clip/STEP parameter state, and returns
the same model API. `model.dispose()` releases model-owned scene objects and
STEP parameter cleanup hooks.
`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic
camera framing helper used by interactive rendering.
### `common/renderModel.js`
Use this module for interactive browser canvases, including the docs hero.
```js
import { renderModel } from "cadjs/common/renderModel.js";
```
`renderModel(THREE, model, options)` returns an interactive viewport API:
```js
{
THREE,
model,
renderer,
scene,
camera,
ready,
resize(),
render(),
start(),
stop(),
capturePng(),
dispose()
}
```
Common options:
- `canvas`: existing canvas for the renderer.
- `hostElement`/`container`: element used for responsive sizing.
- `renderer`: caller-owned renderer. If omitted, one is created.
- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are
created.
- `theme`/`themeSettings`: background and lighting settings.
- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,
`logarithmicDepthBuffer`, `shadows`: renderer controls.
- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.
- `pixelRatio`, `maxPixelRatio`: output density controls.
- `autoResize`: set `false` to disable `ResizeObserver`.
- `autoStart`: set `true` to start an animation loop.
- `autoRender`: set `false` to prevent the initial render.
- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.
- `disposeModel`: set `false` when the caller will dispose the model.
`dispose()` stops animation, disconnects resize observation, removes the model
root from the scene, and disposes the created renderer/model unless ownership
was explicitly retained by options.
### `common/renderMeshScene.js`
Use this module for deterministic headless snapshot rendering.
```js
import {
renderJobContext,
modelOptionsForRenderJob,
renderModel,
captureModel,
renderMeshJob
} from "cadjs/common/renderMeshScene.js";
```
`renderJobContext(meshData, job)` normalizes snapshot-owned render policy:
appearance, display, scene scale, outputs, STEP topology edge visibility, and
warnings.
`modelOptionsForRenderJob(context, job)` converts that policy into
`buildModel()` settings.
Snapshot `renderModel(THREE, model, { job, context })` returns a headless
viewport:
```js
{
THREE,
model,
scene,
renderer,
orthographicCamera,
perspectiveCamera,
context,
sceneBuildStarted,
ready,
dispose()
}
```
This `renderModel` is intentionally separate from `common/renderModel.js`.
It uses snapshot sizing, theme environment, floor, orthographic/perspective
camera presets, and deterministic renderer settings.
`captureModel(viewport, { job })` returns data only:
- `mode: "view"`: PNG data URLs in `outputs`.
- `mode: "orbit"`: the caller composes frames into GIF output.
- `mode: "section"`: PNG data URLs or SVG text in `outputs`.
- `mode: "list"`: part list and bounds.
It does not write files. The CAD skill snapshot CLI writes the returned data to
disk. Source checkouts use `packages/cadjs`; generated snapshot browser assets
bundle this entrypoint into `skills/cad/scripts/snapshot/runtime`.
`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,
builds a model, renders/captures it, and disposes owned resources.
## STEP Parameters
Shared render APIs use the name `stepParameters`.
Raw render values can be direct parameter values:
```json
{
"drive": 180,
"ringVisible": false
}
```
Or an animation envelope:
```json
{
"values": {
"ringVisible": true
},
"animate": {
"drive": { "from": 0, "to": 1260 }
},
"durationSeconds": 6,
"fps": 18,
"loop": true
}
```
`common/stepParameters.js` validates these values against the loaded STEP
parameter sidecar schema, normalizes defaults, and computes per-frame values.
`loadSource()` uses it to populate `source.stepParameterSource`; callers can
then use `stepParameterFrameRuntime()` when passing parameters into
`buildModel()`.
## Examples
Interactive viewer/docs usage:
```js
import * as THREE from "three";
import { loadSource, stepParameterFrameRuntime } from "cadjs/common/source.js";
import { buildModel } from "cadjs/common/cadScene.js";
import { renderModel } from "cadjs/common/renderModel.js";
const source = await loadSource({
kind: "step",
glbUrl: "/models/.part.step.glb",
stepParameterUrl: "/models/.part.step.js",
cadPath: "models/part.step",
stepParameters: { drive: 180 }
});
const model = buildModel(THREE, source, {
theme,
displayMode: "solid",
stepParameters: stepParameterFrameRuntime(source.stepParameterSource, 0)
});
const viewport = renderModel(THREE, model, {
canvas,
hostElement: canvas.parentElement,
theme,
autoStart: true
});
```
Headless snapshot usage:
```js
import * as THREE from "three";
import { loadSource } from "cadjs/common/source.js";
import { buildModel } from "cadjs/common/cadScene.js";
import {
captureModel,
modelOptionsForRenderJob,
renderJobContext,
renderModel
} from "cadjs/common/renderMeshScene.js";
const source = await loadSource(job);
const context = renderJobContext(source.meshData, job);
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
const viewport = renderModel(THREE, model, { job, context });
try {
const result = await captureModel(viewport, { job });
// Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.
} finally {
viewport.dispose();
}
```
Orbit GIF jobs default to 6 fps over 12 seconds for a calmer review spin
without increasing default render frame count.
Override with `orbit.fps` and `orbit.durationSeconds` when a shorter or denser
render is needed.
## Ownership Rules
- Do not write files from shared render APIs. Return data to the owning CLI or
application layer.
- Do not expose object-construction callbacks for edges. Use declarative
`edgeRendering`.
- Keep STEP-only options explicitly STEP-named and reject them for non-STEP
sources.
- Dispose viewports and models that you create.
- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new
shared render code instead of loading assets or constructing render scenes
inline.
+28
View File
@@ -0,0 +1,28 @@
{
"name": "cadjs",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cadjs",
"version": "0.3.9",
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/three": {
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
"license": "MIT"
}
}
}
@@ -0,0 +1,23 @@
{
"name": "cadjs",
"version": "0.3.9",
"private": true,
"type": "module",
"description": "Reusable CAD render/viewer runtime for docs, CAD Viewer, and generated snapshot browser assets.",
"exports": {
".": "./src/index.js",
"./common/*": "./src/common/*",
"./lib/*": "./src/lib/*"
},
"scripts": {
"test": "node scripts/run-tests.mjs"
},
"sideEffects": [
"./src/common/headlessRenderEntry.js",
"./src/common/render.html"
],
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(packageRoot, "..", "..");
const sourceRoot = path.join(packageRoot, "src");
function collectTests(dir, tests = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectTests(entryPath, tests);
} else if (/\.test\.[cm]?js$/u.test(entry.name)) {
tests.push(entryPath);
}
}
return tests;
}
const requestedTests = process.argv.slice(2).map((testPath) => path.resolve(packageRoot, testPath));
const tests = (requestedTests.length ? requestedTests : collectTests(sourceRoot)).sort();
if (!tests.length) {
console.error("No cadjs tests found.");
process.exit(1);
}
const result = spawnSync(process.execPath, [
"--test",
"--experimental-default-type=module",
...tests,
], {
cwd: packageRoot,
env: {
...process.env,
...(fs.existsSync(path.join(repoRoot, ".venv", "bin", "python"))
? { CAD_PYTHON: path.join(repoRoot, ".venv", "bin", "python") }
: {}),
...(fs.existsSync(path.join(repoRoot, "packages", "cadpy", "src"))
? { CAD_PYTHONPATH: path.join(repoRoot, "packages", "cadpy", "src") }
: {}),
},
stdio: "inherit",
});
process.exit(result.status ?? 1);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,730 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import {
applyPartVisualState,
buildModel,
CAD_DISPLAY_MODE,
normalizeDisplayMode
} from "./cadScene.js";
import {
DEFAULT_DISPLAY_EDGE_SETTINGS
} from "./displaySettings.js";
import {
cloneThemeSettings
} from "./themeSettings.js";
function sampleMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [3, 1, 0]
},
parts: [
{
id: "left",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "right",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
}
]
};
}
function nestedAssemblyMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0,
10, 0, 0,
11, 0, 0,
10, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [11, 1, 0]
},
parts: [
{
id: "o1.2.1",
occurrenceId: "o1.2.1",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "o1.2.2",
occurrenceId: "o1.2.2",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
},
{
id: "o1.3",
occurrenceId: "o1.3",
vertexOffset: 6,
vertexCount: 3,
triangleOffset: 2,
triangleCount: 1,
bounds: { min: [10, 0, 0], max: [11, 1, 0] }
}
]
};
}
function createDisplayRecord(partId, {
baseOpacity = 1
} = {}) {
const material = new THREE.MeshStandardMaterial({
color: "#aaaaaa",
emissive: "#000000",
transparent: false,
opacity: 1
});
const edgeMaterial = new THREE.LineBasicMaterial({
color: "#222222",
transparent: true,
opacity: 1
});
return {
partId,
mesh: { visible: true, renderOrder: 2 },
edges: { visible: true, renderOrder: 3 },
material,
edgeMaterial,
baseOpacity,
baseColor: new THREE.Color("#aaaaaa"),
baseEmissiveColor: new THREE.Color("#000000"),
baseEmissiveIntensity: 0
};
}
function squareMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
1, 1, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
},
parts: []
};
}
function edgeSegmentCount(record) {
return Math.floor((record?.edges?.geometry?.getAttribute("position")?.count || 0) / 2);
}
test("applyPartVisualState keeps dimmed context from depth-occluding highlights", () => {
const dimmed = createDisplayRecord("dimmed");
const selected = createDisplayRecord("selected");
applyPartVisualState(THREE, [dimmed, selected], {
baseTheme: {
edge: "#111111",
edgeOpacity: 0.5
},
edgeSettings: {
opacity: 0.5
},
hiddenPartIds: [],
hoveredPartId: "",
focusedPartId: ["selected"],
selectedPartIds: ["selected"],
showEdges: true
});
assert.equal(dimmed.material.transparent, true);
assert.equal(dimmed.material.depthWrite, false);
assert.equal(dimmed.mesh.renderOrder, 2);
assert.equal(dimmed.edges.renderOrder, 3);
assert.equal(selected.material.transparent, true);
assert.equal(selected.material.depthWrite, true);
assert.equal(selected.mesh.renderOrder, 23);
assert.equal(selected.edges.renderOrder, 26);
applyPartVisualState(THREE, [selected], {
baseTheme: {},
edgeSettings: {},
hiddenPartIds: [],
hoveredPartId: "",
focusedPartId: [],
selectedPartIds: [],
showEdges: true
});
assert.equal(selected.material.transparent, false);
assert.equal(selected.material.depthWrite, true);
assert.equal(selected.mesh.renderOrder, 2);
assert.equal(selected.edges.renderOrder, 3);
});
test("buildModel renders solid part records and updates theme without rebuilding geometry", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme,
renderPartsIndividually: true
});
const firstMesh = scene.displayRecords[0].mesh;
const firstGeometry = firstMesh.geometry;
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].partId, "left");
assert.equal(scene.displayRecords[0].edges.visible, true);
assert.equal(scene.displayRecords[0].mesh.castShadow, true);
assert.equal(scene.displayRecords[0].mesh.receiveShadow, false);
scene.update({
theme: {
...theme,
materials: {
...theme.materials,
defaultColor: "#ff0000",
fillColors: ["#ff0000"]
}
}
});
assert.equal(scene.displayRecords[0].mesh, firstMesh);
assert.equal(scene.displayRecords[0].mesh.geometry, firstGeometry);
assert.equal(scene.displayRecords[0].material.color.getHexString(), "ff0000");
scene.dispose();
});
test("buildModel keeps source-mesh color buffers immutable across material refreshes", () => {
const sourceColors = new Float32Array([
0.2, 0.4, 0.6,
0.8, 0.45, 0.2,
0.55, 0.3, 0.75
]);
const originalColors = Array.from(sourceColors);
const sourceMesh = {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
colors: sourceColors
};
const meshData = {
vertices: new Float32Array(0),
indices: new Uint32Array(0),
normals: new Float32Array(0),
bounds: { min: [0, 0, 0], max: [1, 1, 0] },
parts: [
{
id: "camera-source",
sourceMeshKey: "camera-source",
sourceMesh,
hasSourceColors: true,
vertexCount: 3,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
}
]
};
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, meshData, {
theme,
renderPartsIndividually: true
});
const record = scene.displayRecords[0];
const colorAttribute = record.geometry.getAttribute("color");
assert.notEqual(record.rawColors, sourceColors);
assert.notEqual(colorAttribute.array, sourceColors);
assert.deepEqual(Array.from(sourceColors), originalColors);
scene.update({
theme: {
...theme,
materials: {
...theme.materials,
brightness: 0.72,
saturation: 1.8
}
}
});
assert.deepEqual(Array.from(sourceColors), originalColors);
assert.deepEqual(Array.from(record.rawColors), originalColors);
scene.dispose();
});
test("buildModel selection can focus and hide subassembly occurrence descendants", () => {
const focused = buildModel(THREE, nestedAssemblyMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
focus: ["#o1.2"]
}
});
assert.deepEqual(focused.displayRecords.map((record) => record.partId), ["o1.2.1", "o1.2.2"]);
assert.deepEqual(focused.bounds, {
min: [0, 0, 0],
max: [3, 1, 0]
});
focused.dispose();
const hidden = buildModel(THREE, nestedAssemblyMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
hide: ["o1.2"]
}
});
assert.deepEqual(hidden.displayRecords.map((record) => record.partId), ["o1.3"]);
assert.deepEqual(hidden.bounds, {
min: [10, 0, 0],
max: [11, 1, 0]
});
hidden.dispose();
});
test("buildModel renders STEP surface-owned edges from mesh attributes without line objects", () => {
const meshData = sampleMeshData();
meshData.surfaceEdgeBarycentric = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 0, 0,
0, 1, 0,
0, 0, 1
]);
meshData.surfaceEdgeClass = new Uint8Array([
1, 1, 0,
1, 1, 0,
1, 1, 0,
2, 3, 0,
2, 3, 0,
2, 3, 0
]);
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
displayMode: CAD_DISPLAY_MODE.SOLID,
renderPartsIndividually: true
});
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.edgesGroup.children.length, 0);
assert.equal(scene.displayRecords[0].edges, null);
assert.equal(scene.displayRecords[0].material.userData.cadSurfaceEdges, true);
assert.equal(scene.displayRecords[0].material.polygonOffset, false);
assert.equal(scene.displayRecords[0].geometry.getAttribute("_cad_edge_barycentric").count, 3);
assert.equal(scene.displayRecords[0].geometry.getAttribute("_cad_edge_class").count, 3);
scene.dispose();
});
test("buildModel rebuilds surface edge shader materials when edge theme settings change", () => {
const meshData = sampleMeshData();
meshData.surfaceEdgeBarycentric = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 0, 0,
0, 1, 0,
0, 0, 1
]);
meshData.surfaceEdgeClass = new Uint8Array([
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0
]);
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, meshData, {
theme,
displayMode: CAD_DISPLAY_MODE.SOLID,
renderPartsIndividually: true
});
const originalMaterial = scene.displayRecords[0].material;
scene.update({
displayMode: CAD_DISPLAY_MODE.SOLID,
theme: {
...theme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
color: "#0055ff",
classes: {
...DEFAULT_DISPLAY_EDGE_SETTINGS.classes,
tangent: {
...DEFAULT_DISPLAY_EDGE_SETTINGS.classes.tangent,
thickness: 2.5
}
}
}
}
});
assert.notEqual(scene.displayRecords[0].material, originalMaterial);
assert.equal(scene.displayRecords[0].material.userData.cadSurfaceEdges, true);
assert.equal(scene.edgesGroup.children.length, 0);
scene.dispose();
});
test("buildModel reuses cached geometry for posed wrappers with the same geometry source", () => {
const geometrySource = sampleMeshData();
const posedMeshData = {
...geometrySource,
geometrySource,
parts: geometrySource.parts.map((part) => ({
...part,
transform: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]
}))
};
const movedMeshData = {
...geometrySource,
geometrySource,
parts: geometrySource.parts.map((part, index) => ({
...part,
bounds: {
min: [part.bounds.min[0] + index, part.bounds.min[1], part.bounds.min[2]],
max: [part.bounds.max[0] + index, part.bounds.max[1], part.bounds.max[2]]
},
transform: [
1, 0, 0, index,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]
}))
};
const scene = buildModel(THREE, posedMeshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
const firstGeometry = scene.displayRecords[0].mesh.geometry;
const movedScene = buildModel(THREE, movedMeshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
assert.equal(movedScene.displayRecords[0].mesh.geometry, firstGeometry);
scene.dispose();
movedScene.dispose();
});
test("buildModel wireframe mode keeps a translucent surface and wire edges", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.WIREFRAME,
renderPartsIndividually: true
});
assert.equal(normalizeDisplayMode("wireframe"), CAD_DISPLAY_MODE.WIREFRAME);
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].material.type, "MeshBasicMaterial");
assert.equal(scene.displayRecords[0].material.opacity, 0.035);
assert.equal(scene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
scene.dispose();
});
test("buildModel display modes control edges, transparency, and flat surfaces", () => {
const theme = cloneThemeSettings("workbench");
const renderedScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.RENDERED,
renderPartsIndividually: true
});
assert.equal(renderedScene.displayRecords[0].edges, null);
assert.equal(renderedScene.displayRecords[0].material.opacity, 1);
const transparentScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.TRANSPARENT,
renderPartsIndividually: true
});
assert.equal(transparentScene.displayRecords[0].material.opacity, 0.22);
assert.equal(transparentScene.displayRecords[0].material.transparent, true);
assert.equal(transparentScene.displayRecords[0].material.depthWrite, false);
assert.equal(transparentScene.displayRecords[0].edgeMaterial.depthTest, false);
const hiddenEdgeScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.HIDDEN_EDGES,
renderPartsIndividually: true
});
assert.equal(hiddenEdgeScene.displayRecords[0].material.opacity, 1);
assert.equal(hiddenEdgeScene.displayRecords[0].edgeMaterial.depthTest, false);
const unshadedScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.UNSHADED,
renderPartsIndividually: true
});
assert.equal(unshadedScene.displayRecords[0].material.type, "MeshBasicMaterial");
assert.equal(unshadedScene.displayRecords[0].edges, null);
renderedScene.dispose();
transparentScene.dispose();
hiddenEdgeScene.dispose();
unshadedScene.dispose();
});
test("buildModel applies source part opacity from GLB material metadata", () => {
const meshData = sampleMeshData();
meshData.parts = meshData.parts.map((part, index) => index === 0
? { ...part, color: "#ff0000", opacity: 0.2, hasSourceColors: true }
: part
);
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
const left = scene.displayRecords.find((record) => record.partId === "left");
assert.equal(left.baseOpacity, 0.2);
assert.equal(left.material.opacity, 0.2);
assert.equal(left.material.transparent, true);
assert.equal(left.material.depthWrite, false);
scene.dispose();
});
test("buildModel uses part records when only source opacity differs", () => {
const meshData = sampleMeshData();
meshData.sourceColor = "#ff0000";
meshData.has_source_colors = true;
meshData.parts = meshData.parts.map((part) => ({
...part,
color: "#ff0000",
opacity: 0.2,
hasSourceColors: true
}));
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: false
});
assert.equal(scene.displayRecords.length, 2);
for (const record of scene.displayRecords) {
assert.equal(record.baseOpacity, 0.2);
assert.equal(record.material.opacity, 0.2);
assert.equal(record.material.transparent, true);
assert.equal(record.material.depthWrite, false);
}
scene.dispose();
});
test("buildModel ignores deprecated mesh edge detail and keeps wireframe all-edge mode", () => {
const baseTheme = cloneThemeSettings("workbench");
const deprecatedDetailScene = buildModel(THREE, squareMeshData(), {
theme: {
...baseTheme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
topologyFilter: "all"
}
},
displayMode: CAD_DISPLAY_MODE.SOLID
});
const wireScene = buildModel(THREE, squareMeshData(), {
theme: baseTheme,
displayMode: CAD_DISPLAY_MODE.WIREFRAME
});
assert.equal(edgeSegmentCount(deprecatedDetailScene.displayRecords[0]), 4);
assert.notEqual(deprecatedDetailScene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
assert.equal(edgeSegmentCount(wireScene.displayRecords[0]), 5);
assert.equal(wireScene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
deprecatedDetailScene.dispose();
wireScene.dispose();
});
test("buildModel creates screen-space edges from declarative edge rendering options", () => {
const scene = buildModel(THREE, squareMeshData(), {
theme: cloneThemeSettings("workbench"),
displayMode: CAD_DISPLAY_MODE.SOLID,
edgeRendering: {
mode: "screen-space",
LineSegments2,
LineSegmentsGeometry,
LineMaterial
}
});
assert.equal(scene.runtime.edgeRendering.mode, "screen-space");
assert.equal(scene.displayRecords[0].edges instanceof LineSegments2, true);
assert.equal(scene.runtime.screenSpaceLineMaterials.size, 1);
scene.dispose();
assert.equal(scene.runtime.screenSpaceLineMaterials.size, 0);
});
test("buildModel can render silhouette contours without derived mesh edges", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme: {
...theme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false,
silhouette: true,
silhouetteScale: 0.004
}
},
displayMode: CAD_DISPLAY_MODE.RENDERED,
silhouette: true,
renderPartsIndividually: true
});
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].edges, null);
assert.equal(scene.displayRecords[0].silhouette?.isMesh, true);
scene.dispose();
});
test("buildModel applies selection, clipping, and STEP parameter effects", () => {
const scene = buildModel(THREE, sampleMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
selectedPartIds: ["left"],
hiddenPartIds: ["right"]
},
clip: {
enabled: true,
axis: "x",
offset: 0.5
},
stepParameters: {
definition: {
module: {
render(ctx) {
if (ctx.params.hideLeft) {
ctx.effects.visible("left", false);
}
}
},
manifest: {},
cadPath: "part.step"
},
parameterValues: {
hideLeft: true
}
}
});
const left = scene.displayRecords.find((record) => record.partId === "left");
const right = scene.displayRecords.find((record) => record.partId === "right");
assert.equal(left.mesh.visible, false);
assert.equal(right.mesh.visible, true);
assert.equal(right.material.transparent, true);
assert.equal(right.material.depthWrite, false);
assert.equal(right.material.opacity, 0.035);
assert.equal(left.material.clippingPlanes.length, 1);
assert.equal(scene.bounds.min[0], 2);
assert.equal(scene.bounds.max[0], 3);
scene.dispose();
});
test("buildModel can apply STEP parameter effects while deferring setup lifecycle", () => {
let setupCalls = 0;
const scene = buildModel(THREE, sampleMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
parameterSetup: false,
stepParameters: {
definition: {
module: {
setup() {
setupCalls += 1;
},
render(ctx) {
ctx.effects.transform("left", { translate: [5, 0, 0] });
}
},
manifest: {},
cadPath: "part.step"
}
}
});
const left = scene.displayRecords.find((record) => record.partId === "left");
assert.equal(setupCalls, 0);
assert.equal(left.mesh.matrix.elements[12], 5);
assert.equal(scene.bounds.min[0], 2);
assert.equal(scene.bounds.max[0], 6);
scene.dispose();
});
@@ -0,0 +1,337 @@
import * as THREE from "three";
export const WORLD_UP = Object.freeze([0, 0, 1]);
export const TOP_VIEW_UP = Object.freeze([0, 1, 0]);
export const RENDER_CAMERA_PRESETS = Object.freeze({
front: Object.freeze({ name: "front", direction: Object.freeze([0, -1, 0]), up: WORLD_UP }),
back: Object.freeze({ name: "back", direction: Object.freeze([0, 1, 0]), up: WORLD_UP }),
right: Object.freeze({ name: "right", direction: Object.freeze([1, 0, 0]), up: WORLD_UP }),
left: Object.freeze({ name: "left", direction: Object.freeze([-1, 0, 0]), up: WORLD_UP }),
top: Object.freeze({ name: "top", direction: Object.freeze([0, 0, 1]), up: TOP_VIEW_UP }),
bottom: Object.freeze({ name: "bottom", direction: Object.freeze([0, 0, -1]), up: TOP_VIEW_UP }),
iso: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
isometric: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
side: Object.freeze({ name: "side", direction: Object.freeze([1, 0, 0]), up: WORLD_UP })
});
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
const DEFAULT_CAMERA_PRESET = "iso";
const CAMERA_SPEC_KEYS = Object.freeze(["preset", "position", "target", "up", "zoom", "direction", "name"]);
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function normalizeVector(value, {
fieldName = "camera vector",
required = false,
nonZero = false,
strict = true
} = {}) {
if (value === undefined || value === null) {
if (required && strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
if (!Array.isArray(value) || value.length < 3) {
if (strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
const vector = [
Number(value[0]),
Number(value[1]),
Number(value[2])
];
if (!vector.every(Number.isFinite)) {
if (strict) {
throw new Error(`${fieldName} must contain only finite numbers`);
}
return null;
}
if (nonZero && vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 <= 1e-12) {
if (strict) {
throw new Error(`${fieldName} must not be the zero vector`);
}
return null;
}
return vector;
}
export function cloneCameraVector(vector) {
return normalizeVector(vector, { strict: false });
}
export function normalizeCameraZoom(value, fallback = 1, {
fieldName = "camera.zoom",
strict = false
} = {}) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue;
}
if (strict) {
throw new Error(`${fieldName} must be a positive finite number`);
}
return fallback;
}
function clonePreset(preset) {
return {
name: preset.name,
direction: [...preset.direction],
up: [...preset.up]
};
}
function cameraPresetByName(name, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const raw = String(name || DEFAULT_CAMERA_PRESET).trim().toLowerCase();
if (presets[raw]) {
return clonePreset(presets[raw]);
}
const parts = raw.split(":").map((entry) => Number(entry));
if (parts.length >= 2 && parts.every(Number.isFinite)) {
const azimuth = (parts[0] * Math.PI) / 180;
const elevation = (parts[1] * Math.PI) / 180;
const cosElevation = Math.cos(elevation);
return {
name: `${parts[0]}:${parts[1]}`,
direction: [
Math.sin(azimuth) * cosElevation,
-Math.cos(azimuth) * cosElevation,
Math.sin(elevation)
],
up: [...WORLD_UP]
};
}
if (strict) {
throw new Error(`Unknown camera preset: ${name}`);
}
return clonePreset(presets[DEFAULT_CAMERA_PRESET]);
}
function parseCameraJsonString(raw, { strict = true } = {}) {
const text = String(raw || "").trim();
if (!text.startsWith("{")) {
return null;
}
try {
const parsed = JSON.parse(text);
if (!isPlainObject(parsed)) {
throw new Error("camera JSON must be an object");
}
return parsed;
} catch (error) {
if (strict) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid camera JSON: ${message}`);
}
return null;
}
}
function validateCameraSpecKeys(spec) {
const unknownKeys = Object.keys(spec).filter((key) => !CAMERA_SPEC_KEYS.includes(key));
if (unknownKeys.length) {
throw new Error(`Unsupported camera fields: ${unknownKeys.join(", ")}`);
}
}
export function normalizeCameraSpec(rawCamera = DEFAULT_CAMERA_PRESET, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const parsedJson = typeof rawCamera === "string"
? parseCameraJsonString(rawCamera, { strict })
: null;
const source = parsedJson || rawCamera;
if (!isPlainObject(source)) {
const preset = cameraPresetByName(source || DEFAULT_CAMERA_PRESET, { presets, strict });
return {
sourceKind: "string",
name: preset.name,
preset: preset.name,
direction: preset.direction,
position: null,
target: null,
up: preset.up,
zoom: 1,
hasExplicitPosition: false,
hasExplicitTarget: false,
hasExplicitUp: false,
hasExplicitZoom: false
};
}
validateCameraSpecKeys(source);
const rawPreset = source.preset ?? DEFAULT_CAMERA_PRESET;
const preset = cameraPresetByName(rawPreset, { presets, strict });
const direction = normalizeVector(source.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || preset.direction;
const position = normalizeVector(source.position, {
fieldName: "camera.position",
strict
});
const target = normalizeVector(source.target, {
fieldName: "camera.target",
strict
});
const up = normalizeVector(source.up, {
fieldName: "camera.up",
nonZero: true,
strict
}) || preset.up || [...WORLD_UP];
const hasExplicitZoom = Object.prototype.hasOwnProperty.call(source, "zoom");
return {
sourceKind: "object",
name: String(source.name || preset.name || "custom"),
preset: preset.name,
direction,
position,
target,
up,
zoom: normalizeCameraZoom(source.zoom, 1, { strict, fieldName: "camera.zoom" }),
hasExplicitPosition: Object.prototype.hasOwnProperty.call(source, "position"),
hasExplicitTarget: Object.prototype.hasOwnProperty.call(source, "target"),
hasExplicitUp: Object.prototype.hasOwnProperty.call(source, "up"),
hasExplicitZoom
};
}
function settingsForCamera(sceneScale, settingsByScale) {
if (settingsByScale?.[sceneScale]) {
return settingsByScale[sceneScale];
}
if (settingsByScale?.cad) {
return settingsByScale.cad;
}
return {
minBoundsSpan: 1,
minModelRadius: 1,
minCameraDistance: 10,
minCameraFar: 1000
};
}
function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = settingsForCamera(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
function vectorFromArray(vector) {
return new THREE.Vector3(vector[0], vector[1], vector[2]);
}
export function resolveCameraView(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
const target = spec.target ? vectorFromArray(spec.target) : null;
const position = spec.position ? vectorFromArray(spec.position) : null;
const direction = position && target
? position.clone().sub(target).normalize().toArray()
: normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict: false
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
return {
name: spec.name,
direction,
up: [...(spec.up || WORLD_UP)],
zoom: spec.zoom
};
}
export function resolveCameraSnapshot(rawCamera = DEFAULT_CAMERA_PRESET, bounds = null, {
sceneScale = "cad",
settingsByScale = null,
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const spec = normalizeCameraSpec(rawCamera, { presets, strict });
const settings = settingsForCamera(sceneScale, settingsByScale);
const { center, radius, size } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const target = spec.target ? vectorFromArray(spec.target) : center.clone();
const direction = normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
const position = spec.position
? vectorFromArray(spec.position)
: target.clone().add(vectorFromArray(direction).normalize().multiplyScalar(
Math.max(radius * 3.2, settings.minCameraDistance || settings.minModelRadius * 10 || 10)
));
const resolvedDirection = position.clone().sub(target);
if (resolvedDirection.lengthSq() <= 1e-12) {
throw new Error("camera.position must differ from camera.target");
}
const up = vectorFromArray(spec.up || WORLD_UP);
if (up.lengthSq() <= 1e-12) {
throw new Error("camera.up must not be the zero vector");
}
return {
name: spec.name,
preset: spec.preset,
position: position.toArray(),
target: target.toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom,
direction: resolvedDirection.normalize().toArray(),
view: {
name: spec.name,
direction: resolvedDirection.normalize().toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom
},
sourceKind: spec.sourceKind,
hasExplicitPosition: spec.hasExplicitPosition,
hasExplicitTarget: spec.hasExplicitTarget,
hasExplicitUp: spec.hasExplicitUp,
hasExplicitZoom: spec.hasExplicitZoom,
radius,
size: size.toArray()
};
}
export function cameraSpecUsesPerspectiveProjection(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
return spec.sourceKind === "object" && (
spec.hasExplicitPosition ||
spec.hasExplicitTarget ||
spec.hasExplicitUp
);
}
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cameraSpecUsesPerspectiveProjection,
normalizeCameraSpec,
resolveCameraSnapshot
} from "./camera.js";
import {
RENDER_SCENE_SCALE
} from "./renderOptions.js";
const SCALE_SETTINGS = Object.freeze({
[RENDER_SCENE_SCALE.CAD]: Object.freeze({
minBoundsSpan: 1,
minModelRadius: 1,
minFloorSize: 100,
minCameraDistance: 10,
minCameraFar: 1000
}),
[RENDER_SCENE_SCALE.URDF]: Object.freeze({
minBoundsSpan: 0.05,
minModelRadius: 0.05,
minFloorSize: 0.05,
minCameraDistance: 0.5,
minCameraFar: 10
})
});
function assertClose(actual, expected, epsilon = 1e-6) {
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} !== ${expected}`);
}
test("camera preset strings expand like JSON preset specs", () => {
const stringSpec = normalizeCameraSpec("iso");
const jsonSpec = normalizeCameraSpec({ preset: "iso" });
assert.equal(stringSpec.name, jsonSpec.name);
assert.equal(stringSpec.preset, jsonSpec.preset);
assert.deepEqual(stringSpec.direction, jsonSpec.direction);
assert.deepEqual(stringSpec.up, jsonSpec.up);
assert.equal(stringSpec.zoom, jsonSpec.zoom);
});
test("camera JSON preset overrides merge onto the preset defaults", () => {
const spec = normalizeCameraSpec({
preset: "top",
up: [0, 0, 1],
zoom: 1.4
});
assert.equal(spec.name, "top");
assert.deepEqual(spec.direction, [0, 0, 1]);
assert.deepEqual(spec.up, [0, 0, 1]);
assert.equal(spec.zoom, 1.4);
assert.equal(spec.hasExplicitZoom, true);
});
test("explicit camera state passes through resolved snapshots", () => {
const snapshot = resolveCameraSnapshot({
position: [10, 20, 30],
target: [1, 2, 3],
up: [0, 0, 1],
zoom: 1.4
}, { min: [0, 0, 0], max: [2, 4, 6] }, {
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assert.deepEqual(snapshot.position, [10, 20, 30]);
assert.deepEqual(snapshot.target, [1, 2, 3]);
assert.deepEqual(snapshot.up, [0, 0, 1]);
assert.equal(snapshot.zoom, 1.4);
assert.equal(cameraSpecUsesPerspectiveProjection({ position: [10, 20, 30] }), true);
});
test("omitted camera fields derive from model bounds", () => {
const snapshot = resolveCameraSnapshot({ preset: "top" }, { min: [0, 0, 0], max: [2, 4, 6] }, {
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assert.deepEqual(snapshot.target, [1, 2, 3]);
assertClose(snapshot.position[0], 1);
assertClose(snapshot.position[1], 2);
assert.ok(snapshot.position[2] > 3);
assert.deepEqual(snapshot.up, [0, 1, 0]);
assert.equal(snapshot.zoom, 1);
assert.equal(cameraSpecUsesPerspectiveProjection({ preset: "top" }), false);
});
test("invalid camera specs fail clearly", () => {
assert.throws(() => normalizeCameraSpec({ preset: "wat" }), /Unknown camera preset/);
assert.throws(() => normalizeCameraSpec({ position: [1, 2, "x"] }), /camera.position/);
assert.throws(() => normalizeCameraSpec({ up: [0, 0, 0] }), /camera.up/);
assert.throws(() => normalizeCameraSpec({ zoom: 0 }), /camera.zoom/);
assert.throws(() => normalizeCameraSpec({ preset: "iso", extra: true }), /Unsupported camera fields/);
});
@@ -0,0 +1,48 @@
import {
buildPartTransformMatrix
} from "./stepModuleEffects.js";
function applyObjectMatrix(THREE, object3d, matrix) {
if (!object3d || !(matrix instanceof THREE.Matrix4)) {
return;
}
object3d.matrixAutoUpdate = false;
const targetMatrix = object3d.matrix instanceof THREE.Matrix4 ? object3d.matrix : new THREE.Matrix4();
targetMatrix.copy(matrix);
object3d.matrix = targetMatrix;
object3d.matrixWorldNeedsUpdate = true;
}
export function composeDisplayRecordEffectMatrix(THREE, record) {
if (!record || !THREE?.Matrix4) {
return null;
}
const matrices = [
record.effectMatrix,
record.explodedViewMatrix
].filter((matrix) => matrix instanceof THREE.Matrix4);
if (!matrices.length) {
return null;
}
const combined = new THREE.Matrix4();
for (const matrix of matrices) {
combined.premultiply(matrix);
}
return combined;
}
export function composeDisplayRecordObjectMatrix(THREE, record) {
const baseMatrix = buildPartTransformMatrix(THREE, record?.baseTransform);
const effectMatrix = composeDisplayRecordEffectMatrix(THREE, record);
return effectMatrix ? effectMatrix.multiply(baseMatrix) : baseMatrix;
}
export function applyDisplayRecordTransform(THREE, record) {
if (!record) {
return;
}
const combinedMatrix = composeDisplayRecordObjectMatrix(THREE, record);
applyObjectMatrix(THREE, record.mesh, combinedMatrix);
applyObjectMatrix(THREE, record.edges, combinedMatrix);
applyObjectMatrix(THREE, record.silhouette, combinedMatrix);
}
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import {
composeDisplayRecordEffectMatrix,
composeDisplayRecordObjectMatrix
} from "./displayRecordTransform.js";
test("display record transforms compose module effects and exploded view effects", () => {
const record = {
baseTransform: [
1, 0, 0, 3,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
],
effectMatrix: new THREE.Matrix4().makeTranslation(5, 0, 0),
explodedViewMatrix: new THREE.Matrix4().makeTranslation(0, 7, 0)
};
const effectPoint = new THREE.Vector3(0, 0, 0).applyMatrix4(
composeDisplayRecordEffectMatrix(THREE, record)
);
assert.deepEqual(effectPoint.toArray(), [5, 7, 0]);
const objectPoint = new THREE.Vector3(0, 0, 0).applyMatrix4(
composeDisplayRecordObjectMatrix(THREE, record)
);
assert.deepEqual(objectPoint.toArray(), [8, 7, 0]);
});
@@ -0,0 +1,381 @@
import {
DEFAULT_STEP_CLIP_SETTINGS,
normalizeStepClipSettings,
stepClipSettingsEqual
} from "../lib/viewer/clipPlane.js";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "../lib/perspective.js";
export { CAMERA_PROJECTION, normalizeCameraProjection };
export const CAD_DISPLAY_MODE = Object.freeze({
HIDDEN_EDGES: "hidden_edges",
HIDDEN_LINES_REMOVED: "hidden_lines_removed",
RENDERED: "rendered",
SOLID: "solid",
TRANSPARENT: "transparent",
UNSHADED: "unshaded",
WIREFRAME: "wireframe"
});
export const CAD_DISPLAY_MODE_VALUES = Object.freeze(Object.values(CAD_DISPLAY_MODE));
export const EXPLODED_VIEW_AXES = Object.freeze(["x", "y", "z", "radial"]);
export const EXPLODED_VIEW_DIRECTIONS = Object.freeze(["positive", "negative"]);
export const MAX_EXPLODED_VIEW_DEPTH = 8;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const CAD_EDGE_COLOR = "#132232";
export const CAD_EDGE_HIGHLIGHT_COLOR = "#8dc5ff";
export const CAD_EDGE_CLASS_IDS = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
export const DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 1.15
}),
tangent: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.5,
thickness: 1.15
}),
seam: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.85,
thickness: 1.15
}),
degenerate: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 0
})
});
export const DEFAULT_DISPLAY_EDGE_SETTINGS = Object.freeze({
enabled: true,
contrastMode: "manual",
color: CAD_EDGE_COLOR,
thickness: 1,
classes: DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
highlightColor: CAD_EDGE_HIGHLIGHT_COLOR,
highlightOpacity: 1,
highlightThickness: 3,
silhouette: false,
silhouetteScale: 0
});
export const DISABLED_DISPLAY_EDGE_SETTINGS = Object.freeze({
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false
});
export const DEFAULT_EXPLODED_VIEW_SETTINGS = Object.freeze({
enabled: false,
axis: "z",
direction: "positive",
spacing: 1.45,
depth: 1,
keepBaseGrounded: true,
mergeCoplanar: false,
autoFrame: true
});
export const DEFAULT_DISPLAY_SETTINGS = Object.freeze({
projection: CAMERA_PROJECTION.ORTHOGRAPHIC,
mode: CAD_DISPLAY_MODE.SOLID,
clip: DEFAULT_STEP_CLIP_SETTINGS,
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeNumber(value, fallback, min = -Infinity, max = Infinity) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return fallback;
}
return clamp(numericValue, min, max);
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDisplayEdgeContrastMode(value, fallback = "manual") {
const normalized = String(value || "").trim().toLowerCase();
return ["auto", "manual"].includes(normalized)
? normalized
: fallback;
}
export function normalizeDisplayEdgeClassSettings(
value = {},
fallback = DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
colorFallback = CAD_EDGE_COLOR
) {
const source = isObject(value) ? value : {};
const fallbackColor = normalizeColor(colorFallback, CAD_EDGE_COLOR);
return Object.fromEntries(CAD_EDGE_CLASS_IDS.map((classId) => {
const classSource = isObject(source[classId]) ? source[classId] : {};
const classFallback = fallback?.[classId] || DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS[classId];
const legacyDisabled = classSource.enabled === false;
return [classId, {
color: normalizeColor(classSource.color, fallbackColor),
opacity: normalizeNumber(classSource.opacity, classFallback.opacity, 0, 1),
thickness: legacyDisabled
? 0
: normalizeNumber(classSource.thickness, classFallback.thickness, 0, 6)
}];
}));
}
export function normalizeDisplayEdgeSettings(value = null, fallback = DEFAULT_DISPLAY_EDGE_SETTINGS) {
const source = isObject(value) ? value : {};
const color = normalizeColor(source.color, fallback.color);
const normalized = {
enabled: normalizeBoolean(source.enabled, fallback.enabled),
contrastMode: normalizeDisplayEdgeContrastMode(source.contrastMode, fallback.contrastMode),
color,
thickness: normalizeNumber(source.thickness, fallback.thickness, 0.5, 6),
classes: normalizeDisplayEdgeClassSettings(source.classes, fallback.classes, color),
highlightColor: normalizeColor(source.highlightColor, fallback.highlightColor || CAD_EDGE_HIGHLIGHT_COLOR),
highlightOpacity: normalizeNumber(source.highlightOpacity, fallback.highlightOpacity || 1, 0, 1),
highlightThickness: normalizeNumber(source.highlightThickness, fallback.highlightThickness || 3, 0.5, 6),
silhouette: normalizeBoolean(source.silhouette, fallback.silhouette || false),
silhouetteScale: normalizeNumber(source.silhouetteScale, fallback.silhouetteScale || 0, 0, 0.04)
};
if (typeof source.depthTest === "boolean") {
normalized.depthTest = source.depthTest;
}
return normalized;
}
function normalizeModeText(value) {
return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_");
}
export function normalizeDisplayMode(value) {
const normalized = normalizeModeText(value);
if (!normalized) {
return CAD_DISPLAY_MODE.SOLID;
}
if (normalized === "wire" || normalized === "wire_frame") {
return CAD_DISPLAY_MODE.WIREFRAME;
}
if (
normalized === "edges" ||
normalized === "edge" ||
normalized === "shaded_edges" ||
normalized === "shaded_with_edges" ||
normalized === "with_edges"
) {
return CAD_DISPLAY_MODE.SOLID;
}
if (
normalized === "shaded" ||
normalized === "shaded_without_edges" ||
normalized === "without_edges"
) {
return CAD_DISPLAY_MODE.RENDERED;
}
if (
normalized === "translucent" ||
normalized === "xray" ||
normalized === "x_ray" ||
normalized === "see_through"
) {
return CAD_DISPLAY_MODE.TRANSPARENT;
}
if (
normalized === "hidden_edge" ||
normalized === "hidden_edges_visible" ||
normalized === "hidden_edge_display" ||
normalized === "shaded_hidden_edges"
) {
return CAD_DISPLAY_MODE.HIDDEN_EDGES;
}
if (
normalized === "visible_edges" ||
normalized === "visible_edges_only" ||
normalized === "hidden_lines" ||
normalized === "hidden_line_removed" ||
normalized === "hidden_lines_removed" ||
normalized === "hidden_edges_removed"
) {
return CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED;
}
if (normalized === "flat") {
return CAD_DISPLAY_MODE.UNSHADED;
}
if (normalized === "appearance" || normalized === "material" || normalized === "materials") {
return CAD_DISPLAY_MODE.RENDERED;
}
return CAD_DISPLAY_MODE_VALUES.includes(normalized)
? normalized
: CAD_DISPLAY_MODE.SOLID;
}
export function normalizeExplodedViewAxis(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.axis) {
const normalized = String(value || "").trim().toLowerCase();
const axis = normalized.startsWith("-") ? normalized.slice(1) : normalized;
return EXPLODED_VIEW_AXES.includes(axis) ? axis : fallback;
}
export function normalizeExplodedViewDirection(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.direction) {
const normalized = String(value || "").trim().toLowerCase();
if (["negative", "reverse", "down", "backward", "-", "-1"].includes(normalized)) {
return "negative";
}
if (["positive", "forward", "up", "+", "+1", "1"].includes(normalized)) {
return "positive";
}
return fallback;
}
export function normalizeExplodedViewDepth(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.depth) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["all", "full", "parts", "leaves", "leaf"].includes(normalized)) {
return MAX_EXPLODED_VIEW_DEPTH;
}
const numericValue = Math.round(Number(value));
return Number.isFinite(numericValue)
? clamp(numericValue, 1, MAX_EXPLODED_VIEW_DEPTH)
: fallback;
}
export function normalizeExplodedViewSettings(value = null, overrides = {}) {
const source = isObject(value) ? value : {};
const merged = isObject(overrides) ? { ...source, ...overrides } : source;
const axisText = String(merged.axis || "").trim().toLowerCase();
return {
enabled: normalizeBoolean(merged.enabled, DEFAULT_EXPLODED_VIEW_SETTINGS.enabled),
axis: normalizeExplodedViewAxis(merged.axis),
direction: normalizeExplodedViewDirection(merged.direction || (axisText.startsWith("-") ? "negative" : "positive")),
spacing: normalizeNumber(merged.spacing ?? merged.distance ?? merged.distanceScale, DEFAULT_EXPLODED_VIEW_SETTINGS.spacing, 0.25, 4),
depth: normalizeExplodedViewDepth(merged.depth ?? merged.levels ?? merged.scopeDepth),
keepBaseGrounded: normalizeBoolean(merged.keepBaseGrounded ?? merged.groundBase, DEFAULT_EXPLODED_VIEW_SETTINGS.keepBaseGrounded),
mergeCoplanar: normalizeBoolean(merged.mergeCoplanar ?? merged.mergeLayers ?? merged.coalesceLayers, DEFAULT_EXPLODED_VIEW_SETTINGS.mergeCoplanar),
autoFrame: normalizeBoolean(merged.autoFrame, DEFAULT_EXPLODED_VIEW_SETTINGS.autoFrame)
};
}
export function normalizeDisplaySettings(value = null) {
const source = isObject(value) ? value : {};
const explodedOverrides = {};
if (isObject(source.exploded) && source.exploded.enabled !== undefined) {
explodedOverrides.enabled = source.exploded.enabled;
}
return {
projection: normalizeCameraProjection(source.projection, DEFAULT_DISPLAY_SETTINGS.projection),
mode: normalizeDisplayMode(source.mode),
clip: normalizeStepClipSettings(source.clip),
exploded: normalizeExplodedViewSettings(source.exploded, explodedOverrides),
edges: normalizeDisplayEdgeSettings(source.edges)
};
}
export function cloneDisplaySettings(value = DEFAULT_DISPLAY_SETTINGS) {
return normalizeDisplaySettings(value);
}
export function displaySettingsEqual(left, right) {
const a = normalizeDisplaySettings(left);
const b = normalizeDisplaySettings(right);
return a.projection === b.projection &&
a.mode === b.mode &&
stepClipSettingsEqual(a.clip, b.clip) &&
JSON.stringify(a.exploded) === JSON.stringify(b.exploded) &&
JSON.stringify(a.edges) === JSON.stringify(b.edges);
}
export function resolveDisplayMode(displaySettings) {
return normalizeDisplaySettings(displaySettings).mode;
}
export function resolveDisplayEdgeSettings(displaySettings) {
return normalizeDisplaySettings(displaySettings).edges;
}
export function displayModeIsWireframe(value) {
return normalizeDisplayMode(value) === CAD_DISPLAY_MODE.WIREFRAME;
}
export function displayModeForcesEdges(value) {
return [
CAD_DISPLAY_MODE.SOLID,
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED
].includes(normalizeDisplayMode(value));
}
export function displayModeAllowsEdges(value) {
return ![
CAD_DISPLAY_MODE.RENDERED,
CAD_DISPLAY_MODE.UNSHADED
].includes(normalizeDisplayMode(value));
}
export function displayModeShowsEdges(value, edgeSettings = null) {
const mode = normalizeDisplayMode(value);
return mode === CAD_DISPLAY_MODE.WIREFRAME ||
displayModeForcesEdges(mode);
}
export function displayModeShowsThroughEdges(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesTransparentSurfaces(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesUnlitSurfaces(value) {
return [
CAD_DISPLAY_MODE.UNSHADED,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeSurfaceOpacity(value, fallback = 1) {
const mode = normalizeDisplayMode(value);
if (mode === CAD_DISPLAY_MODE.WIREFRAME) {
return 0.035;
}
if (mode === CAD_DISPLAY_MODE.TRANSPARENT) {
return 0.22;
}
if (mode === CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED) {
return 0.045;
}
const numericFallback = Number(fallback);
return Number.isFinite(numericFallback) ? numericFallback : 1;
}
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CAD_DISPLAY_MODE,
CAMERA_PROJECTION,
DEFAULT_DISPLAY_EDGE_SETTINGS,
DEFAULT_DISPLAY_SETTINGS,
DEFAULT_EXPLODED_VIEW_SETTINGS,
displayModeForcesEdges,
displayModeShowsEdges,
displayModeShowsThroughEdges,
displayModeSurfaceOpacity,
displayModeUsesUnlitSurfaces,
displaySettingsEqual,
normalizeDisplayEdgeSettings,
normalizeDisplaySettings,
normalizeExplodedViewSettings,
resolveDisplayMode
} from "./displaySettings.js";
test("display settings normalize mode and clip independently from appearance settings", () => {
assert.deepEqual(normalizeDisplaySettings(), DEFAULT_DISPLAY_SETTINGS);
assert.equal(resolveDisplayMode({ mode: "wireframe" }), CAD_DISPLAY_MODE.WIREFRAME);
assert.equal(normalizeDisplaySettings().projection, CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.equal(normalizeDisplaySettings({ projection: "perspective" }).projection, CAMERA_PROJECTION.PERSPECTIVE);
assert.equal(normalizeDisplaySettings({ projection: "fisheye" }).projection, CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.deepEqual(normalizeDisplaySettings({
projection: "perspective",
mode: "wireframe",
clip: {
enabled: true,
axis: "z",
offset: 0.4,
invert: true
}
}), {
projection: CAMERA_PROJECTION.PERSPECTIVE,
mode: CAD_DISPLAY_MODE.WIREFRAME,
clip: {
enabled: true,
axis: "z",
offset: 0.4,
offsets: {
x: 0,
y: 0,
z: 0.4
},
invert: true
},
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
});
test("display settings normalize edge styling independently from appearance settings", () => {
assert.deepEqual(normalizeDisplayEdgeSettings({
enabled: false,
contrastMode: "auto",
color: "#ABC",
thickness: 2,
classes: {
tangent: {
enabled: false,
color: "#456",
opacity: 0.25,
thickness: 4
}
},
highlightColor: "#123456",
highlightOpacity: 0.4,
highlightThickness: 4,
silhouette: true,
silhouetteScale: 0.01
}), {
enabled: false,
contrastMode: "auto",
color: "#aabbcc",
thickness: 2,
classes: {
feature: { color: "#aabbcc", opacity: 1, thickness: 1.15 },
tangent: { color: "#445566", opacity: 0.25, thickness: 0 },
seam: { color: "#aabbcc", opacity: 0.85, thickness: 1.15 },
degenerate: { color: "#aabbcc", opacity: 1, thickness: 0 }
},
highlightColor: "#123456",
highlightOpacity: 0.4,
highlightThickness: 4,
silhouette: true,
silhouetteScale: 0.01
});
assert.deepEqual(normalizeDisplaySettings({
mode: "solid",
edges: {
enabled: false,
color: "#456"
}
}).edges, {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false,
color: "#445566",
classes: {
feature: { color: "#445566", opacity: 1, thickness: 1.15 },
tangent: { color: "#445566", opacity: 0.5, thickness: 1.15 },
seam: { color: "#445566", opacity: 0.85, thickness: 1.15 },
degenerate: { color: "#445566", opacity: 1, thickness: 0 }
}
});
});
test("display settings normalize exploded-view controls independently from mode", () => {
assert.deepEqual(normalizeExplodedViewSettings({
enabled: true,
axis: "-x",
spacing: 2,
levels: "all",
groundBase: false,
mergeLayers: true,
autoFrame: false
}), {
enabled: true,
axis: "x",
direction: "negative",
spacing: 2,
depth: 8,
keepBaseGrounded: false,
mergeCoplanar: true,
autoFrame: false
});
assert.equal(normalizeExplodedViewSettings({ axis: "diagonal" }).axis, "z");
assert.equal(normalizeExplodedViewSettings({ axis: "radial" }).axis, "radial");
assert.equal(normalizeDisplaySettings({ exploded: true }).exploded.enabled, false);
assert.equal(normalizeDisplaySettings({ mode: "exploded", exploded: { enabled: true } }).exploded.enabled, true);
assert.deepEqual(normalizeDisplaySettings({ mode: "exploded view" }), DEFAULT_DISPLAY_SETTINGS);
});
test("display modes normalize common CAD aliases", () => {
assert.equal(resolveDisplayMode({ mode: "edges" }), CAD_DISPLAY_MODE.SOLID);
assert.equal(resolveDisplayMode({ mode: "shaded-with-edges" }), CAD_DISPLAY_MODE.SOLID);
assert.equal(resolveDisplayMode({ mode: "shaded without edges" }), CAD_DISPLAY_MODE.RENDERED);
assert.equal(resolveDisplayMode({ mode: "x-ray" }), CAD_DISPLAY_MODE.TRANSPARENT);
assert.equal(resolveDisplayMode({ mode: "hidden edges visible" }), CAD_DISPLAY_MODE.HIDDEN_EDGES);
assert.equal(resolveDisplayMode({ mode: "hidden-lines-removed" }), CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED);
assert.equal(resolveDisplayMode({ mode: "flat" }), CAD_DISPLAY_MODE.UNSHADED);
assert.equal(resolveDisplayMode({ mode: "appearance" }), CAD_DISPLAY_MODE.RENDERED);
assert.equal(resolveDisplayMode({ mode: "wire" }), CAD_DISPLAY_MODE.WIREFRAME);
});
test("display mode policies describe edge and surface behavior", () => {
assert.equal(displayModeShowsEdges(CAD_DISPLAY_MODE.SOLID, { enabled: false }), true);
assert.equal(displayModeForcesEdges(CAD_DISPLAY_MODE.SOLID), true);
assert.equal(displayModeShowsEdges(CAD_DISPLAY_MODE.RENDERED, { enabled: true }), false);
assert.equal(displayModeShowsThroughEdges(CAD_DISPLAY_MODE.HIDDEN_EDGES), true);
assert.equal(displayModeShowsThroughEdges(CAD_DISPLAY_MODE.TRANSPARENT), true);
assert.equal(displayModeUsesUnlitSurfaces(CAD_DISPLAY_MODE.UNSHADED), true);
assert.equal(displayModeSurfaceOpacity(CAD_DISPLAY_MODE.TRANSPARENT, 1), 0.22);
assert.equal(displayModeSurfaceOpacity(CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED, 1), 0.045);
});
test("display settings compare after normalization", () => {
assert.equal(displaySettingsEqual(
{ mode: "wireframe", clip: { enabled: true, axis: "x", offset: 0.5 } },
{ mode: CAD_DISPLAY_MODE.WIREFRAME, clip: { enabled: true, axis: "x", offsets: { x: 0.5 } } }
), true);
assert.equal(displaySettingsEqual(
{ mode: "solid", clip: { enabled: true } },
{ mode: "wireframe", clip: { enabled: true } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", exploded: { enabled: true } },
{ mode: "solid", exploded: { enabled: false } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", edges: { color: "#111111" } },
{ mode: "solid", edges: { color: "#222222" } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", projection: "orthographic" },
{ mode: "solid", projection: "perspective" }
), false);
});
@@ -0,0 +1,29 @@
export const DEFAULT_ORBIT_FPS = 6;
export const DEFAULT_ORBIT_DURATION_SECONDS = 12;
export function orbitFrameOutputs(job) {
const orbit = job.orbit && typeof job.orbit === "object" ? job.orbit : {};
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || orbit.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || orbit.height || 480)));
const fps = Math.max(1, Math.min(Number(orbit.fps || DEFAULT_ORBIT_FPS), 60));
const durationSeconds = Math.max(0.1, Math.min(Number(orbit.durationSeconds || DEFAULT_ORBIT_DURATION_SECONDS), 60));
const frameCount = Math.max(2, Math.min(Math.round(fps * durationSeconds), 720));
const startAzimuth = Number.isFinite(Number(orbit.startAzimuth)) ? Number(orbit.startAzimuth) : -45;
const elevation = Number.isFinite(Number(orbit.elevation)) ? Number(orbit.elevation) : 30;
const turns = Number.isFinite(Number(orbit.turns)) ? Number(orbit.turns) : 1;
return {
path: String(output.path || job.output || ""),
width,
height,
fps,
durationSeconds,
frameCount,
outputs: Array.from({ length: frameCount }, (_, index) => ({
path: "",
width,
height,
camera: `${startAzimuth + ((360 * turns * index) / frameCount)}:${elevation}`
}))
};
}
@@ -0,0 +1,232 @@
import gifencDefault, {
GIFEncoder as exportedGifEncoder,
applyPalette as exportedApplyPalette,
quantize as exportedQuantize
} from "gifenc";
import * as THREE from "three";
import {
buildModel
} from "./cadScene.js";
import {
captureModel,
modelOptionsForRenderJob,
renderJobContext,
renderModel
} from "./renderMeshScene.js";
import {
hasStepParameterRenderValues
} from "./stepParameters.js";
import {
loadSource,
stepParameterFrameRuntime
} from "./source.js";
import {
orbitFrameOutputs
} from "./headlessOrbitFrames.js";
const GIFEncoder = exportedGifEncoder || gifencDefault?.GIFEncoder || gifencDefault;
const quantize = exportedQuantize || gifencDefault?.quantize;
const applyPalette = exportedApplyPalette || gifencDefault?.applyPalette;
async function dataUrlToImageData(dataUrl, width, height) {
const image = new Image();
image.decoding = "async";
const loaded = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error("Failed to load rendered orbit frame"));
});
image.src = dataUrl;
await loaded;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return context.getImageData(0, 0, width, height);
}
function shouldEncodeTransparentGif(job = {}) {
const backgroundType = String(
job.appearance?.background?.type || ""
).toLowerCase();
return Boolean(job.render?.transparent) || backgroundType === "transparent";
}
function encodeGifFrameImageData(imageData, { transparent = false } = {}) {
if (!transparent) {
const palette = quantize(imageData.data, 256);
return {
indexed: applyPalette(imageData.data, palette),
palette,
transparent: false,
transparentIndex: 0
};
}
const palette = quantize(imageData.data, 256, {
format: "rgba4444",
oneBitAlpha: true
});
const transparentIndex = palette.findIndex((color) => Number(color?.[3]) <= 127);
return {
indexed: applyPalette(imageData.data, palette, "rgba4444"),
palette,
transparent: transparentIndex >= 0,
transparentIndex: Math.max(transparentIndex, 0)
};
}
async function capturePreparedSource(source, job) {
const context = renderJobContext(source.meshData, job);
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
if (context.mode === "list" || context.mode === "section") {
try {
return await captureModel({ model, context }, { job });
} finally {
model.dispose();
}
}
const viewport = renderModel(THREE, model, { job, context });
try {
return await captureModel(viewport, { job });
} finally {
viewport.dispose();
}
}
async function renderOrbit(source, job) {
const orbit = orbitFrameOutputs(job);
const frameResult = await capturePreparedSource(source, {
...job,
mode: "view",
outputs: orbit.outputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, orbit.width, orbit.height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, orbit.width, orbit.height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / orbit.fps,
repeat: 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: "orbit",
outputs: [{
path: orbit.path,
width: orbit.width,
height: orbit.height,
frameCount: orbit.frameCount,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
async function renderParamAnimation(source, job, stepParameterSource) {
const params = stepParameterSource.renderParameters;
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || 480)));
const frameOutputs = Array.from({ length: params.frameCount }, (_, index) => ({
...output,
path: "",
width,
height,
stepParameters: stepParameterFrameRuntime(stepParameterSource, index)
}));
const frameResult = await capturePreparedSource(source, {
...job,
outputs: frameOutputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, width, height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, width, height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / params.fps,
repeat: params.loop === false ? -1 : 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: String(job.mode || "view").toLowerCase(),
outputs: [{
path: String(output.path || job.output || ""),
width,
height,
frameCount: params.frameCount,
fps: params.fps,
durationSeconds: params.durationSeconds,
loop: params.loop !== false,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
export async function runHeadlessRenderJob(job) {
const source = await loadSource(job);
const stepParameterSource = source.stepParameterSource;
const explicitParams = hasStepParameterRenderValues(job.stepParameters);
const renderJob = {
...job,
selectorRuntime: source.selectorRuntime,
displayEdgeRuntime: source.displayEdgeRuntime
};
if (stepParameterSource && explicitParams && String(job.mode || "view").toLowerCase() !== "view") {
throw new Error("stepParameters support only view mode; set display.mode for display-style changes");
}
const renderJobWithStepParameters = stepParameterSource
? {
...renderJob,
stepParameters: stepParameterFrameRuntime(stepParameterSource, 0)
}
: renderJob;
if (stepParameterSource?.renderParameters?.animated) {
return renderParamAnimation(source, renderJob, stepParameterSource);
}
if (String(job.mode || "view").toLowerCase() === "orbit") {
return renderOrbit(source, renderJobWithStepParameters);
}
return capturePreparedSource(source, renderJobWithStepParameters);
}
if (typeof window !== "undefined") {
window.__snapshotRender = runHeadlessRenderJob;
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_ORBIT_DURATION_SECONDS,
DEFAULT_ORBIT_FPS,
orbitFrameOutputs
} from "./headlessOrbitFrames.js";
test("orbitFrameOutputs uses calm default orbit timing", () => {
const orbit = orbitFrameOutputs({
mode: "orbit",
outputs: [{ path: "orbit.gif" }]
});
assert.equal(orbit.fps, DEFAULT_ORBIT_FPS);
assert.equal(orbit.durationSeconds, DEFAULT_ORBIT_DURATION_SECONDS);
assert.equal(orbit.frameCount, 72);
assert.equal(orbit.outputs.length, 72);
assert.equal(orbit.outputs[0].camera, "-45:30");
assert.equal(orbit.outputs.at(-1).camera, "310:30");
});
test("orbitFrameOutputs honors explicit orbit timing", () => {
const orbit = orbitFrameOutputs({
mode: "orbit",
orbit: {
fps: 18,
durationSeconds: 4,
turns: 0.5,
startAzimuth: 10,
elevation: 20
},
outputs: [{ path: "orbit.gif" }]
});
assert.equal(orbit.fps, 18);
assert.equal(orbit.durationSeconds, 4);
assert.equal(orbit.frameCount, 72);
assert.equal(orbit.outputs[0].camera, "10:20");
assert.equal(orbit.outputs.at(-1).camera, "187.5:20");
});
@@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>CAD Viewer headless render</title>
<style>
html,
body {
margin: 0;
min-width: 100%;
min-height: 100%;
overflow: hidden;
background: transparent;
}
</style>
<script type="importmap">
{
"imports": {
"three": "/node_modules/three/build/three.module.js",
"three/examples/": "/node_modules/three/examples/",
"gifenc": "/node_modules/gifenc/dist/gifenc.esm.js"
}
}
</script>
</head>
<body>
<script type="module" src="/common/headlessRenderEntry.js"></script>
</body>
</html>

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