Add 3d grot simulation

This commit is contained in:
Drew Slobodnjak
2025-12-07 16:08:33 -08:00
parent fc8893bc53
commit 3d7358c4ce
4 changed files with 37408 additions and 0 deletions
@@ -67,6 +67,7 @@ func NewSimulationEngine() (*SimulationEngine, error) {
newSinewaveInfo,
newTankSimInfo,
newNBodySimInfo,
newGrot3dSimInfo,
}
for _, init := range initializers {
@@ -0,0 +1,538 @@
package sims
import (
_ "embed"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"math"
"time"
"bytes"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
//go:embed grot_mesh.json
var grotMeshData []byte
//go:embed grot_base_color.png
var grotBaseColor []byte
type grot3dSim struct {
key simulationKey
cfg grot3dConfig
state grot3dState
vertices []point3d
uvs [][]float64
indices []int
texture image.Image
}
var (
_ Simulation = (*grot3dSim)(nil)
)
type grot3dConfig struct {
RotationSpeedX float64 `json:"rotationSpeedX"` // Rotation speed around X axis (degrees/second)
RotationSpeedY float64 `json:"rotationSpeedY"` // Rotation speed around Y axis (degrees/second)
RotationSpeedZ float64 `json:"rotationSpeedZ"` // Rotation speed around Z axis (degrees/second)
MinAngleX float64 `json:"minAngleX"` // Minimum rotation angle for X axis (degrees)
MaxAngleX float64 `json:"maxAngleX"` // Maximum rotation angle for X axis (degrees)
MinAngleY float64 `json:"minAngleY"` // Minimum rotation angle for Y axis (degrees)
MaxAngleY float64 `json:"maxAngleY"` // Maximum rotation angle for Y axis (degrees)
MinAngleZ float64 `json:"minAngleZ"` // Minimum rotation angle for Z axis (degrees)
MaxAngleZ float64 `json:"maxAngleZ"` // Maximum rotation angle for Z axis (degrees)
ViewWidth float64 `json:"viewWidth"` // SVG viewBox width
ViewHeight float64 `json:"viewHeight"` // SVG viewBox height
Perspective float64 `json:"perspective"` // Perspective distance (larger = less perspective)
Scale float64 `json:"scale"` // Overall scale multiplier
}
type grot3dState struct {
lastTime time.Time
angleX float64 // Current rotation around X axis (radians)
angleY float64 // Current rotation around Y axis (radians)
angleZ float64 // Current rotation around Z axis (radians)
directionX float64 // Direction multiplier for X rotation (+1 or -1)
directionY float64 // Direction multiplier for Y rotation (+1 or -1)
directionZ float64 // Direction multiplier for Z rotation (+1 or -1)
}
type point3d struct {
x, y, z float64
}
type point2d struct {
x, y float64
}
type meshData struct {
Vertices [][]float64 `json:"vertices"`
Uvs [][]float64 `json:"uvs"`
Indices []int `json:"indices"`
}
type triangleWithDepth struct {
v0, v1, v2 point2d
depth float64
visible bool
idx0, idx1, idx2 int
}
func (s *grot3dSim) GetState() simulationState {
return simulationState{
Key: s.key,
Config: s.cfg,
}
}
func (s *grot3dSim) SetConfig(vals map[string]any) error {
return updateConfigObjectFromJSON(&s.cfg, vals)
}
func (s *grot3dSim) initialize() error {
s.state.lastTime = time.Time{}
s.state.angleX = 0
s.state.angleY = 0
s.state.angleZ = 0
s.state.directionX = 1
s.state.directionY = 1
s.state.directionZ = 1
// Load mesh data if not already loaded
if len(s.vertices) == 0 {
var mesh meshData
if err := json.Unmarshal(grotMeshData, &mesh); err != nil {
return fmt.Errorf("failed to load grot holiday mesh data: %w", err)
}
// Convert to point3d
s.vertices = make([]point3d, len(mesh.Vertices))
for i, v := range mesh.Vertices {
if len(v) != 3 {
return fmt.Errorf("invalid vertex data at index %d", i)
}
s.vertices[i] = point3d{x: v[0], y: v[1], z: v[2]}
}
s.uvs = mesh.Uvs
if len(s.uvs) != len(s.vertices) {
return fmt.Errorf("UV count mismatch: %d vs %d", len(s.uvs), len(s.vertices))
}
s.indices = mesh.Indices
}
// Load texture
img, err := png.Decode(bytes.NewReader(grotBaseColor))
if err != nil {
return fmt.Errorf("failed to decode texture: %w", err)
}
s.texture = img
return nil
}
func (s *grot3dSim) NewFrame(size int) *data.Frame {
frame := data.NewFrame("")
// Time field
frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size)))
// SVG content field (string)
frame.Fields = append(frame.Fields, data.NewField("svg_content", nil, make([]string, size)))
// Also add rotation angles for reference/debugging
frame.Fields = append(frame.Fields, data.NewField("angle_x", nil, make([]float64, size)))
frame.Fields = append(frame.Fields, data.NewField("angle_y", nil, make([]float64, size)))
frame.Fields = append(frame.Fields, data.NewField("angle_z", nil, make([]float64, size)))
return frame
}
func (s *grot3dSim) GetValues(t time.Time) map[string]any {
// Initialize if this is the first call
if s.state.lastTime.IsZero() {
s.state.lastTime = t
}
// Calculate elapsed time and update rotation
if t.After(s.state.lastTime) {
dt := t.Sub(s.state.lastTime).Seconds()
s.updateRotation(dt)
s.state.lastTime = t
} else if t.Before(s.state.lastTime) {
// Can't go backwards - reinitialize
s.initialize()
s.state.lastTime = t
}
// Generate the SVG content for the current rotation
svgContent := s.generateSVG()
return map[string]any{
"time": t,
"svg_content": svgContent,
"angle_x": s.state.angleX * 180 / math.Pi, // Convert to degrees for display
"angle_y": s.state.angleY * 180 / math.Pi,
"angle_z": s.state.angleZ * 180 / math.Pi,
}
}
func (s *grot3dSim) updateRotation(dt float64) {
// Update X rotation
if s.cfg.MinAngleX == 0 && s.cfg.MaxAngleX == 0 {
// No limits - continuous rotation
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180
s.state.angleX = math.Mod(s.state.angleX, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleX := s.cfg.MinAngleX * math.Pi / 180
maxAngleX := s.cfg.MaxAngleX * math.Pi / 180
s.state.angleX += s.cfg.RotationSpeedX * dt * math.Pi / 180 * s.state.directionX
if s.state.angleX >= maxAngleX {
s.state.angleX = maxAngleX
s.state.directionX = -1
} else if s.state.angleX <= minAngleX {
s.state.angleX = minAngleX
s.state.directionX = 1
}
}
// Update Y rotation
if s.cfg.MinAngleY == 0 && s.cfg.MaxAngleY == 0 {
// No limits - continuous rotation
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180
s.state.angleY = math.Mod(s.state.angleY, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleY := s.cfg.MinAngleY * math.Pi / 180
maxAngleY := s.cfg.MaxAngleY * math.Pi / 180
s.state.angleY += s.cfg.RotationSpeedY * dt * math.Pi / 180 * s.state.directionY
if s.state.angleY >= maxAngleY {
s.state.angleY = maxAngleY
s.state.directionY = -1
} else if s.state.angleY <= minAngleY {
s.state.angleY = minAngleY
s.state.directionY = 1
}
}
// Update Z rotation
if s.cfg.MinAngleZ == 0 && s.cfg.MaxAngleZ == 0 {
// No limits - continuous rotation
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180
s.state.angleZ = math.Mod(s.state.angleZ, 2*math.Pi)
} else {
// Bouncing rotation with limits
minAngleZ := s.cfg.MinAngleZ * math.Pi / 180
maxAngleZ := s.cfg.MaxAngleZ * math.Pi / 180
s.state.angleZ += s.cfg.RotationSpeedZ * dt * math.Pi / 180 * s.state.directionZ
if s.state.angleZ >= maxAngleZ {
s.state.angleZ = maxAngleZ
s.state.directionZ = -1
} else if s.state.angleZ <= minAngleZ {
s.state.angleZ = minAngleZ
s.state.directionZ = 1
}
}
}
// rotatePoint3D applies 3D rotation around X, Y, and Z axes
func (s *grot3dSim) rotatePoint3D(p point3d) point3d {
// Rotate around X axis
cosX, sinX := math.Cos(s.state.angleX), math.Sin(s.state.angleX)
y := p.y*cosX - p.z*sinX
z := p.y*sinX + p.z*cosX
p.y, p.z = y, z
// Rotate around Y axis
cosY, sinY := math.Cos(s.state.angleY), math.Sin(s.state.angleY)
x := p.x*cosY + p.z*sinY
z = -p.x*sinY + p.z*cosY
p.x, p.z = x, z
// Rotate around Z axis
cosZ, sinZ := math.Cos(s.state.angleZ), math.Sin(s.state.angleZ)
x = p.x*cosZ - p.y*sinZ
y = p.x*sinZ + p.y*cosZ
p.x, p.y = x, y
return p
}
// project3DTo2D converts 3D point to 2D using perspective projection
func (s *grot3dSim) project3DTo2D(p point3d) point2d {
// Apply scale
scaledP := point3d{
x: p.x * s.cfg.Scale,
y: p.y * s.cfg.Scale,
z: p.z * s.cfg.Scale,
}
// Apply perspective projection
scale := s.cfg.Perspective / (s.cfg.Perspective + scaledP.z)
return point2d{
x: scaledP.x*scale + s.cfg.ViewWidth/2,
y: scaledP.y*scale + s.cfg.ViewHeight/2,
}
}
func (s *grot3dSim) generateSVG() string {
// Rotate all vertices
rotatedVertices := make([]point3d, len(s.vertices))
for i, v := range s.vertices {
rotatedVertices[i] = s.rotatePoint3D(v)
}
// Project to 2D
projectedVertices := make([]point2d, len(rotatedVertices))
for i, v := range rotatedVertices {
projectedVertices[i] = s.project3DTo2D(v)
}
// Process triangles for depth sorting and backface culling
triangles := make([]triangleWithDepth, 0, len(s.indices)/3)
// Calculate near plane for clipping
nearPlane := -s.cfg.Perspective * 0.9 / s.cfg.Scale
for i := 0; i < len(s.indices); i += 3 {
idx0 := s.indices[i]
idx1 := s.indices[i+1]
idx2 := s.indices[i+2]
v0 := rotatedVertices[idx0]
v1 := rotatedVertices[idx1]
v2 := rotatedVertices[idx2]
// Near-plane clipping: skip triangles too close to camera
if v0.z < nearPlane || v1.z < nearPlane || v2.z < nearPlane {
continue
}
// Calculate triangle center depth for sorting
centerZ := (v0.z + v1.z + v2.z) / 3
// Calculate face normal for backface culling
// Two edges of the triangle
edge1 := point3d{v1.x - v0.x, v1.y - v0.y, v1.z - v0.z}
edge2 := point3d{v2.x - v0.x, v2.y - v0.y, v2.z - v0.z}
// Cross product gives normal
normal := point3d{
x: edge1.y*edge2.z - edge1.z*edge2.y,
y: edge1.z*edge2.x - edge1.x*edge2.z,
z: edge1.x*edge2.y - edge1.y*edge2.x,
}
// Normalize the normal vector
normalMag := math.Sqrt(normal.x*normal.x + normal.y*normal.y + normal.z*normal.z)
if normalMag > 0 {
normal.x /= normalMag
normal.y /= normalMag
normal.z /= normalMag
}
// View vector (camera is looking along -Z axis)
viewVector := point3d{0, 0, -1}
// Dot product of normal and view vector (now both are unit vectors)
dotProduct := normal.x*viewVector.x + normal.y*viewVector.y + normal.z*viewVector.z
// Only render triangles facing the camera (backface culling)
// Use small tolerance to catch edge-on triangles (dot product is now -1 to 1)
visible := dotProduct < 0.2
triangles = append(triangles, triangleWithDepth{
v0: projectedVertices[idx0],
v1: projectedVertices[idx1],
v2: projectedVertices[idx2],
depth: centerZ,
visible: visible,
idx0: idx0,
idx1: idx1,
idx2: idx2,
})
}
// Sort triangles by depth (painter's algorithm - draw furthest first)
for i := 0; i < len(triangles); i++ {
for j := i + 1; j < len(triangles); j++ {
if triangles[i].depth > triangles[j].depth {
triangles[i], triangles[j] = triangles[j], triangles[i]
}
}
}
// Build SVG string
svg := fmt.Sprintf("<svg viewBox='0 0 %.0f %.0f' xmlns='http://www.w3.org/2000/svg' stroke='none'>",
s.cfg.ViewWidth, s.cfg.ViewHeight)
// Calculate colors for all visible triangles and group by color
type triangleWithColor struct {
tri triangleWithDepth
color string
opacity string
}
coloredTriangles := make([]triangleWithColor, 0, len(triangles))
bounds := s.texture.Bounds()
for _, tri := range triangles {
if !tri.visible {
continue
}
// Use depth for shading (closer = lighter)
intensity := 0.5 + (tri.depth+150)/300*0.5
if intensity < 0.5 {
intensity = 0.5
}
if intensity > 1.0 {
intensity = 1.0
}
// Get centroid UV
uv0 := s.uvs[tri.idx0]
uv1 := s.uvs[tri.idx1]
uv2 := s.uvs[tri.idx2]
centU := (uv0[0] + uv1[0] + uv2[0]) / 3
centV := (uv0[1] + uv1[1] + uv2[1]) / 3
// Clamp UVs to 0-1
centU = math.Max(0, math.Min(1, centU))
centV = math.Max(0, math.Min(1, centV))
// Sample texture - no V flip
x := int(centU * float64(bounds.Dx()-1))
y := int(centV * float64(bounds.Dy()-1))
c := s.texture.At(x, y).(color.RGBA)
// Apply depth intensity to the sampled color
r := int(float64(c.R) * intensity)
g := int(float64(c.G) * intensity)
b := int(float64(c.B) * intensity)
// Quantize colors to reduce palette (round to nearest 16)
r = (r / 16) * 16
g = (g / 16) * 16
b = (b / 16) * 16
colorStr := fmt.Sprintf("#%02x%02x%02x", r, g, b)
opacityStr := ""
if c.A < 255 {
opacityStr = fmt.Sprintf("%.2f", float64(c.A)/255)
}
coloredTriangles = append(coloredTriangles, triangleWithColor{
tri: tri,
color: colorStr,
opacity: opacityStr,
})
}
// Group triangles by color and render
i := 0
for i < len(coloredTriangles) {
currentColor := coloredTriangles[i].color
currentOpacity := coloredTriangles[i].opacity
// Build path data for all triangles with the same color
pathData := ""
for i < len(coloredTriangles) &&
coloredTriangles[i].color == currentColor &&
coloredTriangles[i].opacity == currentOpacity {
tri := coloredTriangles[i].tri
pathData += fmt.Sprintf(
"M%.2f,%.2fL%.2f,%.2fL%.2f,%.2fZ",
tri.v0.x, tri.v0.y,
tri.v1.x, tri.v1.y,
tri.v2.x, tri.v2.y,
)
i++
}
// Output single path with all triangles
if currentOpacity != "" {
svg += fmt.Sprintf("<path fill='%s' opacity='%s' d='%s'/>", currentColor, currentOpacity, pathData)
} else {
svg += fmt.Sprintf("<path fill='%s' d='%s'/>", currentColor, pathData)
}
}
svg += "</svg>"
return svg
}
func (s *grot3dSim) Close() error {
return nil
}
func newGrot3dSimInfo() simulationInfo {
return simulationInfo{
Type: "grot3d",
Name: "Rotating 3D Grot",
Description: "Renders a rotating 3D grot model using SVG triangles",
OnlyForward: false,
ConfigFields: data.NewFrame("config",
data.NewField("rotationSpeedX", nil, []float64{0}),
data.NewField("rotationSpeedY", nil, []float64{5}),
data.NewField("rotationSpeedZ", nil, []float64{30}),
data.NewField("minAngleX", nil, []float64{-45}),
data.NewField("maxAngleX", nil, []float64{45}),
data.NewField("minAngleY", nil, []float64{-45}),
data.NewField("maxAngleY", nil, []float64{45}),
data.NewField("minAngleZ", nil, []float64{0}),
data.NewField("maxAngleZ", nil, []float64{0}),
data.NewField("viewWidth", nil, []float64{800}),
data.NewField("viewHeight", nil, []float64{800}),
data.NewField("perspective", nil, []float64{1000}),
data.NewField("scale", nil, []float64{5.0}),
),
create: func(state simulationState) (Simulation, error) {
sim := &grot3dSim{
key: state.Key,
cfg: grot3dConfig{
RotationSpeedX: 0,
RotationSpeedY: 5,
RotationSpeedZ: 30,
MinAngleX: -45,
MaxAngleX: 45,
MinAngleY: -45,
MaxAngleY: 45,
MinAngleZ: 0,
MaxAngleZ: 0,
ViewWidth: 800,
ViewHeight: 800,
Perspective: 1000,
Scale: 5.0,
},
}
if state.Config != nil {
vals, ok := state.Config.(map[string]any)
if ok {
err := sim.SetConfig(vals)
if err != nil {
return nil, err
}
}
}
if err := sim.initialize(); err != nil {
return nil, err
}
return sim, nil
},
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

File diff suppressed because it is too large Load Diff