From 2f57062f0df68470f569edeaff8c658d61a7cb19 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Tue, 8 Sep 2026 08:14:36 +0800 Subject: [PATCH] feat(docs): add logo intro and optimize hero animation --- .../src/app/(home)/hero-brand-geometry.ts | 63 + apps/docs/src/app/(home)/hero-canvas.tsx | 1669 ++++------------- apps/docs/src/app/(home)/hero-workspace.ts | 1192 ++++++++++++ 3 files changed, 1598 insertions(+), 1326 deletions(-) create mode 100644 apps/docs/src/app/(home)/hero-brand-geometry.ts create mode 100644 apps/docs/src/app/(home)/hero-workspace.ts diff --git a/apps/docs/src/app/(home)/hero-brand-geometry.ts b/apps/docs/src/app/(home)/hero-brand-geometry.ts new file mode 100644 index 0000000..990cede --- /dev/null +++ b/apps/docs/src/app/(home)/hero-brand-geometry.ts @@ -0,0 +1,63 @@ +export function createBrandArcGeometry(THREE: typeof import("three")): import("three").BufferGeometry { + const radius = 1.24; + const halfWidth = radius * 0.45 / 2; + const sweep = THREE.MathUtils.degToRad(130); + const thickness = 0.18; + const bevelSize = 0.035; + const bevelThickness = 0.035; + const baseHalfWidth = halfWidth - bevelSize; + const depth = thickness - bevelThickness * 2; + const endX = Math.cos(sweep) * radius; + const endY = Math.sin(sweep) * radius; + + // A circular stroke with two semicircular caps, kept about the SVG's origin. + // Extrusion adds the bevel outside this path, so inset the stroke by its size. + const shape = new THREE.Shape(); + shape.moveTo(radius + baseHalfWidth, 0); + shape.absarc(0, 0, radius + baseHalfWidth, 0, sweep, false); + shape.absarc(endX, endY, baseHalfWidth, sweep, sweep + Math.PI, false); + shape.absarc(0, 0, radius - baseHalfWidth, sweep, 0, true); + shape.absarc(radius, 0, baseHalfWidth, Math.PI, Math.PI * 2, false); + shape.closePath(); + + const geometry = new THREE.ExtrudeGeometry(shape, { + depth, + steps: 1, + bevelEnabled: true, + bevelSize, + bevelThickness, + bevelSegments: 4, + curveSegments: 48, + }); + // Preserve the circular centre at XY = (0, 0); only centre the thickness. + geometry.translate(0, 0, -depth / 2); + + // ExtrudeGeometry duplicates vertices between triangles. Give those copies + // identical analytic normals so the rounded stroke has smooth metal highlights. + const positions = geometry.getAttribute("position"); + const normals = geometry.getAttribute("normal"); + const normal = new THREE.Vector3(); + for (let index = 0; index < positions.count; index += 1) { + const x = positions.getX(index); + const y = positions.getY(index); + const z = positions.getZ(index); + const angle = THREE.MathUtils.clamp(Math.atan2(y, x), 0, sweep); + const deltaX = x - Math.cos(angle) * radius; + const deltaY = y - Math.sin(angle) * radius; + const distance = Math.hypot(deltaX, deltaY); + const verticalBevel = Math.max(0, Math.abs(z) - depth / 2); + + if (verticalBevel < 1e-7) { + normal.set(deltaX / distance, deltaY / distance, 0); + } else { + const radialNormal = Math.max(0, distance - baseHalfWidth) / (bevelSize * bevelSize); + const verticalNormal = Math.sign(z) * verticalBevel / (bevelThickness * bevelThickness); + normal.set(deltaX / distance * radialNormal, deltaY / distance * radialNormal, verticalNormal).normalize(); + } + normals.setXYZ(index, normal.x, normal.y, normal.z); + } + normals.needsUpdate = true; + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; +} diff --git a/apps/docs/src/app/(home)/hero-canvas.tsx b/apps/docs/src/app/(home)/hero-canvas.tsx index 39420a2..28fadba 100644 --- a/apps/docs/src/app/(home)/hero-canvas.tsx +++ b/apps/docs/src/app/(home)/hero-canvas.tsx @@ -1,257 +1,65 @@ "use client"; import type { Locale } from "@/i18n"; -import { useEffect, useRef } from "react"; -import * as THREE from "three"; - -type Group = import("three").Group; -type Line = import("three").Line; -type Material = import("three").Material; -type Mesh = import("three").Mesh; -type Object3D = import("three").Object3D; -type Texture = import("three").Texture; -type Vector3 = import("three").Vector3; - -type ModuleId = - | "apps" - | "api" - | "docs" - | "packages" - | "env" - | "deploy" - | "manifest" - | "cli-interface"; - -type ModuleSpotlightCopy = { - title: string; - subtitle: string; -}; - -type ModuleConfig = { - id: ModuleId; - labels: Record; - spotlight: Record; - x: number; - y: number; - width: number; - depth: number; - delay: number; -}; - -type ModuleModel = { - config: ModuleConfig; - decorativeMaterials: Material[]; - group: Group; - hitTarget: Mesh; - labelMaterials: Material[]; - materials: Material[]; - route: Line; - routeMaterial: Material; - start: Vector3; - control: Vector3; - hoverTarget: Vector3; - target: Vector3; -}; - -type PrintedMarkModel = { - group: Group; - labelMaterials: Material[]; - materials: Material[]; -}; - -type BoardModel = { - group: Group; - materials: Material[]; - flowMaterial: Material; - flowTexture: Texture; -}; - -type ModuleSpotlightModel = { - config: ModuleConfig; - group: Group; - panelGroup: Group; - materials: Material[]; - signalLines: Line[]; - mix: number; -}; - -type HomeHeroCanvasProps = { - ariaLabel?: string; - lang?: Locale; -}; +import { useEffect, useRef, useState } from "react"; +import type * as THREE from "three"; +import { createBrandArcGeometry } from "./hero-brand-geometry"; +import { createWorkspace, type ModuleId } from "./hero-workspace"; type Three = typeof THREE; +type HeroController = { dispose: () => void }; +type HomeHeroCanvasProps = { ariaLabel?: string; lang?: Locale }; -const moduleConfigs: ModuleConfig[] = [ - { - id: "cli-interface", - labels: { zh: "CLI接口", en: "CLI Interface" }, - spotlight: { - zh: { title: "CLI 接口", subtitle: "结构化输出与命令帮助" }, - en: { title: "CLI Interface", subtitle: "JSON output and command help" }, - }, - x: -1.18, - y: 0.9, - width: 0.96, - depth: 0.44, - delay: 0, - }, - { - id: "manifest", - labels: { zh: "Manifest", en: "Manifest" }, - spotlight: { - zh: { title: "Manifest", subtitle: "项目清单和工程契约" }, - en: { title: "Manifest", subtitle: "Workspace contract file" }, - }, - x: -0.1, - y: 0.48, - width: 0.92, - depth: 0.44, - delay: 360, - }, - { - id: "deploy", - labels: { zh: "Deploy", en: "Deploy" }, - spotlight: { - zh: { title: "Deploy", subtitle: "K8s / S3 / Vercel 部署" }, - en: { title: "Deploy", subtitle: "K8s, S3, Vercel deploy" }, - }, - x: 1.0, - y: 0.48, - width: 0.78, - depth: 0.44, - delay: 720, +const copy = { + zh: { + description: "One CLI 品牌双弧归一后生成工作区模块,展示前端、后端、文档、共享库、部署和 CLI 接口模块。", }, - { - id: "apps", - labels: { zh: "Apps", en: "Apps" }, - spotlight: { - zh: { title: "Apps", subtitle: "Web / Mobile / Desktop 模板" }, - en: { title: "Apps", subtitle: "Web, mobile, desktop" }, - }, - x: -1.18, - y: 0.2, - width: 0.78, - depth: 0.44, - delay: 1080, - }, - { - id: "api", - labels: { zh: "API", en: "API" }, - spotlight: { - zh: { title: "API", subtitle: "NestJS / Go 服务" }, - en: { title: "API", subtitle: "NestJS and Go services" }, - }, - x: -0.14, - y: -0.08, - width: 0.72, - depth: 0.44, - delay: 1440, + en: { + description: "One CLI brand arcs reunite before the workspace assembles, showing frontend, backend, docs, library, deploy, and CLI interface modules.", }, - { - id: "docs", - labels: { zh: "Docs", en: "Docs" }, - spotlight: { - zh: { title: "Docs", subtitle: "Starlight 文档站" }, - en: { title: "Docs", subtitle: "Starlight docs site" }, - }, - x: 0.9, - y: -0.08, - width: 0.74, - depth: 0.44, - delay: 1800, - }, - { - id: "packages", - labels: { zh: "Packages", en: "Packages" }, - spotlight: { - zh: { title: "Packages", subtitle: "TS / Go 通用库" }, - en: { title: "Packages", subtitle: "TypeScript and Go libs" }, - }, - x: -0.7, - y: -0.72, - width: 0.98, - depth: 0.44, - delay: 2160, - }, - { - id: "env", - labels: { zh: "Env", en: "Env" }, - spotlight: { - zh: { title: "Env", subtitle: "dotenv / Infisical 环境" }, - en: { title: "Env", subtitle: "dotenv and Infisical" }, - }, - x: 0.52, - y: -0.72, - width: 0.72, - depth: 0.44, - delay: 2520, - }, -]; -const boardWidth = 3.82; -const boardDepth = 2.78; -const boardHeight = 0.18; -const boardTopZ = boardHeight - 0.08; -const launcherX = 1.24; -const launcherY = -0.96; -const launcherLift = 0.09; -const moduleHeight = 0.24; -const coreStart = 120; -const moduleStart = 320; -const moduleDuration = 1080; -const settleStart = moduleStart + moduleConfigs[moduleConfigs.length - 1].delay + moduleDuration; -const sceneScale = 0.84; -const maxCanvasPixelRatio = 2; -const hitTargetPaddingX = 0.36; -const hitTargetPaddingY = 0.28; +}; export function HomeHeroCanvas({ ariaLabel, lang = "zh" }: HomeHeroCanvasProps) { const canvasRef = useRef(null); + const controllerRef = useRef(null); + const [ready, setReady] = useState(false); + const text = copy[lang]; useEffect(() => { const canvas = canvasRef.current; const container = canvas?.parentElement; - - if (!canvas || !container) { - return; - } - - let disposeScene: (() => void) | undefined; + if (!canvas || !container) return; let disposed = false; - + setReady(false); const initialize = async () => { - await document.fonts?.ready; - - if (disposed) { - return; - } - try { - disposeScene = mountScene(THREE, canvas, container, lang); + const [THREE, { RoomEnvironment }] = await Promise.all([ + import("three"), + import("three/addons/environments/RoomEnvironment.js"), + document.fonts?.ready, + ]); + if (disposed) return; + controllerRef.current = mountScene(THREE, RoomEnvironment, canvas, container, lang); + setReady(true); } catch (error) { console.warn("One CLI hero animation could not initialize WebGL.", error); } }; - void initialize(); - return () => { disposed = true; - disposeScene?.(); + controllerRef.current?.dispose(); + controllerRef.current = null; }; }, [lang]); return ( -
+
+ {!ready && ( + + )}