import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; /// Deterministic 3D crystal mesh + palette derived from a question UUID. /// /// Parses the canonical 16-byte UUID (hyphens optional), builds a perturbed /// icosahedron with optional stellated facets, and paints it with depth-sorted /// shaded faces so the same id always produces the same glyph. class GuidGlyphShape { GuidGlyphShape._(this.bytes); final Uint8List bytes; /// Builds a shape from [guid]. Non-UUID strings fall back to a stable hash. factory GuidGlyphShape.fromGuid(String guid) { final Uint8List? parsed = tryParseUuidBytes(guid); if (parsed != null) { return GuidGlyphShape._(parsed); } return GuidGlyphShape._(_hashTo16Bytes(guid)); } static const double _phi = 1.618033988749895; static final List<_Vec3> _icosahedronBase = <_Vec3>[ _Vec3(-1, _phi, 0), _Vec3(1, _phi, 0), _Vec3(-1, -_phi, 0), _Vec3(1, -_phi, 0), _Vec3(0, -1, _phi), _Vec3(0, 1, _phi), _Vec3(0, -1, -_phi), _Vec3(0, 1, -_phi), _Vec3(_phi, 0, -1), _Vec3(_phi, 0, 1), _Vec3(-_phi, 0, -1), _Vec3(-_phi, 0, 1), ].map((v) => v.normalized(scale: 0.88)).toList(growable: false); static const List> _icosahedronFaces = >[ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], ]; double _byte(int index) => bytes[index % 16] / 255; /// Base orientation baked into every guid (before slider warp). ({double x, double y, double z}) get _baseEuler => ( x: (_byte(1) - 0.5) * math.pi * 0.9, y: (_byte(2) - 0.5) * math.pi * 2, z: (_byte(3) - 0.5) * math.pi * 0.55, ); _Vec3 get _lightDirection => _Vec3( 0.25 + (_byte(10) - 0.5) * 0.5, -0.45 + (_byte(11) - 0.5) * 0.35, 0.65 + (_byte(12) - 0.5) * 0.4, ).normalized(); /// Isometric projection mix from guid bytes. ({double xz, double lift}) get _projection => ( xz: 0.42 + _byte(13) * 0.22, lift: 0.28 + _byte(14) * 0.18, ); List<_Vec3> _unitVerticesRaw() { return List<_Vec3>.generate(12, (int i) { final _Vec3 base = _icosahedronBase[i]; final double radial = 0.76 + _byte(i) * 0.42 + (_byte(i + 7) - 0.5) * 0.14; final _Vec3 wobble = _Vec3( (_byte(i + 3) - 0.5) * 0.22, (_byte(i + 5) - 0.5) * 0.22, (_byte(i + 9) - 0.5) * 0.22, ); return (base + wobble).normalized(scale: radial); }); } _Vec3 _rotateEuler(_Vec3 v, ({double x, double y, double z}) euler) { var out = v.rotateX(euler.x).rotateY(euler.y).rotateZ(euler.z); return out; } /// Stellated icosahedron: some faces gain a pyramid apex for extra complexity. ({List<_Vec3> vertices, List<_MeshFace> faces}) _buildMesh( List<_Vec3> vertices, List> triangles, ) { final List<_Vec3> verts = List<_Vec3>.from(vertices); final List<_MeshFace> faces = <_MeshFace>[ for (int fi = 0; fi < triangles.length; fi++) _MeshFace( fi, triangles[fi][0], triangles[fi][1], triangles[fi][2], ), ]; for (int fi = 0; fi < triangles.length; fi++) { if ((bytes[(fi + 4) % 16] % 3) == 0) { continue; } final List tri = triangles[fi]; final _Vec3 v0 = verts[tri[0]]; final _Vec3 v1 = verts[tri[1]]; final _Vec3 v2 = verts[tri[2]]; final _Vec3 centroid = (v0 + v1 + v2) * (1 / 3); final _Vec3 normal = _Vec3.cross(v1 - v0, v2 - v0).normalized(); final double spike = 0.18 + _byte(fi + 6) * 0.38; final int apex = verts.length; verts.add(centroid + normal * spike); faces[fi] = _MeshFace(fi, tri[0], tri[1], apex); faces.add(_MeshFace(fi + 100, tri[1], tri[2], apex)); faces.add(_MeshFace(fi + 200, tri[2], tri[0], apex)); } return (vertices: verts, faces: faces); } ({List<_Vec3> vertices, List<_MeshFace> faces}) displayMesh(num displayValue) { final double t = displayT(displayValue); final double spinSign = (bytes[9] & 1) == 0 ? 1.0 : -1.0; final ({double x, double y, double z}) euler = ( x: _baseEuler.x + t * (0.32 + _byte(4) * 0.28) * spinSign, y: _baseEuler.y + t * (0.55 + _byte(5) * 0.35) * spinSign, z: _baseEuler.z + t * (0.2 + _byte(6) * 0.18), ); final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh = _buildMesh(_unitVerticesRaw(), _icosahedronFaces); final List<_Vec3> rotated = <_Vec3>[ for (final _Vec3 v in mesh.vertices) _rotateEuler(v, euler), ]; return (vertices: rotated, faces: mesh.faces); } /// Projects a unit-space vertex to 2D (isometric-style). Offset _projectUnit(_Vec3 v) { final ({double xz, double lift}) proj = _projection; return Offset( v.x - v.z * proj.xz, v.y + v.z * proj.lift + v.x * proj.lift * 0.12, ); } List displayUnitVertices(num displayValue) { final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh = displayMesh(displayValue); return mesh.vertices.map(_projectUnit).toList(growable: false); } /// Fill color: warm accent band so glyphs stay on-brand but distinct. Color fillColor() { final double hue = 8 + ((bytes[2] << 8 | bytes[3]) / 65535) * 42; return HSLColor.fromAHSL(1, hue, 0.72, 0.52).toColor(); } Color strokeColor() => HSLColor.fromColor(fillColor()).withLightness(0.38).toColor(); /// Normalized slider position in [-1, 1] for [displayValue] in [-10, 10]. static double displayT(num displayValue) => (displayValue / 10).clamp(-1.0, 1.0).toDouble(); /// True when the slider is at neutral zero (no directional guess). static bool isNeutralZero(num displayValue) => displayValue == 0; /// 0 at zero, 1 at ±10. static double displayIntensity(num displayValue) => isNeutralZero(displayValue) ? 0 : (displayValue.abs() / 10).clamp(0.0, 1.0); static const Color _negativeAccent = Color(0xFFF87171); static const Color _neutralBlend = Color(0xFF64748B); /// Progressive green (+) / red (−) fill; crystal white at zero. Color displayFillColor(num displayValue) { if (isNeutralZero(displayValue)) { return const Color(0xFFF8FCFF); } final double intensity = displayIntensity(displayValue); final Color target = displayValue > 0 ? AppColors.success : _negativeAccent; return Color.lerp(fillColor(), target, 0.25 + intensity * 0.75)!; } Color displayStrokeColor(num displayValue) { if (isNeutralZero(displayValue)) { return const Color(0xFFE2E8F0); } final double intensity = displayIntensity(displayValue); final Color target = displayValue > 0 ? AppColors.success : _negativeAccent; return Color.lerp(_neutralBlend, target, 0.35 + intensity * 0.65)!; } Color _shadeFaceColor(Color base, double shade, num displayValue) { if (isNeutralZero(displayValue)) { return Color.lerp( const Color(0xFFE8EEF4), const Color(0xFFF8FCFF), shade, )!; } final HSLColor hsl = HSLColor.fromColor(base); return hsl .withLightness((hsl.lightness * (0.55 + shade * 0.5)).clamp(0.18, 0.82)) .withSaturation((hsl.saturation * (0.75 + shade * 0.35)).clamp(0.2, 1)) .toColor(); } /// Furthest 2D extent after projection (for glow sizing). double displayMaxUnitRadius(num displayValue) { double maxR = 0; for (final Offset p in displayUnitVertices(displayValue)) { maxR = math.max(maxR, p.distance); } return maxR > 0 ? maxR : 0.5; } /// Glow color for shadows (crystal white at zero, green +, red −). Color displayGlowColor(num displayValue) { if (isNeutralZero(displayValue)) { return const Color(0xFFF8FCFF); } return displayValue > 0 ? AppColors.success : _negativeAccent; } double displayStrokeWidth(num displayValue) { final double t = displayT(displayValue); return (1.8 + t.abs() * 0.9 + (bytes[8] / 255) * 0.35).clamp(1.4, 3.2); } static Uint8List? tryParseUuidBytes(String guid) { final String hex = guid.replaceAll('-', '').trim().toLowerCase(); if (hex.length != 32 || !RegExp(r'^[0-9a-f]{32}$').hasMatch(hex)) { return null; } final Uint8List out = Uint8List(16); for (int i = 0; i < 16; i++) { out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); } return out; } static Uint8List _hashTo16Bytes(String input) { var h1 = 0x811c9dc5; var h2 = 0x01000193; for (final int codeUnit in input.codeUnits) { h1 = (h1 ^ codeUnit) * 0x01000193; h2 = (h2 + codeUnit) * 0x85ebca6b; } final Uint8List out = Uint8List(16); for (int i = 0; i < 16; i++) { final int mix = (h1 >> (i % 16)) ^ (h2 >> ((i + 3) % 16)) ^ (i * 31); out[i] = mix & 0xff; } return out; } } class _Vec3 { const _Vec3(this.x, this.y, this.z); final double x; final double y; final double z; double get length => math.sqrt(x * x + y * y + z * z); _Vec3 normalized({double scale = 1}) { final double len = length; if (len < 1e-9) { return _Vec3(0, scale, 0); } final double s = scale / len; return _Vec3(x * s, y * s, z * s); } _Vec3 operator +(_Vec3 other) => _Vec3(x + other.x, y + other.y, z + other.z); _Vec3 operator -(_Vec3 other) => _Vec3(x - other.x, y - other.y, z - other.z); _Vec3 operator *(double scalar) => _Vec3(x * scalar, y * scalar, z * scalar); _Vec3 rotateX(double angle) { final double c = math.cos(angle); final double s = math.sin(angle); return _Vec3(x, y * c - z * s, y * s + z * c); } _Vec3 rotateY(double angle) { final double c = math.cos(angle); final double s = math.sin(angle); return _Vec3(x * c + z * s, y, -x * s + z * c); } _Vec3 rotateZ(double angle) { final double c = math.cos(angle); final double s = math.sin(angle); return _Vec3(x * c - y * s, x * s + y * c, z); } static double dot(_Vec3 a, _Vec3 b) => a.x * b.x + a.y * b.y + a.z * b.z; static _Vec3 cross(_Vec3 a, _Vec3 b) => _Vec3( a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x, ); } class _MeshFace { const _MeshFace(this.id, this.i0, this.i1, this.i2); final int id; final int i0; final int i1; final int i2; double averageDepth(List<_Vec3> vertices) => (vertices[i0].z + vertices[i1].z + vertices[i2].z) / 3; _Vec3 normal(List<_Vec3> vertices) { final _Vec3 v0 = vertices[i0]; final _Vec3 v1 = vertices[i1]; final _Vec3 v2 = vertices[i2]; return _Vec3.cross(v1 - v0, v2 - v0).normalized(); } } /// Painted glyph for a question id (replaces the fixed red dot). class QuestionGuidGlyph extends StatelessWidget { const QuestionGuidGlyph({ super.key, required this.guid, this.size = 80, this.displayValue = 0, }); final String guid; final double size; /// Slider value in [-10, 10]; warps the painted glyph for display only. final num displayValue; @override Widget build(BuildContext context) { final GuidGlyphShape shape = GuidGlyphShape.fromGuid(guid); final double intensity = GuidGlyphShape.displayIntensity(displayValue); final Color glow = shape.displayGlowColor(displayValue); final double strokeWidth = shape.displayStrokeWidth(displayValue); final double baseRadius = (size / 2 - strokeWidth - 2).clamp(18.0, size / 2); final double paintedRadius = baseRadius * (1 + intensity * 0.12); final double bodyDiameter = paintedRadius * 2; final double blur = bodyDiameter * (0.2 + intensity * 0.16); final double spread = bodyDiameter * (0.03 + intensity * 0.06); final double glowAlpha = 0.38 + intensity * 0.42; return SizedBox( width: size, height: size, child: Center( child: Container( width: bodyDiameter, height: bodyDiameter, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.transparent, boxShadow: [ BoxShadow( color: glow.withValues(alpha: glowAlpha), blurRadius: blur, spreadRadius: spread * 0.35, ), BoxShadow( color: glow.withValues(alpha: glowAlpha * 0.45), blurRadius: blur * 1.75, spreadRadius: spread, ), ], ), child: CustomPaint( painter: _GuidGlyphPainter( shape: shape, displayValue: displayValue, paintedRadius: paintedRadius, ), ), ), ), ); } } class _GuidGlyphPainter extends CustomPainter { _GuidGlyphPainter({ required this.shape, required this.displayValue, required this.paintedRadius, }); final GuidGlyphShape shape; final num displayValue; final double paintedRadius; @override void paint(Canvas canvas, Size size) { final Offset center = Offset(size.width / 2, size.height / 2); final double maxUnitR = shape.displayMaxUnitRadius(displayValue); final double scale = paintedRadius / maxUnitR; final double strokeWidth = shape.displayStrokeWidth(displayValue); final ({List<_Vec3> vertices, List<_MeshFace> faces}) mesh = shape.displayMesh(displayValue); final List projected = mesh.vertices .map((v) => center + shape._projectUnit(v) * scale) .toList(growable: false); final List<_MeshFace> sorted = List<_MeshFace>.from(mesh.faces) ..sort( (a, b) => a.averageDepth(mesh.vertices).compareTo(b.averageDepth(mesh.vertices)), ); final Color baseFill = shape.displayFillColor(displayValue); final _Vec3 light = shape._lightDirection; for (final _MeshFace face in sorted) { final _Vec3 normal = face.normal(mesh.vertices); final double ndotl = _Vec3.dot(normal, light).clamp(-1.0, 1.0) * 0.5 + 0.5; final double shade = (0.28 + ndotl * 0.72).clamp(0.22, 1.0); final Path path = Path() ..moveTo(projected[face.i0].dx, projected[face.i0].dy) ..lineTo(projected[face.i1].dx, projected[face.i1].dy) ..lineTo(projected[face.i2].dx, projected[face.i2].dy) ..close(); canvas.drawPath( path, Paint() ..color = shape._shadeFaceColor(baseFill, shade, displayValue) ..style = PaintingStyle.fill, ); } final Color edgeColor = shape.displayStrokeColor(displayValue); final Paint edgePaint = Paint() ..color = edgeColor.withValues(alpha: GuidGlyphShape.isNeutralZero(displayValue) ? 0.55 : 0.75) ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeJoin = StrokeJoin.round; final Set drawnEdges = {}; for (final _MeshFace face in mesh.faces) { _strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i0, face.i1); _strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i1, face.i2); _strokeEdge(canvas, projected, edgePaint, drawnEdges, face.i2, face.i0); } // Specular highlight on the front-most face. if (sorted.isNotEmpty) { final _MeshFace front = sorted.last; final Offset highlight = Offset( (projected[front.i0].dx + projected[front.i1].dx + projected[front.i2].dx) / 3, (projected[front.i0].dy + projected[front.i1].dy + projected[front.i2].dy) / 3, ); canvas.drawCircle( highlight, (scale * 0.09).clamp(2.0, 6.0), Paint() ..color = Colors.white.withValues( alpha: GuidGlyphShape.isNeutralZero(displayValue) ? 0.35 : 0.28, ), ); } } void _strokeEdge( Canvas canvas, List projected, Paint paint, Set drawnEdges, int a, int b, ) { final int lo = a < b ? a : b; final int hi = a < b ? b : a; final String key = '$lo-$hi'; if (!drawnEdges.add(key)) { return; } canvas.drawLine(projected[lo], projected[hi], paint); } @override bool shouldRepaint(covariant _GuidGlyphPainter oldDelegate) => oldDelegate.shape.bytes != shape.bytes || oldDelegate.displayValue != displayValue || oldDelegate.paintedRadius != paintedRadius; }