535 lines
16 KiB
Dart
535 lines
16 KiB
Dart
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<List<int>> _icosahedronFaces = <List<int>>[
|
||
<int>[0, 11, 5],
|
||
<int>[0, 5, 1],
|
||
<int>[0, 1, 7],
|
||
<int>[0, 7, 10],
|
||
<int>[0, 10, 11],
|
||
<int>[1, 5, 9],
|
||
<int>[5, 11, 4],
|
||
<int>[11, 10, 2],
|
||
<int>[10, 7, 6],
|
||
<int>[7, 1, 8],
|
||
<int>[3, 9, 4],
|
||
<int>[3, 4, 2],
|
||
<int>[3, 2, 6],
|
||
<int>[3, 6, 8],
|
||
<int>[3, 8, 9],
|
||
<int>[4, 9, 5],
|
||
<int>[2, 4, 11],
|
||
<int>[6, 2, 10],
|
||
<int>[8, 6, 7],
|
||
<int>[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<List<int>> 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<int> 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<Offset> 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>[
|
||
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<Offset> 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<String> drawnEdges = <String>{};
|
||
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<Offset> projected,
|
||
Paint paint,
|
||
Set<String> 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;
|
||
}
|