mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-11-02 13:07:46 +00:00
630 lines
15 KiB
TypeScript
630 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// Basic types for 3D primitives
|
|
interface Vector3 {
|
|
x: number
|
|
y: number
|
|
z: number
|
|
}
|
|
|
|
interface Color {
|
|
r: number
|
|
g: number
|
|
b: number
|
|
a?: number
|
|
}
|
|
|
|
interface Material {
|
|
color: Color
|
|
emissive?: Color
|
|
metalness?: number
|
|
roughness?: number
|
|
opacity?: number
|
|
}
|
|
|
|
interface Light {
|
|
type: "directional" | "point" | "ambient"
|
|
position?: Vector3
|
|
direction?: Vector3
|
|
color: Color
|
|
intensity: number
|
|
}
|
|
|
|
interface Camera {
|
|
position: Vector3
|
|
target: Vector3
|
|
fov: number
|
|
near: number
|
|
far: number
|
|
}
|
|
|
|
interface Geometry {
|
|
type: "box" | "sphere" | "plane" | "cylinder" | "torus"
|
|
parameters: Record<string, number>
|
|
}
|
|
|
|
interface Mesh {
|
|
id: string
|
|
geometry: Geometry
|
|
material: Material
|
|
position: Vector3
|
|
rotation: Vector3
|
|
scale: Vector3
|
|
visible?: boolean
|
|
}
|
|
|
|
interface Scene3DProps extends React.HTMLAttributes<HTMLCanvasElement> {
|
|
width?: number
|
|
height?: number
|
|
meshes?: Mesh[]
|
|
lights?: Light[]
|
|
camera?: Camera
|
|
backgroundColor?: Color
|
|
enableOrbitControls?: boolean
|
|
enableAutoRotate?: boolean
|
|
autoRotateSpeed?: number
|
|
onMeshClick?: (mesh: Mesh) => void
|
|
onMeshHover?: (mesh: Mesh | null) => void
|
|
className?: string
|
|
}
|
|
|
|
// Simple software renderer for basic shapes
|
|
class SimpleRenderer {
|
|
private canvas: HTMLCanvasElement
|
|
private ctx: CanvasRenderingContext2D
|
|
private camera: Camera
|
|
private meshes: Mesh[] = []
|
|
private lights: Light[] = []
|
|
private backgroundColor: Color = { r: 0.1, g: 0.1, b: 0.1, a: 1 }
|
|
private animationFrame: number | null = null
|
|
private mousePosition = { x: 0, y: 0 }
|
|
private isDragging = false
|
|
private lastMousePosition = { x: 0, y: 0 }
|
|
private cameraRotation = { x: 0, y: 0 }
|
|
private autoRotate = false
|
|
private autoRotateSpeed = 1
|
|
|
|
constructor(canvas: HTMLCanvasElement) {
|
|
this.canvas = canvas
|
|
const ctx = canvas.getContext("2d")
|
|
if (!ctx) throw new Error("Could not get 2D context")
|
|
this.ctx = ctx
|
|
|
|
this.camera = {
|
|
position: { x: 0, y: 0, z: 5 },
|
|
target: { x: 0, y: 0, z: 0 },
|
|
fov: 75,
|
|
near: 0.1,
|
|
far: 1000,
|
|
}
|
|
|
|
this.setupEventListeners()
|
|
}
|
|
|
|
private setupEventListeners() {
|
|
this.canvas.addEventListener("mousedown", this.handleMouseDown.bind(this))
|
|
this.canvas.addEventListener("mousemove", this.handleMouseMove.bind(this))
|
|
this.canvas.addEventListener("mouseup", this.handleMouseUp.bind(this))
|
|
this.canvas.addEventListener("wheel", this.handleWheel.bind(this))
|
|
}
|
|
|
|
private handleMouseDown(e: MouseEvent) {
|
|
this.isDragging = true
|
|
this.lastMousePosition = { x: e.clientX, y: e.clientY }
|
|
}
|
|
|
|
private handleMouseMove(e: MouseEvent) {
|
|
this.mousePosition = { x: e.clientX, y: e.clientY }
|
|
|
|
if (this.isDragging) {
|
|
const deltaX = e.clientX - this.lastMousePosition.x
|
|
const deltaY = e.clientY - this.lastMousePosition.y
|
|
|
|
this.cameraRotation.y += deltaX * 0.01
|
|
this.cameraRotation.x += deltaY * 0.01
|
|
|
|
this.cameraRotation.x = Math.max(
|
|
-Math.PI / 2,
|
|
Math.min(Math.PI / 2, this.cameraRotation.x)
|
|
)
|
|
|
|
this.updateCameraPosition()
|
|
this.lastMousePosition = { x: e.clientX, y: e.clientY }
|
|
}
|
|
}
|
|
|
|
private handleMouseUp() {
|
|
this.isDragging = false
|
|
}
|
|
|
|
private handleWheel(e: WheelEvent) {
|
|
e.preventDefault()
|
|
const distance = Math.sqrt(
|
|
this.camera.position.x ** 2 +
|
|
this.camera.position.y ** 2 +
|
|
this.camera.position.z ** 2
|
|
)
|
|
const newDistance = Math.max(1, Math.min(20, distance + e.deltaY * 0.01))
|
|
|
|
const factor = newDistance / distance
|
|
this.camera.position.x *= factor
|
|
this.camera.position.y *= factor
|
|
this.camera.position.z *= factor
|
|
}
|
|
|
|
private updateCameraPosition() {
|
|
const distance = 5
|
|
this.camera.position.x =
|
|
distance *
|
|
Math.sin(this.cameraRotation.y) *
|
|
Math.cos(this.cameraRotation.x)
|
|
this.camera.position.y = distance * Math.sin(this.cameraRotation.x)
|
|
this.camera.position.z =
|
|
distance *
|
|
Math.cos(this.cameraRotation.y) *
|
|
Math.cos(this.cameraRotation.x)
|
|
}
|
|
|
|
private project(point: Vector3): { x: number; y: number; z: number } {
|
|
const { width, height } = this.canvas
|
|
|
|
// Simple perspective projection
|
|
const dx = point.x - this.camera.position.x
|
|
const dy = point.y - this.camera.position.y
|
|
const dz = point.z - this.camera.position.z
|
|
|
|
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
const scale =
|
|
(this.camera.far - this.camera.near) / (distance + this.camera.near)
|
|
|
|
return {
|
|
x: width / 2 + (dx * scale * width) / 4,
|
|
y: height / 2 - (dy * scale * height) / 4,
|
|
z: distance,
|
|
}
|
|
}
|
|
|
|
private drawMesh(mesh: Mesh) {
|
|
if (!mesh.visible) return
|
|
|
|
const { geometry, material, position, rotation, scale } = mesh
|
|
this.ctx.save()
|
|
|
|
// Apply transformations
|
|
const projected = this.project(position)
|
|
|
|
this.ctx.translate(projected.x, projected.y)
|
|
this.ctx.scale(scale.x, scale.y)
|
|
this.ctx.rotate(rotation.z)
|
|
|
|
// Set material properties
|
|
this.ctx.fillStyle = `rgba(${Math.floor(material.color.r * 255)}, ${Math.floor(material.color.g * 255)}, ${Math.floor(material.color.b * 255)}, ${material.opacity || 1})`
|
|
this.ctx.strokeStyle = this.ctx.fillStyle
|
|
|
|
// Draw based on geometry type
|
|
switch (geometry.type) {
|
|
case "box":
|
|
this.drawBox(geometry.parameters)
|
|
break
|
|
case "sphere":
|
|
this.drawSphere(geometry.parameters)
|
|
break
|
|
case "plane":
|
|
this.drawPlane(geometry.parameters)
|
|
break
|
|
case "cylinder":
|
|
this.drawCylinder(geometry.parameters)
|
|
break
|
|
case "torus":
|
|
this.drawTorus(geometry.parameters)
|
|
break
|
|
}
|
|
|
|
this.ctx.restore()
|
|
}
|
|
|
|
private drawBox(params: Record<string, number>) {
|
|
const width = params.width || 1
|
|
const height = params.height || 1
|
|
|
|
this.ctx.fillRect(-width / 2, -height / 2, width, height)
|
|
this.ctx.strokeRect(-width / 2, -height / 2, width, height)
|
|
}
|
|
|
|
private drawSphere(params: Record<string, number>) {
|
|
const radius = params.radius || 0.5
|
|
|
|
this.ctx.beginPath()
|
|
this.ctx.arc(0, 0, radius * 50, 0, Math.PI * 2)
|
|
this.ctx.fill()
|
|
this.ctx.stroke()
|
|
}
|
|
|
|
private drawPlane(params: Record<string, number>) {
|
|
const width = params.width || 1
|
|
const height = params.height || 1
|
|
|
|
this.ctx.fillRect(-width / 2, -height / 2, width, height)
|
|
}
|
|
|
|
private drawCylinder(params: Record<string, number>) {
|
|
const radius = params.radius || 0.5
|
|
const height = params.height || 1
|
|
|
|
// Draw as rectangle with rounded ends
|
|
this.ctx.fillRect(-radius * 50, -height * 25, radius * 100, height * 50)
|
|
|
|
this.ctx.beginPath()
|
|
this.ctx.arc(-radius * 50, 0, height * 25, Math.PI / 2, -Math.PI / 2)
|
|
this.ctx.arc(radius * 50, 0, height * 25, -Math.PI / 2, Math.PI / 2)
|
|
this.ctx.fill()
|
|
}
|
|
|
|
private drawTorus(params: Record<string, number>) {
|
|
const radius = params.radius || 0.5
|
|
const tube = params.tube || 0.2
|
|
|
|
this.ctx.beginPath()
|
|
this.ctx.arc(0, 0, radius * 50, 0, Math.PI * 2)
|
|
this.ctx.lineWidth = tube * 50
|
|
this.ctx.stroke()
|
|
this.ctx.lineWidth = 1
|
|
}
|
|
|
|
private clear() {
|
|
const { width, height } = this.canvas
|
|
this.ctx.fillStyle = `rgba(${Math.floor(this.backgroundColor.r * 255)}, ${Math.floor(this.backgroundColor.g * 255)}, ${Math.floor(this.backgroundColor.b * 255)}, ${this.backgroundColor.a || 1})`
|
|
this.ctx.fillRect(0, 0, width, height)
|
|
}
|
|
|
|
setCamera(camera: Camera) {
|
|
this.camera = { ...camera }
|
|
}
|
|
|
|
setMeshes(meshes: Mesh[]) {
|
|
this.meshes = [...meshes]
|
|
}
|
|
|
|
setLights(lights: Light[]) {
|
|
this.lights = [...lights]
|
|
}
|
|
|
|
setBackgroundColor(color: Color) {
|
|
this.backgroundColor = { ...color }
|
|
}
|
|
|
|
setAutoRotate(enabled: boolean, speed = 1) {
|
|
this.autoRotate = enabled
|
|
this.autoRotateSpeed = speed
|
|
}
|
|
|
|
render() {
|
|
this.clear()
|
|
|
|
if (this.autoRotate) {
|
|
this.cameraRotation.y += this.autoRotateSpeed * 0.01
|
|
this.updateCameraPosition()
|
|
}
|
|
|
|
// Sort meshes by distance (simple depth sorting)
|
|
const sortedMeshes = [...this.meshes].sort((a, b) => {
|
|
const distA = Math.sqrt(
|
|
(a.position.x - this.camera.position.x) ** 2 +
|
|
(a.position.y - this.camera.position.y) ** 2 +
|
|
(a.position.z - this.camera.position.z) ** 2
|
|
)
|
|
const distB = Math.sqrt(
|
|
(b.position.x - this.camera.position.x) ** 2 +
|
|
(b.position.y - this.camera.position.y) ** 2 +
|
|
(b.position.z - this.camera.position.z) ** 2
|
|
)
|
|
return distB - distA
|
|
})
|
|
|
|
sortedMeshes.forEach((mesh) => this.drawMesh(mesh))
|
|
}
|
|
|
|
startAnimation() {
|
|
const animate = () => {
|
|
this.render()
|
|
this.animationFrame = requestAnimationFrame(animate)
|
|
}
|
|
animate()
|
|
}
|
|
|
|
stopAnimation() {
|
|
if (this.animationFrame) {
|
|
cancelAnimationFrame(this.animationFrame)
|
|
this.animationFrame = null
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.stopAnimation()
|
|
}
|
|
}
|
|
|
|
const Scene3D = React.forwardRef<HTMLCanvasElement, Scene3DProps>(
|
|
(
|
|
{
|
|
width = 800,
|
|
height = 600,
|
|
meshes = [],
|
|
lights = [],
|
|
camera,
|
|
backgroundColor = { r: 0.1, g: 0.1, b: 0.1, a: 1 },
|
|
enableOrbitControls = true,
|
|
enableAutoRotate = false,
|
|
autoRotateSpeed = 1,
|
|
onMeshClick,
|
|
onMeshHover,
|
|
className,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const canvasRef = React.useRef<HTMLCanvasElement>(null)
|
|
const rendererRef = React.useRef<SimpleRenderer | null>(null)
|
|
|
|
// Initialize renderer
|
|
React.useEffect(() => {
|
|
if (!canvasRef.current) return
|
|
|
|
const renderer = new SimpleRenderer(canvasRef.current)
|
|
rendererRef.current = renderer
|
|
|
|
renderer.startAnimation()
|
|
|
|
return () => {
|
|
renderer.destroy()
|
|
}
|
|
}, [])
|
|
|
|
// Update camera
|
|
React.useEffect(() => {
|
|
if (rendererRef.current && camera) {
|
|
rendererRef.current.setCamera(camera)
|
|
}
|
|
}, [camera])
|
|
|
|
// Update meshes
|
|
React.useEffect(() => {
|
|
if (rendererRef.current) {
|
|
rendererRef.current.setMeshes(meshes)
|
|
}
|
|
}, [meshes])
|
|
|
|
// Update lights
|
|
React.useEffect(() => {
|
|
if (rendererRef.current) {
|
|
rendererRef.current.setLights(lights)
|
|
}
|
|
}, [lights])
|
|
|
|
// Update background color
|
|
React.useEffect(() => {
|
|
if (rendererRef.current) {
|
|
rendererRef.current.setBackgroundColor(backgroundColor)
|
|
}
|
|
}, [backgroundColor])
|
|
|
|
// Update auto rotate
|
|
React.useEffect(() => {
|
|
if (rendererRef.current) {
|
|
rendererRef.current.setAutoRotate(enableAutoRotate, autoRotateSpeed)
|
|
}
|
|
}, [enableAutoRotate, autoRotateSpeed])
|
|
|
|
// Update canvas size
|
|
React.useEffect(() => {
|
|
if (canvasRef.current) {
|
|
canvasRef.current.width = width
|
|
canvasRef.current.height = height
|
|
}
|
|
}, [width, height])
|
|
|
|
React.useImperativeHandle(ref, () => canvasRef.current!)
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={width}
|
|
height={height}
|
|
className={cn(
|
|
"border rounded-lg bg-black/5 cursor-grab active:cursor-grabbing",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
)
|
|
Scene3D.displayName = "Scene3D"
|
|
|
|
// Helper functions to create common geometries
|
|
const createBox = (width = 1, height = 1, depth = 1): Geometry => ({
|
|
type: "box",
|
|
parameters: { width, height, depth },
|
|
})
|
|
|
|
const createSphere = (radius = 0.5): Geometry => ({
|
|
type: "sphere",
|
|
parameters: { radius },
|
|
})
|
|
|
|
const createPlane = (width = 1, height = 1): Geometry => ({
|
|
type: "plane",
|
|
parameters: { width, height },
|
|
})
|
|
|
|
const createCylinder = (radius = 0.5, height = 1): Geometry => ({
|
|
type: "cylinder",
|
|
parameters: { radius, height },
|
|
})
|
|
|
|
const createTorus = (radius = 0.5, tube = 0.2): Geometry => ({
|
|
type: "torus",
|
|
parameters: { radius, tube },
|
|
})
|
|
|
|
// Helper functions to create materials
|
|
const createMaterial = (
|
|
color: Color,
|
|
options: Partial<Material> = {}
|
|
): Material => ({
|
|
color,
|
|
metalness: 0,
|
|
roughness: 0.5,
|
|
opacity: 1,
|
|
...options,
|
|
})
|
|
|
|
// Helper functions to create lights
|
|
const createDirectionalLight = (
|
|
direction: Vector3,
|
|
color: Color = { r: 1, g: 1, b: 1 },
|
|
intensity = 1
|
|
): Light => ({
|
|
type: "directional",
|
|
direction,
|
|
color,
|
|
intensity,
|
|
})
|
|
|
|
const createPointLight = (
|
|
position: Vector3,
|
|
color: Color = { r: 1, g: 1, b: 1 },
|
|
intensity = 1
|
|
): Light => ({
|
|
type: "point",
|
|
position,
|
|
color,
|
|
intensity,
|
|
})
|
|
|
|
const createAmbientLight = (
|
|
color: Color = { r: 0.2, g: 0.2, b: 0.2 },
|
|
intensity = 0.5
|
|
): Light => ({
|
|
type: "ambient",
|
|
color,
|
|
intensity,
|
|
})
|
|
|
|
// Preset scenes
|
|
const Scene3DPreset = {
|
|
Basic: React.forwardRef<
|
|
HTMLCanvasElement,
|
|
Omit<Scene3DProps, "meshes" | "lights">
|
|
>((props, ref) => {
|
|
const meshes: Mesh[] = [
|
|
{
|
|
id: "cube",
|
|
geometry: createBox(1, 1, 1),
|
|
material: createMaterial({ r: 0.5, g: 0.7, b: 1 }),
|
|
position: { x: 0, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 },
|
|
scale: { x: 1, y: 1, z: 1 },
|
|
visible: true,
|
|
},
|
|
]
|
|
|
|
const lights: Light[] = [
|
|
createDirectionalLight({ x: 1, y: 1, z: 1 }),
|
|
createAmbientLight(),
|
|
]
|
|
|
|
return (
|
|
<Scene3D
|
|
ref={ref}
|
|
meshes={meshes}
|
|
lights={lights}
|
|
enableOrbitControls
|
|
{...props}
|
|
/>
|
|
)
|
|
}),
|
|
|
|
Primitives: React.forwardRef<
|
|
HTMLCanvasElement,
|
|
Omit<Scene3DProps, "meshes" | "lights">
|
|
>((props, ref) => {
|
|
const meshes: Mesh[] = [
|
|
{
|
|
id: "cube",
|
|
geometry: createBox(0.8, 0.8, 0.8),
|
|
material: createMaterial({ r: 1, g: 0.3, b: 0.3 }),
|
|
position: { x: -2, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 },
|
|
scale: { x: 1, y: 1, z: 1 },
|
|
visible: true,
|
|
},
|
|
{
|
|
id: "sphere",
|
|
geometry: createSphere(0.5),
|
|
material: createMaterial({ r: 0.3, g: 1, b: 0.3 }),
|
|
position: { x: 0, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 },
|
|
scale: { x: 1, y: 1, z: 1 },
|
|
visible: true,
|
|
},
|
|
{
|
|
id: "cylinder",
|
|
geometry: createCylinder(0.4, 1),
|
|
material: createMaterial({ r: 0.3, g: 0.3, b: 1 }),
|
|
position: { x: 2, y: 0, z: 0 },
|
|
rotation: { x: 0, y: 0, z: 0 },
|
|
scale: { x: 1, y: 1, z: 1 },
|
|
visible: true,
|
|
},
|
|
]
|
|
|
|
const lights: Light[] = [
|
|
createDirectionalLight({ x: 1, y: 1, z: 1 }),
|
|
createAmbientLight({ r: 0.3, g: 0.3, b: 0.3 }, 0.3),
|
|
]
|
|
|
|
return (
|
|
<Scene3D
|
|
ref={ref}
|
|
meshes={meshes}
|
|
lights={lights}
|
|
enableOrbitControls
|
|
enableAutoRotate
|
|
autoRotateSpeed={0.5}
|
|
{...props}
|
|
/>
|
|
)
|
|
}),
|
|
}
|
|
|
|
// Assign display names
|
|
Scene3DPreset.Basic.displayName = "Scene3DPreset.Basic"
|
|
Scene3DPreset.Primitives.displayName = "Scene3DPreset.Primitives"
|
|
|
|
export {
|
|
Scene3D,
|
|
Scene3DPreset,
|
|
createBox,
|
|
createSphere,
|
|
createPlane,
|
|
createCylinder,
|
|
createTorus,
|
|
createMaterial,
|
|
createDirectionalLight,
|
|
createPointLight,
|
|
createAmbientLight,
|
|
type Scene3DProps,
|
|
type Vector3,
|
|
type Color,
|
|
type Material,
|
|
type Light,
|
|
type Camera,
|
|
type Geometry,
|
|
type Mesh,
|
|
}
|