"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>["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; 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(null); const viewportRef = useRef(null); const [scheme, setScheme] = useState("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 | null = null; let viewport: ReturnType | 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 (
{status === HERO_STEP_LABEL ? ( {status} ) : ( {status} )}
); }