first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Next.js
.next/
out/
# Dependencies
node_modules/
# Build and cache output
dist/
coverage/
*.tsbuildinfo
# Local env
.env*.local
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
".agents/**",
"out/**",
"build/**",
"node_modules/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+35
View File
@@ -0,0 +1,35 @@
import type { NextConfig } from "next";
import path from "node:path";
const repoRoot = path.resolve(process.cwd(), "..");
const cadJsPackageRoot = path.join(repoRoot, "packages/cadjs/src");
const docsThreeRoot = "./node_modules/three";
const docsThreeExamplesRoot = "./node_modules/three/examples";
const docsGltfLoader =
"./node_modules/three/examples/jsm/loaders/GLTFLoader.js";
const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1"],
experimental: {
externalDir: true,
},
images: {
remotePatterns: [
{
hostname: "www.skills.sh",
protocol: "https",
},
],
},
turbopack: {
root: repoRoot,
resolveAlias: {
"cadjs": cadJsPackageRoot,
three: docsThreeRoot,
"three/examples": docsThreeExamplesRoot,
"three/examples/jsm/loaders/GLTFLoader.js": docsGltfLoader,
},
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "cad-skills-docs",
"version": "0.3.9",
"license": "MIT",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"check": "node scripts/check-hero-step-assets.mjs && npm run lint && npm run build"
},
"dependencies": {
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
"three": "^0.160.0",
"tw-animate-css": "^1.4.0"
},
"overrides": {
"postcss": "^8.5.14"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.160.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

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

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

@@ -0,0 +1,105 @@
{
"id": "workbench-transparent",
"settings": {
"materials": {
"defaultColor": "#d7dce0",
"fillColors": ["#d7dce0", "#cdd3d8", "#e4e7ea", "#bfc7ce"],
"overrideSourceColors": true,
"tintMode": "blend",
"tintStrength": 0,
"saturation": 0,
"contrast": 1,
"brightness": 1,
"roughness": 0.92,
"metalness": 0,
"clearcoat": 0,
"clearcoatRoughness": 0.9,
"opacity": 1,
"envMapIntensity": 0,
"emissiveIntensity": 0
},
"edges": {
"enabled": true,
"contrastMode": "manual",
"source": "topology",
"topologyFilter": "feature",
"topologyMinRelevance": 1,
"color": "#111827",
"opacity": 0.96,
"thickness": 1,
"silhouette": true,
"silhouetteScale": 0.0025
},
"background": {
"type": "transparent",
"solidColor": "#f7f7f7",
"linearStart": "#f7f7f7",
"linearEnd": "#eeeeee",
"linearAngle": 0,
"radialInner": "#ffffff",
"radialOuter": "#eeeeee"
},
"floor": {
"mode": "none",
"color": "#f7f7f7",
"roughness": 1,
"reflectivity": 0,
"shadowOpacity": 0,
"horizonBlend": 0
},
"environment": {
"enabled": false,
"presetId": "studio-hdri-43",
"intensity": 0,
"rotationY": 0,
"useAsBackground": false
},
"lighting": {
"toneMappingExposure": 1,
"directional": {
"enabled": true,
"color": "#ffffff",
"intensity": 0.75,
"position": {
"x": -160,
"y": 220,
"z": 260
}
},
"spot": {
"enabled": false,
"color": "#ffffff",
"intensity": 0,
"angle": 0.74,
"distance": 0,
"position": {
"x": 190,
"y": 210,
"z": 170
}
},
"point": {
"enabled": false,
"color": "#ffffff",
"intensity": 0,
"distance": 0,
"position": {
"x": -240,
"y": 110,
"z": -210
}
},
"ambient": {
"enabled": true,
"color": "#ffffff",
"intensity": 0.82
},
"hemisphere": {
"enabled": true,
"skyColor": "#ffffff",
"groundColor": "#e5e7eb",
"intensity": 0.9
}
}
}
}
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { STEP_TOPOLOGY_SCHEMA_VERSION } from "../../packages/cadjs/src/common/stepTopology.mjs";
const docsRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const heroGlbPath = path.join(docsRoot, "public/hero/planetary_gear_assembly.step.glb");
function readGlbJson(filePath) {
const buffer = fs.readFileSync(filePath);
assert.equal(buffer.toString("ascii", 0, 4), "glTF", `${filePath} is not a GLB`);
const jsonLength = buffer.readUInt32LE(12);
const jsonChunkType = buffer.toString("ascii", 16, 20);
assert.equal(jsonChunkType, "JSON", `${filePath} is missing its JSON chunk`);
return JSON.parse(buffer.subarray(20, 20 + jsonLength).toString("utf8"));
}
const gltf = readGlbJson(heroGlbPath);
const topology = gltf.extensions?.STEP_topology;
assert.ok(topology, "Hero STEP GLB is missing the STEP_topology extension");
assert.equal(
topology.schemaVersion,
STEP_TOPOLOGY_SCHEMA_VERSION,
`Hero STEP GLB uses STEP_topology schemaVersion ${topology.schemaVersion}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`
);
console.log("Hero STEP asset schema is current.");
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
export function GET(request: Request) {
return NextResponse.redirect(
new URL("/hero/planetary_gear_assembly.step.js", request.url),
307
);
}
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
export function GET(request: Request) {
return NextResponse.redirect(
new URL("/hero/planetary_gear_assembly.step.glb", request.url),
307
);
}
+356
View File
@@ -0,0 +1,356 @@
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap");
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
color-scheme: light;
--background: hsl(218 27% 94%);
--foreground: hsl(220 16% 22%);
--card: hsl(218 27% 89%);
--card-foreground: hsl(220 16% 22%);
--popover: hsl(218 27% 89%);
--popover-foreground: hsl(220 16% 22%);
--primary: hsl(213 32% 40%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(219 28% 84%);
--secondary-foreground: hsl(220 16% 22%);
--muted: hsl(219 28% 84%);
--muted-foreground: hsl(220 17% 28%);
--accent: hsl(213 20% 86%);
--accent-foreground: hsl(213 32% 40%);
--destructive: hsl(355 60% 45%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(219 18% 72%);
--input: hsl(219 18% 72%);
--ring: hsl(213 32% 40%);
--chart-1: hsl(213 32% 44%);
--chart-2: hsl(210 34% 42%);
--chart-3: hsl(92 30% 36%);
--chart-4: hsl(40 70% 35%);
--chart-5: hsl(311 28% 42%);
--sidebar-background: hsl(218 27% 89%);
--sidebar: var(--sidebar-background);
--sidebar-foreground: hsl(220 16% 22%);
--sidebar-primary: hsl(213 32% 40%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(213 20% 86%);
--sidebar-accent-foreground: hsl(213 32% 40%);
--sidebar-border: hsl(219 18% 72%);
--sidebar-ring: hsl(213 32% 40%);
--smui-frost-1: 176 30% 35%;
--smui-frost-2: 193 40% 38%;
--smui-frost-3: 210 34% 40%;
--smui-frost-4: 213 32% 36%;
--smui-red: 355 55% 44%;
--smui-orange: 14 55% 44%;
--smui-yellow: 40 70% 34%;
--smui-green: 92 35% 34%;
--smui-purple: 311 28% 40%;
--smui-surface-0: 218 27% 94%;
--smui-surface-1: 218 27% 89%;
--smui-surface-2: 219 28% 84%;
--smui-surface-3: 219 20% 76%;
--smui-border-hover: 220 17% 50%;
--font-sans: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-serif: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-heading: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--radius: 0rem;
--shadow-x: 0;
--shadow-y: 0;
--shadow-blur: 0;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: none;
--shadow-xs: none;
--shadow-sm: none;
--shadow: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
--shadow-2xl: none;
--tracking-normal: 0em;
--spacing: 0.25rem;
--text-label: 11px;
--text-ui: 13px;
--text-heading: 22px;
--text-stat: 26px;
--text-hero: 42px;
}
.dark {
color-scheme: dark;
--background: hsl(213 16% 12%);
--foreground: hsl(213 27% 88%);
--card: hsl(217 16% 15.5%);
--card-foreground: hsl(213 27% 88%);
--popover: hsl(217 16% 15.5%);
--popover-foreground: hsl(213 27% 88%);
--primary: hsl(193 44% 67%);
--primary-foreground: hsl(213 16% 12%);
--secondary: hsl(216 15% 19%);
--secondary-foreground: hsl(213 27% 88%);
--muted: hsl(216 15% 19%);
--muted-foreground: hsl(213 14% 65%);
--accent: hsl(193 10% 16%);
--accent-foreground: hsl(193 44% 67%);
--destructive: hsl(355 52% 64%);
--destructive-foreground: hsl(219 28% 94%);
--border: hsl(217 17% 28%);
--input: hsl(217 17% 28%);
--ring: hsl(193 44% 67%);
--chart-1: hsl(193 44% 67%);
--chart-2: hsl(213 32% 52%);
--chart-3: hsl(92 28% 65%);
--chart-4: hsl(40 71% 73%);
--chart-5: hsl(311 24% 63%);
--sidebar-background: hsl(217 16% 15.5%);
--sidebar: var(--sidebar-background);
--sidebar-foreground: hsl(213 27% 88%);
--sidebar-primary: hsl(193 44% 67%);
--sidebar-primary-foreground: hsl(213 16% 12%);
--sidebar-accent: hsl(193 10% 16%);
--sidebar-accent-foreground: hsl(193 44% 67%);
--sidebar-border: hsl(217 17% 28%);
--sidebar-ring: hsl(193 44% 67%);
--smui-frost-1: 176 25% 65%;
--smui-frost-2: 193 44% 67%;
--smui-frost-3: 210 34% 63%;
--smui-frost-4: 213 32% 52%;
--smui-red: 355 52% 64%;
--smui-orange: 14 51% 63%;
--smui-yellow: 40 71% 73%;
--smui-green: 92 28% 65%;
--smui-purple: 311 24% 63%;
--smui-surface-0: 213 16% 12%;
--smui-surface-1: 217 16% 15.5%;
--smui-surface-2: 216 15% 19%;
--smui-surface-3: 215 14% 22%;
--smui-border-hover: 216 12% 37%;
--font-sans: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-serif: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-heading: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--radius: 0rem;
--shadow-x: 0;
--shadow-y: 0;
--shadow-blur: 0;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: none;
--shadow-xs: none;
--shadow-sm: none;
--shadow: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
--shadow-2xl: none;
--text-label: 11px;
--text-ui: 13px;
--text-heading: 22px;
--text-stat: 26px;
--text-hero: 42px;
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar-background: var(--sidebar-background);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-mono: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-serif: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-heading: "JetBrains Mono", "JetBrains Mono Fallback", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--radius-sm: var(--radius);
--radius-md: var(--radius);
--radius-lg: var(--radius);
--radius-xl: var(--radius);
--shadow-2xs: none;
--shadow-xs: none;
--shadow-sm: none;
--shadow: none;
--shadow-md: none;
--shadow-lg: none;
--shadow-xl: none;
--shadow-2xl: none;
--text-label: 11px;
--text-ui: 13px;
--text-heading: 22px;
--text-stat: 26px;
--text-hero: 42px;
}
@theme inline {
--color-smui-frost-1: hsl(var(--smui-frost-1));
--color-smui-frost-2: hsl(var(--smui-frost-2));
--color-smui-frost-3: hsl(var(--smui-frost-3));
--color-smui-frost-4: hsl(var(--smui-frost-4));
--color-smui-red: hsl(var(--smui-red));
--color-smui-orange: hsl(var(--smui-orange));
--color-smui-yellow: hsl(var(--smui-yellow));
--color-smui-green: hsl(var(--smui-green));
--color-smui-purple: hsl(var(--smui-purple));
--color-smui-surface-0: hsl(var(--smui-surface-0));
--color-smui-surface-1: hsl(var(--smui-surface-1));
--color-smui-surface-2: hsl(var(--smui-surface-2));
--color-smui-surface-3: hsl(var(--smui-surface-3));
--color-smui-border-hover: hsl(var(--smui-border-hover));
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
@apply min-h-full scroll-smooth font-sans;
}
body {
@apply min-h-screen bg-background text-foreground antialiased;
letter-spacing: var(--tracking-normal);
}
::selection {
background: hsl(var(--smui-frost-2) / 0.2);
color: hsl(var(--smui-frost-2));
}
}
@layer components {
@keyframes ascii-terminal-reveal {
0% {
clip-path: inset(0 100% 0 0);
opacity: 1;
}
82% {
clip-path: inset(0 0 0 0);
opacity: 1;
}
100% {
clip-path: inset(0 0 0 0);
opacity: 1;
}
}
@keyframes ascii-terminal-sheen {
0% {
transform: translateX(-115%);
}
72% {
transform: translateX(115%);
}
100% {
transform: translateX(115%);
}
}
@keyframes agent-carousel {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-50% - 0.25rem));
}
}
.ascii-terminal-reveal {
animation: ascii-terminal-reveal 1.05s steps(16, end) both;
overflow: hidden;
}
.ascii-terminal-reveal::after {
animation: ascii-terminal-sheen 1.05s ease-out both;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--primary) 8%, transparent) 34%,
var(--primary) 50%,
color-mix(in srgb, var(--foreground) 45%, transparent) 62%,
transparent 100%
);
content: "";
inset: 0;
pointer-events: none;
position: absolute;
}
.agent-carousel-track {
animation: agent-carousel 72s linear infinite;
}
.agent-carousel:hover .agent-carousel-track {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.ascii-terminal-reveal,
.ascii-terminal-reveal::after,
.agent-carousel-track {
animation: none;
}
.agent-carousel {
overflow-x: auto;
}
}
.card-glow {
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.card-glow:hover {
border-color: hsl(var(--smui-border-hover));
}
}
+119
View File
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Analytics } from "@vercel/analytics/next";
import { TooltipProvider } from "@/components/ui/tooltip";
import { siteConfig } from "@/lib/site";
import "./globals.css";
const socialPreview = {
url: "/social-preview-gear.png",
width: 1200,
height: 630,
alt: "CAD Skills homepage showing a planetary gear CAD model",
};
const themeScript = `
(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const storageKey = "cad-skills-theme-v2";
const applyTheme = (theme) => {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
};
const getStoredTheme = () => {
try {
const storedTheme = window.localStorage.getItem(storageKey);
return storedTheme === "dark" || storedTheme === "light"
? storedTheme
: null;
} catch {
return null;
}
};
applyTheme(getStoredTheme() ?? "dark");
const handleSystemThemeChange = () => {
if (!getStoredTheme()) {
applyTheme("dark");
}
};
try {
media.addEventListener("change", handleSystemThemeChange);
} catch {
media.addListener(handleSystemThemeChange);
}
})();
`;
export const metadata: Metadata = {
metadataBase: new URL(siteConfig.origin),
applicationName: siteConfig.name,
title: {
default: siteConfig.title,
template: `%s | ${siteConfig.name}`,
},
description: siteConfig.description,
keywords: siteConfig.keywords,
alternates: {
canonical: "/",
},
openGraph: {
title: siteConfig.title,
description: siteConfig.description,
url: "/",
siteName: siteConfig.name,
images: [socialPreview],
type: "website",
},
twitter: {
card: "summary_large_image",
title: siteConfig.title,
description: siteConfig.description,
images: [socialPreview],
},
icons: {
icon: [
{ url: "/favicon.ico?v=planetary-gear-workbench", type: "image/x-icon" },
],
shortcut: [
{ url: "/favicon.ico?v=planetary-gear-workbench", type: "image/x-icon" },
],
apple: [
{ url: "/favicon.png?v=planetary-gear-workbench", type: "image/png" },
],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
"max-video-preview": -1,
},
},
category: "engineering",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Script
id="theme-script"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: themeScript }}
/>
<TooltipProvider>{children}</TooltipProvider>
<Analytics />
</body>
</html>
);
}
+352
View File
@@ -0,0 +1,352 @@
import Image from "next/image";
import { ExternalLink } from "lucide-react";
import { CopyButton } from "@/components/copy-button";
import { HeroSection } from "@/components/hero-section";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
const skillsInstallCommand = "npx skills install earthtojake/text-to-cad";
const pluginInstallCommands = [
{
agent: "Codex",
command:
"codex plugin marketplace add earthtojake/text-to-cad\ncodex plugin add cad@text-to-cad",
},
{
agent: "Claude Code",
command:
"claude plugin marketplace add earthtojake/text-to-cad\nclaude plugin install cad@text-to-cad",
},
];
const supportedAgents = [
{ name: "Claude Code", slug: "claude-code", icon: "claude-code.svg" },
{ name: "Codex", slug: "codex", icon: "codex.svg" },
];
const skillGroups = [
{
name: "CAD",
path: "skills/cad",
summary:
"Creates and edits CAD models from plain-language or image requests, with STEP as the main output along with options to export to STL, 3MF and GLB.",
},
{
name: "CAD Viewer",
path: "skills/cad-viewer",
summary: "Shows local browser previews for CAD, G-code, and robot files.",
},
{
name: "step.parts",
path: "skills/step-parts",
summary:
"Finds off-the-shelf STEP parts like screws, bearings, motors, and connectors.",
},
{
name: "DXF",
path: "skills/dxf",
summary:
"Creates 2D DXF drawings like profiles, templates, gaskets, and cut layouts from Python sources or CAD geometry.",
},
{
name: "URDF",
path: "skills/urdf",
summary:
"Writes robot structure files with links, joints, limits, inertials, and meshes.",
},
{
name: "SRDF",
path: "skills/srdf",
summary:
"Adds MoveIt planning groups, end effectors, poses, and collision rules to a URDF.",
},
{
name: "SDF",
path: "skills/sdf",
summary:
"Creates simulator models and worlds with frames, physics, sensors, and lights.",
},
{
name: "SendCutSend",
path: "skills/sendcutsend",
summary: "Checks DXF and STEP files before upload to SendCutSend.",
},
{
name: "G-code",
path: "skills/gcode",
summary:
"Slices supported mesh files into validated, printer-profiled FDM .gcode with real slicer CLIs.",
},
{
name: "Bambu Labs",
path: "skills/bambu-labs",
summary:
"Dry-runs, uploads, and cautiously starts local Bambu Lab print jobs from validated .gcode.",
},
{
name: "Implicit CAD",
path: "skills/implicit-cad",
summary:
"Creates browser-native implicit CAD models using GLSL signed-distance fields and CAD Viewer raymarch rendering. Experimental.",
},
];
function InstallCommand({
item,
}: {
item: (typeof pluginInstallCommands)[number];
}) {
return (
<div className="min-w-0 max-w-full overflow-hidden border border-border bg-card">
<div className="border-b border-border px-3 py-2 text-label uppercase tracking-[1.5px] text-muted-foreground">
{item.agent}
</div>
<div className="flex min-h-[54px] min-w-0 max-w-full items-stretch">
<code className="flex min-w-0 flex-1 items-center overflow-x-auto whitespace-pre px-3 py-2 text-sm leading-6 text-foreground">
{item.command}
</code>
<CopyButton
text={item.command}
label={`Copy ${item.agent} install command`}
compact
/>
</div>
</div>
);
}
function InstallCommands() {
return (
<div className="grid min-w-0 gap-2">
{pluginInstallCommands.map((item) => (
<InstallCommand key={item.agent} item={item} />
))}
</div>
);
}
function SkillsInstallCommand() {
return (
<div className="min-w-0 max-w-full overflow-hidden border border-border bg-card">
<div className="flex min-h-[54px] min-w-0 max-w-full items-stretch">
<code className="flex min-w-0 flex-1 items-center overflow-x-auto whitespace-pre px-3 py-2 text-sm leading-6 text-foreground">
{skillsInstallCommand}
</code>
<CopyButton
text={skillsInstallCommand}
label="Copy Skills CLI install command"
compact
/>
</div>
</div>
);
}
function AgentTile({
agent,
hidden = false,
}: {
agent: (typeof supportedAgents)[number];
hidden?: boolean;
}) {
return (
<a
aria-hidden={hidden ? "true" : undefined}
aria-label={`Skills for ${agent.name}`}
className="group flex h-[54px] w-[150px] shrink-0 items-center gap-2.5 border border-border bg-card px-2.5 text-muted-foreground transition hover:bg-secondary hover:text-foreground sm:w-[168px]"
href={`https://www.skills.sh/agent/${agent.slug}`}
rel="noreferrer"
tabIndex={hidden ? -1 : undefined}
target="_blank"
>
<span className="flex size-7 shrink-0 items-center justify-center">
<Image
alt=""
src={`https://www.skills.sh/agents/${agent.icon}`}
width={44}
height={44}
unoptimized
className="size-5 object-contain opacity-70 grayscale transition group-hover:opacity-100 group-hover:grayscale-0 dark:invert"
/>
</span>
<span className="min-w-0 truncate text-label uppercase tracking-[1.3px]">
{agent.name}
</span>
</a>
);
}
function AgentCarousel() {
return (
<div className="agent-carousel relative min-h-[54px] min-w-0 overflow-hidden">
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-12 bg-gradient-to-r from-background to-transparent sm:w-20" />
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-12 bg-gradient-to-l from-background to-transparent sm:w-20" />
<div className="agent-carousel-track flex w-max gap-2">
{supportedAgents.map((agent) => (
<AgentTile key={agent.slug} agent={agent} />
))}
{supportedAgents.map((agent) => (
<AgentTile key={`${agent.slug}-duplicate`} agent={agent} hidden />
))}
</div>
</div>
);
}
function SectionIntro({
id,
title,
description,
}: {
id?: string;
title: string;
description: string;
}) {
return (
<div>
<h2
id={id}
className="text-heading font-medium tracking-normal text-foreground"
>
{title}
</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
{description}
</p>
</div>
);
}
function SkillLink({ skill }: { skill: (typeof skillGroups)[number] }) {
return (
<a
className="inline-flex min-w-0 items-center gap-1.5 text-label uppercase tracking-[1.5px] text-primary transition hover:text-primary/80"
href={`https://github.com/earthtojake/text-to-cad/blob/main/${skill.path}/SKILL.md`}
target="_blank"
rel="noreferrer"
>
<span className="truncate">{skill.path}</span>
<ExternalLink className="size-3 shrink-0" />
</a>
);
}
export default function Home() {
return (
<main className="min-h-screen bg-background text-foreground">
<SiteHeader />
<div className="mx-auto w-full max-w-[1200px] px-4 py-4 sm:px-6">
<div className="min-w-0 space-y-2">
<HeroSection />
<section
aria-label="Install CAD Skills with supported agents"
className="grid gap-5 py-6 lg:grid-cols-2 lg:items-center lg:gap-12"
>
<div className="min-w-0 space-y-3">
<h2 className="text-sm font-medium uppercase tracking-[1.5px] text-foreground">
Try It Now
</h2>
<SkillsInstallCommand />
</div>
<div className="min-w-0 space-y-3">
<h2 className="text-sm font-medium uppercase tracking-[1.5px] text-foreground">
Supported Agents
</h2>
<AgentCarousel />
</div>
</section>
<section
id="skills"
aria-labelledby="skills-title"
className="scroll-mt-20 space-y-3 py-6"
>
<SectionIntro
id="skills-title"
title="SKILLS"
description="Install the library to give agents focused workflows for CAD, fabrication, robot description files, simulation, and local review."
/>
<div className="border border-border bg-card">
<div className="grid grid-cols-[minmax(0,1fr)] border-b border-border px-3.5 py-2.5 text-xs uppercase tracking-[1.5px] text-muted-foreground md:grid-cols-[minmax(9rem,12rem)_minmax(0,1fr)_max-content] md:gap-5 md:pl-0 md:pr-3.5">
<span className="md:pl-3.5">skill</span>
<span className="hidden md:block">summary</span>
<span className="hidden text-right md:block">source</span>
</div>
<ul className="divide-y divide-border">
{skillGroups.map((skill) => (
<li
key={skill.name}
className="card-glow grid gap-3 px-3.5 py-3 hover:bg-secondary/60 md:grid-cols-[minmax(9rem,12rem)_minmax(0,1fr)_max-content] md:items-center md:gap-5 md:pl-0 md:pr-3.5"
>
<div className="flex min-w-0 items-center md:pl-3.5">
<div className="min-w-0">
<h3 className="text-sm font-medium text-foreground">
{skill.name}
</h3>
<p className="mt-0.5 text-label uppercase tracking-wider text-muted-foreground md:hidden">
{skill.path}
</p>
</div>
</div>
<div className="min-w-0 text-sm leading-6 text-muted-foreground">
<p>{skill.summary}</p>
</div>
<div className="min-w-0 md:justify-self-end md:pt-0.5 md:text-right">
<SkillLink skill={skill} />
</div>
</li>
))}
</ul>
</div>
</section>
<section
id="installation"
aria-labelledby="installation-title"
className="scroll-mt-20 space-y-3 py-6"
>
<SectionIntro
id="installation-title"
title="INSTALL"
description="Install CAD Skills with the Skills CLI. Provider-native plugin installs are available as a secondary path."
/>
<div className="max-w-3xl space-y-3">
<SkillsInstallCommand />
<div className="pt-3">
<h3 className="mb-3 text-sm font-medium uppercase tracking-[1.5px] text-foreground">
Plugin Installs
</h3>
<InstallCommands />
</div>
<p className="text-sm leading-6 text-muted-foreground">
Skills CLI installation is preferred for regular use. Restart
your agent if newly installed skills do not appear.
</p>
<p className="text-sm leading-6 text-muted-foreground">
Local development symlink guidance lives in{" "}
<a
className="inline-flex items-center gap-1 text-primary transition hover:text-primary/80"
href="https://github.com/earthtojake/text-to-cad/blob/main/CONTRIBUTING.md"
rel="noreferrer"
target="_blank"
>
CONTRIBUTING.md
<ExternalLink className="size-3" aria-hidden="true" />
</a>
.
</p>
</div>
</section>
</div>
</div>
<SiteFooter />
</main>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { absoluteUrl } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: absoluteUrl("/sitemap.xml"),
};
}
+12
View File
@@ -0,0 +1,12 @@
import type { MetadataRoute } from "next";
import { absoluteUrl } from "@/lib/site";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: absoluteUrl("/"),
changeFrequency: "weekly",
priority: 1,
},
];
}
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState } from "react";
type CopyButtonProps = {
text: string;
label?: string;
compact?: boolean;
};
type CopyStatus = "idle" | "copied" | "error";
export function CopyButton({
text,
label = "Copy command",
compact = false,
}: CopyButtonProps) {
const [status, setStatus] = useState<CopyStatus>("idle");
useEffect(() => {
if (status === "idle") {
return;
}
const timeout = window.setTimeout(() => setStatus("idle"), 1600);
return () => window.clearTimeout(timeout);
}, [status]);
const copyText = async () => {
setStatus("copied");
try {
await window.navigator.clipboard.writeText(text);
} catch {
setStatus("error");
}
};
const isCopied = status === "copied";
const isError = status === "error";
const buttonLabel = isCopied ? "Copied" : isError ? "Copy failed" : label;
return (
<button
type="button"
className={`min-w-[4.25rem] shrink-0 cursor-pointer border-l border-border py-[7px] text-xs uppercase leading-none tracking-wider transition-colors ${
isCopied
? "bg-primary text-primary-foreground hover:text-primary-foreground"
: isError
? "bg-destructive text-destructive-foreground hover:text-destructive-foreground"
: "bg-secondary text-muted-foreground hover:text-foreground"
} ${
compact ? "px-2.5" : "px-3"
}`}
onClick={copyText}
aria-label={buttonLabel}
title={buttonLabel}
aria-live="polite"
>
{isCopied ? "copied" : isError ? "failed" : "copy"}
</button>
);
}
@@ -0,0 +1,141 @@
"use client";
import type { CSSProperties } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import { HeroStepRender } from "@/components/hero-step-render";
const cadSkillsAscii = String.raw` ██████╗ █████╗ ██████╗ ███████╗██╗ ██╗██╗██╗ ██╗ ███████╗
██╔════╝██╔══██╗██╔══██╗ ██╔════╝██║ ██╔╝██║██║ ██║ ██╔════╝
██║ ███████║██║ ██║ ███████╗█████╔╝ ██║██║ ██║ ███████╗
██║ ██╔══██║██║ ██║ ╚════██║██╔═██╗ ██║██║ ██║ ╚════██║
╚██████╗██║ ██║██████╔╝ ███████║██║ ██╗██║███████╗███████╗███████║
╚═════╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝╚══════╝╚══════╝╚══════╝`;
const ASCII_MEASURE_FONT_SIZE = 10;
const ASCII_MIN_FONT_SIZE = 7;
const ASCII_MAX_FONT_SIZE = 18;
const ASCII_MAX_HEIGHT = 132;
const ASCII_WIDTH_FILL = 0.995;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function AsciiWord({ ascii }: { ascii: string }) {
const frameRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLPreElement>(null);
const [fontSize, setFontSize] = useState(10.5);
useLayoutEffect(() => {
const frame = frameRef.current;
const measure = measureRef.current;
if (!frame || !measure) {
return;
}
const updateFontSize = () => {
const measuredWidth = measure.scrollWidth;
const measuredHeight = measure.scrollHeight;
const availableWidth = frame.clientWidth * ASCII_WIDTH_FILL;
if (!measuredWidth || !measuredHeight || !availableWidth) {
return;
}
const widthSize =
(availableWidth / measuredWidth) * ASCII_MEASURE_FONT_SIZE;
const heightSize =
(ASCII_MAX_HEIGHT / measuredHeight) * ASCII_MEASURE_FONT_SIZE;
const nextFontSize = clamp(
Math.min(widthSize, heightSize),
ASCII_MIN_FONT_SIZE,
ASCII_MAX_FONT_SIZE
);
setFontSize((current) =>
Math.abs(current - nextFontSize) > 0.05 ? nextFontSize : current
);
};
updateFontSize();
const resizeObserver = new ResizeObserver(updateFontSize);
resizeObserver.observe(frame);
void document.fonts?.ready.then(updateFontSize);
return () => resizeObserver.disconnect();
}, [ascii]);
const visibleClassName =
"inline-block select-none whitespace-pre font-mono leading-[108%]";
const measureClassName =
"pointer-events-none invisible absolute left-0 top-0 inline-block whitespace-pre font-mono text-[10px] leading-[108%]";
const asciiStyle = { fontSize: `${fontSize}px` } as CSSProperties;
return (
<div
ref={frameRef}
className="relative max-h-[132px] w-full overflow-hidden"
>
<pre ref={measureRef} className={measureClassName} aria-hidden="true">
{ascii}
</pre>
<pre
className={`${visibleClassName} text-muted-foreground/55`}
style={asciiStyle}
>
{ascii}
</pre>
<pre
className={`ascii-terminal-reveal absolute left-0 top-0 ${visibleClassName} text-foreground`}
style={asciiStyle}
>
{ascii}
</pre>
</div>
);
}
function HeroAsciiHeading() {
return (
<div aria-hidden="true" className="w-full max-w-full overflow-hidden">
<AsciiWord ascii={cadSkillsAscii} />
</div>
);
}
function HeroSubtitle({ className = "" }: { className?: string }) {
return (
<p
className={`text-[17px] font-medium leading-7 text-foreground sm:text-[20px] sm:leading-8 lg:text-[22px] lg:leading-9 ${className}`}
>
Generate CAD in AI agents like Codex and Claude Code.{" "}
<span className="text-primary">
100% open source, runs locally, free forever.
</span>
</p>
);
}
export function HeroSection() {
return (
<section className="border border-border bg-card">
<div className="grid min-w-0 lg:grid-cols-2">
<div className="flex min-w-0 items-center px-4 py-4 sm:px-5 lg:min-h-[156px] lg:px-6 lg:py-5">
<h1 className="sr-only">CAD Skills</h1>
<HeroAsciiHeading />
</div>
<div className="flex min-w-0 items-center border-t border-border px-4 py-4 sm:px-5 lg:min-h-[156px] lg:border-l lg:border-t-0 lg:px-6 lg:py-5">
<HeroSubtitle className="w-full lg:max-w-[34rem]" />
</div>
</div>
<div className="h-[260px] min-h-0 border-t border-border bg-background sm:h-[300px] lg:h-[340px]">
<HeroStepRender />
</div>
</section>
);
}
@@ -0,0 +1,455 @@
"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import {
CAD_SCENE_SCALE,
buildModel,
} from "cadjs/common/cadScene.js";
import { renderModel } from "cadjs/common/renderModel.js";
import { loadSource } from "cadjs/common/source.js";
import {
normalizeParameterValue,
normalizeStepModuleParameterValues as normalizeStepParameterValues,
} from "cadjs/common/stepModule.js";
import { cloneThemeSettings } from "cadjs/common/themeSettings.js";
const HERO_STEP_URL = "/hero/planetary_gear_assembly.step.glb";
const HERO_STEP_PARAMETER_URL = "/hero/planetary_gear_assembly.step.js";
const HERO_STEP_CAD_PATH = "models/fun/planetary_gear_assembly.step";
const HERO_STEP_DEMO_URL =
"https://demo.cadskills.xyz/?file=fun%2Fplanetary_gear_assembly.step";
const HERO_STEP_LABEL = "PLANETARY_GEAR_ASSEMBLY.STEP";
const GEAR_MESH_ANIMATION_SPEED = 0.14;
const HERO_STEP_PARAMETER_VALUES = {
drive: 0,
explode: 0,
highlightMeshing: false,
orbitGuides: false,
ringVisible: true,
viewMode: "mesh",
};
type PreviewScheme = "dark" | "light";
type StepParameterDefinition = NonNullable<
Awaited<ReturnType<typeof loadSource>>["stepParameterSource"]
>["definition"];
type StepParameterAnimation = StepParameterDefinition["animations"][number];
type StepParameterRuntime = {
animation: StepParameterAnimation | null;
animationElapsedSec: number;
animationState: {
activeId: string;
duration: number;
elapsedSec: number;
loop: boolean;
playing: boolean;
speed: number;
};
cadPath: string;
definition: StepParameterDefinition;
parameterValues: Record<string, unknown>;
selectorRuntime: null;
sourceUrl: string;
};
const STEP_PREVIEW_PALETTES = {
dark: {
background: "#111820",
border: "#3b4553",
fill: ["#c7d0d8", "#aeb9c3", "#d9dee3", "#8f9ba7"],
headerBackground: "rgba(17, 24, 32, 0.9)",
headerText: "#c9d3df",
keyLight: "#f6f8fb",
keyLightIntensity: 2.5,
fillLight: "#7f95ad",
fillLightIntensity: 0.8,
ambientLight: "#eef4fa",
ambientLightIntensity: 1.85,
},
light: {
background: "#eef1f5",
border: "#c9cfda",
fill: ["#d7dce0", "#cdd3d8", "#e4e7ea", "#bfc7ce"],
headerBackground: "rgba(238, 241, 245, 0.9)",
headerText: "#4c566a",
keyLight: "#ffffff",
keyLightIntensity: 2.6,
fillLight: "#cfd8e3",
fillLightIntensity: 0.9,
ambientLight: "#ffffff",
ambientLightIntensity: 2.2,
},
} as const;
function currentPreviewScheme(): PreviewScheme {
if (typeof document === "undefined") {
return "dark";
}
return document.documentElement.classList.contains("dark") ? "dark" : "light";
}
function buildWorkbenchTheme(scheme: PreviewScheme) {
const palette = STEP_PREVIEW_PALETTES[scheme];
const theme = cloneThemeSettings("workbench");
const materials =
theme.materials && typeof theme.materials === "object"
? theme.materials
: {};
return {
...theme,
materials: {
...materials,
defaultColor: palette.fill[0],
fillColors: [...palette.fill],
overrideSourceColors: false,
saturation: 1,
contrast: 1,
brightness: 1,
tintStrength: 0,
roughness: 0.92,
metalness: 0,
clearcoat: 0,
envMapIntensity: 0,
},
edges: {
...(theme.edges || {}),
enabled: true,
color: scheme === "dark" ? "#202b38" : "#2f3a4b",
contrastMode: "manual",
opacity: 1,
silhouette: true,
thickness: 1,
},
background: {
type: "solid",
solidColor: palette.background,
},
lighting: {
ambient: {
color: palette.ambientLight,
enabled: true,
intensity: palette.ambientLightIntensity,
},
directionalLights: [
{
color: palette.keyLight,
enabled: true,
intensity: palette.keyLightIntensity,
position: { x: -120, y: 180, z: 240 },
},
{
color: palette.fillLight,
enabled: true,
intensity: palette.fillLightIntensity,
position: { x: 180, y: -120, z: 120 },
},
],
hemisphere: {
enabled: false,
},
},
};
}
function createHeroStepParameterRuntime(
definition: StepParameterDefinition,
parameterUrl: string
): StepParameterRuntime {
const animation =
definition.animations.find((item) => item.id === "meshCycle") ??
definition.animations[0] ??
null;
const duration = Math.max(Number(animation?.duration) || 6, 0.001);
return {
animation,
animationElapsedSec: 0,
animationState: {
activeId: animation?.id ?? "",
duration,
elapsedSec: 0,
loop: animation?.loop !== false,
playing: false,
speed: GEAR_MESH_ANIMATION_SPEED,
},
cadPath: HERO_STEP_CAD_PATH,
definition,
parameterValues: normalizeStepParameterValues(
definition,
HERO_STEP_PARAMETER_VALUES
),
selectorRuntime: null,
sourceUrl: parameterUrl,
};
}
function advanceStepParameterRuntime(
runtime: StepParameterRuntime,
deltaSeconds: number
) {
const animation = runtime.animation;
if (!animation?.update) {
return;
}
const duration = Math.max(Number(animation.duration) || 6, 0.001);
const nextElapsed =
(runtime.animationElapsedSec +
Math.max(deltaSeconds, 0) * GEAR_MESH_ANIMATION_SPEED) %
duration;
const progress = nextElapsed / duration;
const currentValues = normalizeStepParameterValues(
runtime.definition,
runtime.parameterValues
);
const nextValues = { ...currentValues };
const set = (parameterId: string, value: unknown) => {
const id = String(parameterId || "").trim();
const parameter = runtime.definition.parameterMap?.[id];
if (!parameter) {
return;
}
nextValues[id] = normalizeParameterValue(parameter, value);
};
animation.update({
cycle: nextElapsed / duration,
duration,
elapsed: nextElapsed,
elapsedSec: nextElapsed,
loop: animation.loop !== false,
params: currentValues,
progress,
set,
});
runtime.animationElapsedSec = nextElapsed;
// The hero drives sidecar parameter values directly; keep time.playing false so
// sidecar-only inspection highlights do not override native STEP colors.
runtime.animationState = {
activeId: animation.id,
duration,
elapsedSec: nextElapsed,
loop: animation.loop !== false,
playing: false,
speed: GEAR_MESH_ANIMATION_SPEED,
};
runtime.parameterValues = nextValues;
}
export function HeroStepRender() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const [scheme, setScheme] = useState<PreviewScheme>("dark");
const [status, setStatus] = useState("loading step");
const palette = STEP_PREVIEW_PALETTES[scheme];
useEffect(() => {
const syncScheme = () => {
setScheme(currentPreviewScheme());
};
syncScheme();
const observer = new MutationObserver(syncScheme);
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true,
});
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
let disposed = false;
let cadModel: ReturnType<typeof buildModel> | null = null;
let viewport: ReturnType<typeof renderModel> | null = null;
let stepParameterRuntime: StepParameterRuntime | null = null;
const dragState = {
active: false,
lastX: 0,
lastY: 0,
pitch: 0.18,
pointerId: -1,
yaw: -0.18,
};
const beforeRender = ({ deltaSeconds }: { deltaSeconds: number }) => {
if (disposed || !cadModel) {
return;
}
if (stepParameterRuntime) {
advanceStepParameterRuntime(stepParameterRuntime, deltaSeconds);
cadModel.update({ stepParameters: stepParameterRuntime });
}
if (!dragState.active) {
dragState.yaw += 0.0018;
}
cadModel.root.rotation.x = dragState.pitch;
cadModel.root.rotation.z = dragState.yaw;
};
const handlePointerDown = (event: PointerEvent) => {
dragState.active = true;
dragState.lastX = event.clientX;
dragState.lastY = event.clientY;
dragState.pointerId = event.pointerId;
canvas.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent) => {
if (!dragState.active || event.pointerId !== dragState.pointerId) {
return;
}
const dx = event.clientX - dragState.lastX;
const dy = event.clientY - dragState.lastY;
dragState.lastX = event.clientX;
dragState.lastY = event.clientY;
dragState.yaw += dx * 0.008;
dragState.pitch = Math.max(
-0.85,
Math.min(0.85, dragState.pitch + dy * 0.006)
);
};
const handlePointerEnd = (event: PointerEvent) => {
if (event.pointerId !== dragState.pointerId) {
return;
}
dragState.active = false;
dragState.pointerId = -1;
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
};
const load = async () => {
try {
setStatus("loading step");
const parameterUrl = `${HERO_STEP_PARAMETER_URL}?v=${Date.now()}`;
const source = await loadSource({
kind: "step",
glbUrl: HERO_STEP_URL,
stepParameterUrl: parameterUrl,
cadPath: HERO_STEP_CAD_PATH,
stepParameters: HERO_STEP_PARAMETER_VALUES,
});
if (disposed) {
return;
}
if (!source.stepParameterSource?.definition) {
throw new Error("missing STEP parameter sidecar");
}
stepParameterRuntime = createHeroStepParameterRuntime(
source.stepParameterSource.definition,
parameterUrl
);
cadModel = buildModel(THREE, source, {
theme: buildWorkbenchTheme(scheme),
displayMode: "solid",
stepParameters: stepParameterRuntime,
scale: CAD_SCENE_SCALE.CAD,
selection: {
showEdges: true,
},
edgeRendering: {
mode: "screen-space",
Line2,
LineGeometry,
LineMaterial,
LineSegments2,
LineSegmentsGeometry,
},
});
cadModel.root.rotation.x = dragState.pitch;
cadModel.root.rotation.z = dragState.yaw;
viewport = renderModel(THREE, cadModel, {
autoStart: true,
beforeRender,
canvas,
direction: [1, -1, 0.65],
hostElement: viewportRef.current ?? canvas,
lockedHalfHeightScale: 0.86,
padding: 0.1,
scale: CAD_SCENE_SCALE.CAD,
theme: buildWorkbenchTheme(scheme),
});
setStatus(HERO_STEP_LABEL);
} catch (error) {
setStatus(error instanceof Error ? error.message : "render failed");
}
};
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerEnd);
canvas.addEventListener("pointercancel", handlePointerEnd);
void load();
return () => {
disposed = true;
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerEnd);
canvas.removeEventListener("pointercancel", handlePointerEnd);
viewport?.dispose();
};
}, [palette, scheme]);
return (
<div
className="flex h-full min-h-0 flex-col overflow-hidden"
style={{ backgroundColor: palette.background }}
>
<div ref={viewportRef} className="relative min-h-0 flex-1 overflow-hidden">
<canvas
ref={canvasRef}
aria-label="Light 3D render of a sample STEP planetary gear assembly"
className="absolute inset-0 h-full w-full cursor-grab touch-none active:cursor-grabbing"
/>
</div>
<div
className="flex min-h-8 shrink-0 items-center justify-between gap-3 border-t px-3 py-[7px] text-label uppercase leading-none tracking-[1.5px]"
style={{
backgroundColor: palette.headerBackground,
borderColor: palette.border,
color: palette.headerText,
}}
>
{status === HERO_STEP_LABEL ? (
<a
className="min-w-0 truncate transition hover:text-primary"
href={HERO_STEP_DEMO_URL}
target="_blank"
rel="noreferrer"
title="Open planetary gear assembly in the CAD Skills demo"
>
{status}
</a>
) : (
<span className="min-w-0 truncate">{status}</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,16 @@
export function SiteFooter() {
return (
<footer className="border-t border-border py-6">
<div className="mx-auto flex w-full max-w-[1200px] justify-end px-4 text-label uppercase tracking-[1.5px] text-muted-foreground sm:px-6">
<a
className="transition hover:text-primary"
href="https://x.com/earthtojake"
target="_blank"
rel="noreferrer"
>
Made by @earthtojake
</a>
</div>
</footer>
);
}
@@ -0,0 +1,178 @@
"use client";
import Link from "next/link";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const GITHUB_REPO_URL = "https://github.com/earthtojake/text-to-cad";
function DiscordLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M20.32 4.37a19.8 19.8 0 0 0-4.89-1.51.07.07 0 0 0-.07.03c-.21.38-.44.86-.61 1.25a18.27 18.27 0 0 0-5.49 0 12.64 12.64 0 0 0-.62-1.25.08.08 0 0 0-.07-.03 19.74 19.74 0 0 0-4.89 1.51.07.07 0 0 0-.03.03C.53 9.05-.32 13.58.1 18.06a.08.08 0 0 0 .03.06 19.9 19.9 0 0 0 5.99 3.03.08.08 0 0 0 .08-.03c.46-.63.87-1.3 1.23-1.99a.08.08 0 0 0-.04-.11 13.1 13.1 0 0 1-1.87-.89.08.08 0 0 1-.01-.13c.13-.09.25-.19.37-.29a.07.07 0 0 1 .08-.01c3.93 1.79 8.18 1.79 12.06 0a.07.07 0 0 1 .08.01c.12.1.25.2.37.29a.08.08 0 0 1-.01.13 12.3 12.3 0 0 1-1.87.89.08.08 0 0 0-.04.11c.36.7.77 1.36 1.23 1.99a.08.08 0 0 0 .08.03 19.84 19.84 0 0 0 6-3.03.08.08 0 0 0 .03-.05c.5-5.18-.84-9.67-3.55-13.66a.06.06 0 0 0-.02-.04ZM8.02 15.33c-1.18 0-2.16-1.09-2.16-2.42s.96-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.33-.96 2.42-2.16 2.42Zm7.98 0c-1.18 0-2.16-1.09-2.16-2.42s.96-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.33-.95 2.42-2.16 2.42Z" />
</svg>
);
}
function GitHubLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.5-1.3-1.2-1.6-1.2-1.6-1-.7.1-.7.1-.7 1.1.1 1.7 1.2 1.7 1.2 1 1.7 2.6 1.2 3.3.9.1-.7.4-1.2.7-1.5-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.6.1-3.2 0 0 1-.3 3.3 1.2a11.2 11.2 0 0 1 6 0C17 4.7 18 5 18 5c.7 1.6.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .3Z" />
</svg>
);
}
function formatGitHubStars(stars: number) {
return new Intl.NumberFormat("en-US").format(stars);
}
function VersionLink({ version }: { version: string }) {
const normalizedVersion = version.trim();
if (!normalizedVersion) {
return null;
}
return (
<a
className="hidden px-2.5 py-1.5 text-ui text-muted-foreground transition hover:bg-secondary hover:text-foreground md:inline-flex"
href={`${GITHUB_REPO_URL}/releases`}
target="_blank"
rel="noreferrer"
aria-label={`Open GitHub releases for version ${normalizedVersion}`}
title={`Open GitHub releases for version ${normalizedVersion}`}
>
{normalizedVersion}
</a>
);
}
export function SiteHeaderClient({
githubStars,
discordUrl,
version,
}: {
githubStars: number | null;
discordUrl: string;
version: string;
}) {
const githubLabel =
githubStars === null
? "Open text-to-cad on GitHub"
: `Open text-to-cad on GitHub, ${new Intl.NumberFormat("en-US").format(
githubStars
)} stars`;
return (
<header className="sticky top-0 z-40 h-14 shrink-0 overflow-hidden border-b border-border bg-background">
<div className="mx-auto flex h-full w-full max-w-[1200px] items-center gap-3 px-4 sm:px-6">
<Link
href="/"
className="flex min-w-0 items-center text-foreground transition hover:text-primary"
>
<span className="min-w-0 truncate text-sm font-medium">
CAD Skills
</span>
</Link>
<nav
aria-label="Primary"
className="ml-auto hidden items-center gap-1 sm:flex"
>
<a
className="px-2.5 py-1.5 text-ui text-muted-foreground transition hover:bg-secondary hover:text-foreground"
href="#skills"
>
SKILLS
</a>
<a
className="px-2.5 py-1.5 text-ui text-muted-foreground transition hover:bg-secondary hover:text-foreground"
href="#installation"
>
INSTALL
</a>
<VersionLink version={version} />
</nav>
<div className="ml-auto flex shrink-0 items-center gap-1 sm:ml-0">
<Button
asChild
variant="outline"
className="card-glow h-8 border-border bg-card px-2 text-ui text-foreground hover:bg-secondary hover:text-primary"
>
<a
href="https://demo.cadskills.xyz"
target="_blank"
rel="noreferrer"
aria-label="Open demo in a new tab"
>
DEMO
</a>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="outline"
size="icon"
className="card-glow h-8 w-8 border-border bg-card text-foreground hover:bg-secondary hover:text-primary"
>
<a
href={discordUrl}
target="_blank"
rel="noreferrer"
aria-label="Join the CAD Skills Discord"
>
<DiscordLogo className="size-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Discord</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="outline"
className="card-glow h-8 border-border bg-card px-2 text-foreground hover:bg-secondary hover:text-primary"
>
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noreferrer"
aria-label={githubLabel}
>
<GitHubLogo className="size-3.5" />
{githubStars !== null ? (
<span className="text-label font-medium tabular-nums tracking-wider">
{formatGitHubStars(githubStars)}
</span>
) : null}
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">GitHub</TooltipContent>
</Tooltip>
<ThemeToggle />
</div>
</div>
</header>
);
}
@@ -0,0 +1,60 @@
import { SiteHeaderClient } from "@/components/site-header-client";
import packageJson from "../../package.json";
const GITHUB_REPO_API_URL =
"https://api.github.com/repos/earthtojake/text-to-cad";
const DEFAULT_DISCORD_URL = "https://discord.gg/5FGB9DwJYU";
function normalizeDiscordUrl(value: string | undefined) {
const rawValue = String(value || "").trim();
if (!rawValue) {
return DEFAULT_DISCORD_URL;
}
const urlValue = /^[a-z][a-z\d+.-]*:\/\//i.test(rawValue)
? rawValue
: `https://${rawValue.replace(/^\/+/, "")}`;
try {
const url = new URL(urlValue);
return ["http:", "https:"].includes(url.protocol)
? url.href
: DEFAULT_DISCORD_URL;
} catch {
return DEFAULT_DISCORD_URL;
}
}
async function getGitHubStars() {
try {
const response = await fetch(GITHUB_REPO_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "cad-skills-docs",
},
next: { revalidate: 60 * 60 },
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as { stargazers_count?: unknown };
const stars = Number(data.stargazers_count);
return Number.isFinite(stars) ? stars : null;
} catch {
return null;
}
}
export async function SiteHeader() {
const githubStars = await getGitHubStars();
return (
<SiteHeaderClient
githubStars={githubStars}
discordUrl={normalizeDiscordUrl(process.env.DISCORD_URL)}
version={packageJson.version}
/>
);
}
@@ -0,0 +1,47 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const THEME_STORAGE_KEY = "cad-skills-theme-v2";
export function ThemeToggle() {
const toggleTheme = () => {
const root = document.documentElement;
const isDark = !root.classList.contains("dark");
const theme = isDark ? "dark" : "light";
root.classList.toggle("dark", isDark);
root.style.colorScheme = theme;
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// The class change still applies when storage is unavailable.
}
};
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="card-glow h-8 w-8 border-border bg-card text-foreground hover:bg-secondary hover:text-primary"
onClick={toggleTheme}
aria-label="Toggle light and dark mode"
>
<Sun className="size-4 dark:hidden" />
<Moon className="hidden size-4 dark:block" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Light / dark</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
+34
View File
@@ -0,0 +1,34 @@
const DEFAULT_SITE_ORIGIN = "https://www.cadskills.xyz";
const SITE_DESCRIPTION =
"A skills library for CAD, robotics, and hardware design agents";
const SITE_TITLE = `CAD Skills | ${SITE_DESCRIPTION}`;
function normalizeOrigin(value: string | undefined, fallback: string) {
const candidate = value?.trim() || fallback;
try {
return new URL(candidate).origin;
} catch {
return fallback;
}
}
export const siteConfig = {
name: "CAD Skills",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
keywords: [
"CAD Skills",
"text-to-cad",
"CAD agents",
"agent skills",
"step.parts",
"STEP parts",
"DXF",
],
origin: normalizeOrigin(process.env.NEXT_PUBLIC_SITE_URL, DEFAULT_SITE_ORIGIN),
};
export function absoluteUrl(path: string) {
return new URL(path, `${siteConfig.origin}/`).toString();
}
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from "clsx"
import { extendTailwindMerge } from "tailwind-merge"
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [
"text-label",
"text-ui",
"text-heading",
"text-stat",
"text-hero",
],
},
},
})
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+133
View File
@@ -0,0 +1,133 @@
declare module "cadjs/common/cadScene.js" {
import type { Group, Vector3 } from "three";
type CadBounds = {
min: number[];
max: number[];
};
export const CAD_SCENE_SCALE: {
CAD: string;
URDF: string;
};
export type CadSceneApi = {
root: Group;
modelGroup: Group;
edgesGroup: Group;
displayRecords: unknown[];
records: unknown[];
bounds: CadBounds;
radius: number;
runtime: unknown;
dispose: () => void;
update: (settings?: Record<string, unknown>) => CadSceneApi;
};
export function buildModel(
THREE: unknown,
source: unknown,
settings?: Record<string, unknown>
): CadSceneApi;
export function fitCameraToModel(
THREE: unknown,
camera: unknown,
bounds: CadBounds,
options?: Record<string, unknown>
): {
center: Vector3;
radius: number;
halfHeight: number;
distance: number;
};
}
declare module "cadjs/common/renderModel.js" {
export type RenderViewportApi = {
renderer: unknown;
scene: unknown;
camera: unknown;
ready: Promise<void>;
resize: () => unknown;
render: () => void;
start: () => void;
stop: () => void;
capturePng: () => string;
dispose: () => void;
};
export function renderModel(
THREE: unknown,
model: unknown,
options?: Record<string, unknown>
): RenderViewportApi;
}
declare module "cadjs/common/source.js" {
export type RenderSource = {
kind: string;
meshData: unknown;
stepParameterSource: {
definition: import("cadjs/common/stepModule.js").StepModuleDefinition;
} | null;
};
export function loadSource(
input: unknown,
options?: Record<string, unknown>
): Promise<RenderSource>;
}
declare module "cadjs/common/themeSettings.js" {
export function cloneThemeSettings(themeId: string): Record<
string,
unknown
> & {
materials?: Record<string, unknown>;
};
}
declare module "cadjs/common/stepModule.js" {
export type StepModuleParameter = {
id: string;
type: string;
defaultValue: unknown;
};
export type StepModuleAnimation = {
id: string;
duration: number;
loop: boolean;
update?: (context: {
cycle: number;
duration: number;
elapsed: number;
elapsedSec: number;
loop: boolean;
params: Record<string, unknown>;
progress: number;
set: (parameterId: string, value: unknown) => void;
}) => void;
};
export type StepModuleDefinition = {
animations: StepModuleAnimation[];
parameterMap: Record<string, StepModuleParameter>;
};
export function loadStepModuleDefinition(
url: string,
options?: Record<string, unknown>
): Promise<StepModuleDefinition>;
export function normalizeParameterValue(
definition: StepModuleParameter,
value: unknown
): unknown;
export function normalizeStepModuleParameterValues(
definition: StepModuleDefinition,
values?: Record<string, unknown>
): Record<string, unknown>;
}
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"cadjs/*": ["../packages/cadjs/src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"git": {
"deploymentEnabled": false
},
"buildCommand": "npm run build"
}