first commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user