first commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
Executable
+3
@@ -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 "$@"
|
||||
Executable
+3
@@ -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 "$@"
|
||||
Executable
+3
@@ -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 "$@"
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
exec "$REPO_ROOT/scripts/git-hooks/pre-commit" "$@"
|
||||
Executable
+3
@@ -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 "$@"
|
||||
@@ -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
|
||||
Vendored
+29
@@ -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:
|
||||
- "*"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
[lfs]
|
||||
fetchexclude = assets/**,benchmarks/**,models/**,.agents/skills/**/assets/*.gif
|
||||
@@ -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`.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
[](https://discord.gg/5FGB9DwJYU)
|
||||
[](https://github.com/earthtojake/text-to-cad/stargazers)
|
||||
[](LICENSE)
|
||||
[](https://x.com/earthtojake)
|
||||
[](skills/cad/requirements.txt)
|
||||
[](skills/cad/SKILL.md)
|
||||
[](skills/cad/SKILL.md)
|
||||
[](skills/cad/SKILL.md)
|
||||
[](skills/urdf/SKILL.md)
|
||||
[](skills/sdf/SKILL.md)
|
||||
[](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
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,14 @@
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build and cache output
|
||||
dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local env
|
||||
.env*.local
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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;
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+11577
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
Binary file not shown.
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"git": {
|
||||
"deploymentEnabled": false
|
||||
},
|
||||
"buildCommand": "npm run build"
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user