Last active
July 15, 2026 13:41
-
-
Save chooyan-eng/6c461cc98aedaf806259b6e8e6c00ed0 to your computer and use it in GitHub Desktop.
main.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // "Nets of Solids" — a self-contained, single-file version | |
| // | |
| // Extracted from a math-experience app for elementary school kids | |
| // (private repository) as a single runnable screen for sharing as a Gist. | |
| // No external packages (Flutter standard APIs only). | |
| // | |
| // How to run: | |
| // flutter create net_folding_demo | |
| // cd net_folding_demo | |
| // (replace lib/main.dart with this file) | |
| // flutter run | |
| // * Requires Flutter 3.27+ (uses Color.withValues). | |
| // | |
| // Layout (the file concatenates the original repository's files as sections): | |
| // 1. App shell (main / MaterialApp) ...... minimal setup for the Gist | |
| // 2. models: pure folding/projection logic (net_folding.dart) | |
| // 3. models: drawing hit-tests and mapping (net_drawing.dart) | |
| // 4. widgets: the "puffy" tactile UI kit (puffy_*.dart / control_math.dart) | |
| // 5. Simplified island layout (stand-in for ExperienceLayout / IslandScaffold) | |
| // 6. Solid glyphs for the shape chips (net_folding_glyphs.dart) | |
| // 7. CustomPainter (net_folding_painter.dart) | |
| // 8. The experience screen (net_folding_page.dart) | |
| import 'dart:math' as math; | |
| import 'package:flutter/material.dart'; | |
| // ============================================================================= | |
| // 1. App shell | |
| // ============================================================================= | |
| void main() => runApp(const NetFoldingGistApp()); | |
| /// Minimal shell for the Gist. Carries over only the piece of the full app's | |
| /// theme that affects this screen's look: the orange seed color. | |
| class NetFoldingGistApp extends StatelessWidget { | |
| const NetFoldingGistApp({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| title: 'Nets of Solids', | |
| debugShowCheckedModeBanner: false, | |
| theme: ThemeData( | |
| colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange), | |
| ), | |
| darkTheme: ThemeData( | |
| colorScheme: ColorScheme.fromSeed( | |
| seedColor: Colors.orange, | |
| brightness: Brightness.dark, | |
| ), | |
| ), | |
| home: const NetFoldingPage(), | |
| ); | |
| } | |
| } | |
| /// In the full app these strings come from l10n (AppLocalizations). | |
| /// The Gist version hardcodes the English ones. | |
| class _L10n { | |
| const _L10n(); | |
| String get netFoldingCube => 'Cube'; | |
| String get netFoldingTetra => 'Pyramid'; | |
| String get netFoldingCylinder => 'Cylinder'; | |
| String get netFoldingCone => 'Cone'; | |
| String get netFoldingBaseRadius => 'Base radius'; | |
| String get netFoldingSlant => 'Slant'; | |
| String get netFoldingHeight => 'Height'; | |
| String get netFoldingUnfold => 'Unfold'; | |
| String get netFoldingFold => 'Fold up'; | |
| String get netFoldingRotateMode => 'Rotate'; | |
| String get netFoldingDrawMode => 'Draw'; | |
| String get netFoldingClearDrawing => 'Clear all'; | |
| } | |
| // ============================================================================= | |
| // 2. models: pure folding/projection logic (net_folding.dart) | |
| // | |
| // Coordinate system: x=right, y=down, z=away. The unfolded net lies on the | |
| // horizontal plane y=0, and folding lifts each face upward (-y). | |
| // ============================================================================= | |
| /// Lightweight 3D vector dedicated to this experience. | |
| class Vec3 { | |
| const Vec3(this.x, this.y, this.z); | |
| final double x; | |
| final double y; | |
| final double z; | |
| 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 s) => Vec3(x * s, y * s, z * s); | |
| double dot(Vec3 other) => x * other.x + y * other.y + z * other.z; | |
| Vec3 cross(Vec3 other) => Vec3( | |
| y * other.z - z * other.y, | |
| z * other.x - x * other.z, | |
| x * other.y - y * other.x, | |
| ); | |
| double get length => math.sqrt(dot(this)); | |
| Vec3 normalized() { | |
| final l = length; | |
| return l == 0 ? this : Vec3(x / l, y / l, z / l); | |
| } | |
| /// Rotation around the X axis. A positive angle tilts the y axis (down) | |
| /// toward the z axis (away). | |
| Vec3 rotatedX(double angle) { | |
| final c = math.cos(angle); | |
| final s = math.sin(angle); | |
| return Vec3(x, y * c - z * s, y * s + z * c); | |
| } | |
| /// Rotation around the Y axis. A positive angle tilts the z axis (away) | |
| /// toward the x axis (right). | |
| Vec3 rotatedY(double angle) { | |
| final c = math.cos(angle); | |
| final s = math.sin(angle); | |
| return Vec3(x * c + z * s, y, -x * s + z * c); | |
| } | |
| @override | |
| String toString() => 'Vec3($x, $y, $z)'; | |
| } | |
| /// Rotates point [p] by [angle] around the line through [axisPoint] with unit | |
| /// direction [axisDir] (Rodrigues' rotation formula). The primitive that all | |
| /// hinge rotations build on. | |
| Vec3 rotatePointAroundAxis(Vec3 p, Vec3 axisPoint, Vec3 axisDir, double angle) { | |
| final v = p - axisPoint; | |
| final c = math.cos(angle); | |
| final s = math.sin(angle); | |
| final k = axisDir; | |
| return axisPoint + v * c + k.cross(v) * s + k * (k.dot(v) * (1 - c)); | |
| } | |
| /// One face of a net. It folds around the edge (hinge) shared with its parent. | |
| /// | |
| /// Corners and hinges are all stored in the 2D coordinates (u, v) of the | |
| /// fully-opened net. Keeping the hinges in flat-net coordinates means folding | |
| /// is just the composition "own hinge rotation → parent's hinge rotation → … | |
| /// → root" (each parent's rotation carries its whole subtree along). | |
| class FoldableFace { | |
| const FoldableFace({ | |
| required this.corners, | |
| this.parentIndex = -1, | |
| this.hingeStart, | |
| this.hingeEnd, | |
| this.foldAngle = math.pi / 2, | |
| }); | |
| /// Vertices in net coordinates (adjacent order). | |
| final List<Offset> corners; | |
| /// Index of the parent face. The root is -1 and has no hinge. | |
| final int parentIndex; | |
| /// Both ends of the hinge (net coordinates). The hingeStart→hingeEnd | |
| /// direction is chosen so a positive [foldAngle] lifts the face up (-y). | |
| final Offset? hingeStart; | |
| final Offset? hingeEnd; | |
| /// Rotation angle when fully folded (t=1). Every cube hinge is 90°. | |
| final double foldAngle; | |
| } | |
| /// A named cube net: the display name for the switcher UI plus the net. | |
| class CubeNetPattern { | |
| const CubeNetPattern({required this.name, required this.net}); | |
| final String name; | |
| final FoldableNet net; | |
| } | |
| /// A tree of hinge-connected faces. Computes 3D vertices for a fold amount t. | |
| class FoldableNet { | |
| const FoldableNet({required this.faces}); | |
| /// The list of faces. A parent always precedes its children (root first). | |
| final List<FoldableFace> faces; | |
| /// The cross-shaped cube net (edge length 1). Face order: | |
| /// bottom, back, left, right, front, top. | |
| /// Net coordinates (u, v) map to 3D (x, z); the bottom face is (0,0)-(1,1). | |
| factory FoldableNet.cubeCross() => FoldableNet.fromCells(const [ | |
| Offset(0, 0), // bottom (root) | |
| Offset(0, -1), // back | |
| Offset(-1, 0), // left | |
| Offset(1, 0), // right | |
| Offset(0, 1), // front | |
| Offset(0, 2), // top (past the front; becomes the lid) | |
| ]); | |
| /// The net of a regular tetrahedron. The central equilateral triangle is the | |
| /// base (root), with one outward-pointing triangular flap attached to each | |
| /// of its 3 edges. Each flap hinges on the shared edge and lifts up (-y) for | |
| /// a positive foldAngle. With foldAngle = π − arccos(1/3) (≈109.47°), the | |
| /// apexes of the 3 flaps meet at a single point in space at t=1. | |
| /// | |
| /// Hinge orientation follows the same convention as the cube (a face lifts | |
| /// up when hingeDir × (direction to apex) > 0). Coordinates are an | |
| /// equilateral triangle (edge length [edge]) in the (u, v) net plane. | |
| factory FoldableNet.tetrahedron({double edge = 1.6}) { | |
| final e = edge; | |
| final h = e * math.sqrt(3) / 2; // height of the equilateral triangle | |
| final a = const Offset(0, 0); | |
| final b = Offset(e, 0); | |
| final c = Offset(e / 2, h); | |
| final foldAngle = math.pi - math.acos(1 / 3); | |
| // Each flap's apex is the root's third vertex mirrored across the shared | |
| // edge, landing outside the base. | |
| final apexAb = Offset(e / 2, -h); // outside edge AB (mirror of C) | |
| final apexBc = Offset(1.5 * e, h); // outside edge BC (mirror of A) | |
| final apexCa = Offset(-0.5 * e, h); // outside edge CA (mirror of B) | |
| return FoldableNet( | |
| faces: [ | |
| FoldableFace(corners: [a, b, c]), // base (root) | |
| // Each flap's corners are [hinge vertex, hinge vertex, apex]. The | |
| // start/end of the hinge are chosen so the flap lifts toward the apex | |
| // side (positive 2D cross product). | |
| FoldableFace( | |
| corners: [b, a, apexAb], | |
| parentIndex: 0, | |
| hingeStart: b, | |
| hingeEnd: a, | |
| foldAngle: foldAngle, | |
| ), | |
| FoldableFace( | |
| corners: [c, b, apexBc], | |
| parentIndex: 0, | |
| hingeStart: c, | |
| hingeEnd: b, | |
| foldAngle: foldAngle, | |
| ), | |
| FoldableFace( | |
| corners: [a, c, apexCa], | |
| parentIndex: 0, | |
| hingeStart: a, | |
| hingeEnd: c, | |
| foldAngle: foldAngle, | |
| ), | |
| ], | |
| ); | |
| } | |
| /// Builds a cube net from a placement of unit cells. | |
| /// | |
| /// [cells] holds the grid coordinates of each face's top-left corner. The | |
| /// face at [rootIndex] becomes the root (pinned to the horizontal plane), | |
| /// and a breadth-first walk over edge-adjacent cells forms the spanning | |
| /// tree used as the hinge tree. Faces are stored in visit order, which | |
| /// naturally satisfies the "parent precedes child" invariant. | |
| /// A cube's adjacent faces always meet at a 90° valley fold, so any | |
| /// spanning tree closes correctly with every hinge at π/2. | |
| factory FoldableNet.fromCells(List<Offset> cells, {int rootIndex = 0}) { | |
| assert(cells.length == 6, 'a cube net must consist of 6 cells'); | |
| final parentOf = List<int>.filled(cells.length, -1); | |
| final visited = List<bool>.filled(cells.length, false); | |
| visited[rootIndex] = true; | |
| final order = <int>[rootIndex]; | |
| for (var head = 0; head < order.length; head++) { | |
| final current = order[head]; | |
| for (var i = 0; i < cells.length; i++) { | |
| if (visited[i] || !_cellsAdjacent(cells[current], cells[i])) continue; | |
| visited[i] = true; | |
| parentOf[i] = current; | |
| order.add(i); | |
| } | |
| } | |
| assert(order.length == cells.length, 'all cells must be edge-connected'); | |
| // Position in cells → position in faces; used to remap parentIndex. | |
| final faceIndexOf = List<int>.filled(cells.length, -1); | |
| for (var f = 0; f < order.length; f++) { | |
| faceIndexOf[order[f]] = f; | |
| } | |
| return FoldableNet( | |
| faces: [ | |
| for (final cellIndex in order) | |
| if (parentOf[cellIndex] < 0) | |
| FoldableFace(corners: _cellCorners(cells[cellIndex])) | |
| else | |
| _hingedFace( | |
| cell: cells[cellIndex], | |
| parentCell: cells[parentOf[cellIndex]], | |
| parentIndex: faceIndexOf[parentOf[cellIndex]], | |
| ), | |
| ], | |
| ); | |
| } | |
| /// All cube nets (11 kinds, up to rotation and reflection). | |
| /// The familiar cross comes first, then 1-4-1 → 2-3-1 → staircase → 3-3. | |
| /// | |
| /// A 1-4-1 net is "a column of 4 cells plus one tab on each side". Reducing | |
| /// the tab-row pairs by the square's symmetry group D4 leaves 6 of them, | |
| /// (0,0) through (1,2) (rows of the column numbered -1,0,1,2 from the top; | |
| /// the cross is (0,0)). | |
| static List<CubeNetPattern> allCubeNets() { | |
| // Builds a 1-4-1 net: the column (0,-1)..(0,2) plus left/right tabs. | |
| FoldableNet oneFourOne(Offset leftTab, Offset rightTab) => | |
| FoldableNet.fromCells([ | |
| const Offset(0, 0), | |
| const Offset(0, -1), | |
| const Offset(0, 1), | |
| const Offset(0, 2), | |
| leftTab, | |
| rightTab, | |
| ]); | |
| // Builds a 2-3-1 net: a row of 3 cells (1,1)..(3,1) with a 2-cell domino | |
| // at the upper left and 1 cell below. The bottom cell's position gives 3 | |
| // variants. | |
| FoldableNet twoThreeOne(Offset bottomCell) => FoldableNet.fromCells([ | |
| const Offset(2, 1), | |
| const Offset(1, 1), | |
| const Offset(3, 1), | |
| const Offset(1, 0), | |
| const Offset(0, 0), | |
| bottomCell, | |
| ]); | |
| return [ | |
| CubeNetPattern(name: 'Cross (1-4-1)', net: FoldableNet.cubeCross()), | |
| CubeNetPattern( | |
| name: '1-4-1 #2', | |
| net: oneFourOne(const Offset(-1, -1), const Offset(1, -1)), | |
| ), | |
| CubeNetPattern( | |
| name: '1-4-1 #3', | |
| net: oneFourOne(const Offset(-1, -1), const Offset(1, 0)), | |
| ), | |
| CubeNetPattern( | |
| name: '1-4-1 #4', | |
| net: oneFourOne(const Offset(-1, -1), const Offset(1, 1)), | |
| ), | |
| CubeNetPattern( | |
| name: '1-4-1 #5', | |
| net: oneFourOne(const Offset(-1, -1), const Offset(1, 2)), | |
| ), | |
| CubeNetPattern( | |
| name: '1-4-1 #6', | |
| net: oneFourOne(const Offset(-1, 0), const Offset(1, 1)), | |
| ), | |
| CubeNetPattern(name: '2-3-1 #1', net: twoThreeOne(const Offset(1, 2))), | |
| CubeNetPattern(name: '2-3-1 #2', net: twoThreeOne(const Offset(2, 2))), | |
| CubeNetPattern(name: '2-3-1 #3', net: twoThreeOne(const Offset(3, 2))), | |
| CubeNetPattern( | |
| name: 'Staircase (2-2-2)', | |
| net: FoldableNet.fromCells(const [ | |
| Offset(1, 1), | |
| Offset(0, 0), | |
| Offset(1, 0), | |
| Offset(2, 1), | |
| Offset(2, 2), | |
| Offset(3, 2), | |
| ]), | |
| ), | |
| CubeNetPattern( | |
| name: '3-3', | |
| net: FoldableNet.fromCells(const [ | |
| Offset(2, 0), | |
| Offset(0, 0), | |
| Offset(1, 0), | |
| Offset(2, 1), | |
| Offset(3, 1), | |
| Offset(4, 1), | |
| ]), | |
| ), | |
| ]; | |
| } | |
| /// Whether two cells share an edge (Manhattan distance 1 on the grid). | |
| static bool _cellsAdjacent(Offset a, Offset b) => | |
| ((a.dx - b.dx).abs() + (a.dy - b.dy).abs() - 1).abs() < 1e-9; | |
| /// The cell's 4 vertices (adjacent order), matching the winding used by the | |
| /// handwritten cubeCross definition. | |
| static List<Offset> _cellCorners(Offset cell) => [ | |
| cell, | |
| cell + const Offset(1, 0), | |
| cell + const Offset(1, 1), | |
| cell + const Offset(0, 1), | |
| ]; | |
| /// Builds a face hinged on the edge shared with its parent. The hinge | |
| /// direction is picked so a positive foldAngle lifts the face up (-y): | |
| /// the side where the 2D cross product of (hingeEnd-hingeStart) and | |
| /// (childCell-parentCell) is positive (same convention as cubeCross). | |
| static FoldableFace _hingedFace({ | |
| required Offset cell, | |
| required Offset parentCell, | |
| required int parentIndex, | |
| }) { | |
| final corners = _cellCorners(cell); | |
| final shared = [ | |
| for (final c in corners) | |
| if (_cellCorners(parentCell).any((p) => (p - c).distance < 1e-9)) c, | |
| ]; | |
| assert(shared.length == 2, 'adjacent cells share exactly 2 vertices'); | |
| final toChild = cell - parentCell; | |
| final hingeDir = shared[1] - shared[0]; | |
| final crossZ = hingeDir.dx * toChild.dy - hingeDir.dy * toChild.dx; | |
| return FoldableFace( | |
| corners: corners, | |
| parentIndex: parentIndex, | |
| hingeStart: crossZ > 0 ? shared[0] : shared[1], | |
| hingeEnd: crossZ > 0 ? shared[1] : shared[0], | |
| ); | |
| } | |
| /// The 3D vertices of every face at fold amount [t] (0=flat, 1=complete). | |
| List<List<Vec3>> foldedFaces(double t) => [ | |
| for (final face in faces) _foldFace(face, t), | |
| ]; | |
| /// Maps net-coordinate points [netPoints] belonging to face [faceIndex] | |
| /// into 3D at fold amount [t]. They go through the same hinge-rotation | |
| /// chain as the corners, so freehand lines drawn on a face stay glued to it | |
| /// in every state from flat to fully folded. | |
| List<Vec3> foldedNetPoints(int faceIndex, List<Offset> netPoints, double t) => | |
| _foldPoints(faces[faceIndex], [for (final p in netPoints) _lift(p)], t); | |
| List<Vec3> _foldFace(FoldableFace face, double t) => | |
| _foldPoints(face, [for (final c in face.corners) _lift(c)], t); | |
| /// Folds the already-lifted points [lifted] by rotating through [face]'s | |
| /// ancestor hinges in order. Centralized here so corners and stroke points | |
| /// share the exact same transform. | |
| List<Vec3> _foldPoints(FoldableFace face, List<Vec3> lifted, double t) { | |
| var points = lifted; | |
| var current = face; | |
| while (current.parentIndex >= 0) { | |
| final axisStart = _lift(current.hingeStart!); | |
| final axisDir = (_lift(current.hingeEnd!) - axisStart).normalized(); | |
| final angle = t * current.foldAngle; | |
| points = [ | |
| for (final p in points) | |
| rotatePointAroundAxis(p, axisStart, axisDir, angle), | |
| ]; | |
| current = faces[current.parentIndex]; | |
| } | |
| return points; | |
| } | |
| /// Lifts a net coordinate onto the horizontal plane (y=0) in 3D. | |
| static Vec3 _lift(Offset netPoint) => Vec3(netPoint.dx, 0, netPoint.dy); | |
| } | |
| /// Common interface for drawing on the nets of curved solids | |
| /// (cylinder / cone). | |
| /// | |
| /// Drawing is only allowed at t=0 (the fully-opened flat net), so input just | |
| /// needs [hitPart] to decide analytically which part a flat net coordinate | |
| /// (u,v) belongs to. Rendering (following the fold), on the other hand, goes | |
| /// through the correspondence between flat-net triangles and folded triangles | |
| /// ([foldedNetTriangles]) and maps stroke points onto the curved surface with | |
| /// barycentric coordinates. Part IDs color the lateral surface | |
| /// ([netLateralPart], blue) and everything else (bases, orange). | |
| abstract class CurvedNet { | |
| /// Which part the net coordinate [uv] (t=0 plane (x,z)) belongs to. | |
| /// Null if outside. | |
| int? hitPart(Offset uv); | |
| /// The "flat-net triangle ↔ folded triangle" pairs at fold amount [t]. | |
| List<NetTriangle> foldedNetTriangles(double t, {int segments}); | |
| /// Representative triangle size in net coordinates. Used as the subdivision | |
| /// length for strokes (split below this so chords don't sink into the | |
| /// curved surface). | |
| double get netTriangleSize; | |
| } | |
| /// Part ID of the lateral surface. Used for coloring (lateral=blue) and for | |
| /// narrowing the per-part triangle search when drawing. | |
| const int netLateralPart = 0; | |
| /// Part ID of a base (the cylinder's top cap / the cone's base). | |
| const int netBasePartA = 1; | |
| /// Part ID of the cylinder's bottom cap. | |
| const int netBasePartB = 2; | |
| /// A triangle of the flat net (t=0) paired with the same triangle after | |
| /// folding (t). | |
| /// | |
| /// [net] holds net coordinates (u,v) = plane (x,z); [folded] holds the same | |
| /// vertices mapped onto the curved surface at fold amount t. They share vertex | |
| /// order, so expressing a flat-net point in barycentric coordinates of the | |
| /// [net] triangle and interpolating [folded] with those weights yields the | |
| /// corresponding point on the curved surface. | |
| class NetTriangle { | |
| const NetTriangle({ | |
| required this.part, | |
| required this.net, | |
| required this.folded, | |
| }); | |
| /// Part ID (e.g. [netLateralPart]). | |
| final int part; | |
| /// The 3 vertices in net coordinates (u,v). | |
| final List<Offset> net; | |
| /// The folded 3D vertices, same order as [net]. | |
| final List<Vec3> folded; | |
| } | |
| /// Unfold ↔ assemble for a cone. The lateral surface moves continuously | |
| /// between "sector ⇔ cone surface" isometrically (the paper never stretches). | |
| /// The mechanism differs from the cube's hinge tree, hence a separate class. | |
| /// | |
| /// The lateral surface is a sector with radius = slant [slant] L and arc | |
| /// length = base circumference 2πR. Unfolded (t=0) it lies flat on the y=0 | |
| /// plane as a sector; assembled (t=1) it becomes a cone with half-apex angle | |
| /// asin(R/L). Every intermediate state is also a "partial cone with half-apex | |
| /// angle β(t)", so it never self-intersects and stays isometric: a point at | |
| /// slant distance ρ and sector angle φ maps, with s = sinβ, to | |
| /// x = ρ s cos(φ/s), z = ρ s sin(φ/s), y = ρ√(1-s²) | |
| /// (s=1 is the flat sector, s=R/L the closed cone). The base circle is pinned | |
| /// at the lateral rim point at the arc's midpoint (attachment point A), and — | |
| /// identical in structure to the cylinder cap (CylinderNet._cap) — folds up | |
| /// around the "lateral-following crease line c(t)" through A by dihedral angle | |
| /// δ(t) to close the bottom. The base is horizontal only at t=0 (flat) and | |
| /// t=1 (the cone's bottom); in between it tilts as it folds up. | |
| class ConeNet implements CurvedNet { | |
| const ConeNet({required this.baseRadius, required this.slant}) | |
| : assert(slant > baseRadius, | |
| 'the slant must exceed the base radius (real height)'); | |
| /// Mesh subdivision count. The same value is used for rendering, the net | |
| /// correspondence, and part sizing to keep them consistent. | |
| static const int _segments = 48; | |
| /// Base radius R. | |
| final double baseRadius; | |
| /// Slant length L (hypotenuse from the apex to the base rim). | |
| final double slant; | |
| /// Cone height h = √(L²−R²). | |
| double get height => math.sqrt(slant * slant - baseRadius * baseRadius); | |
| /// Central angle of the lateral sector, θ = 2πR/L (radians). | |
| double get sectorAngle => 2 * math.pi * baseRadius / slant; | |
| /// Triangle meshes of the lateral surface and base at fold amount [t] | |
| /// (0 = flat sector, 1 = cone). | |
| ConeMesh foldedMesh(double t, {int segments = 48}) { | |
| final r = baseRadius; | |
| final l = slant; | |
| final sFinal = r / l; // sin of the cone's half-apex angle | |
| // s: 1 (flat) → R/L (cone). Clamped so rounding never pushes it past 1. | |
| final s = (1 + (sFinal - 1) * t).clamp(sFinal, 1.0); | |
| final axialUnit = math.sqrt((1 - s * s).clamp(0.0, 1.0)); // √(1-s²) | |
| final theta = sectorAngle; | |
| final apex = const Vec3(0, 0, 0); | |
| final rim = <Vec3>[ | |
| for (var i = 0; i <= segments; i++) | |
| _lateralPoint(l, theta * i / segments, s, axialUnit), | |
| ]; | |
| final lateral = <List<Vec3>>[ | |
| for (var i = 0; i < segments; i++) [apex, rim[i], rim[i + 1]], | |
| ]; | |
| // The base circle is built as "a rigid disc hinged at the lateral rim's | |
| // attachment point A(t)". Same structure as the cylinder's _cap: rather | |
| // than interpolating the center independently (which lets it drift), A is | |
| // pinned on the rim at all times, and the disc folds up around the | |
| // "lateral-following crease line c(t)" through A by dihedral angle δ(t) | |
| // to close the bottom. | |
| // | |
| // - Attachment point A(t): the lateral rim point in the direction of the | |
| // arc's midpoint, θ/2. Computed via _lateralPoint, so it always sits on | |
| // the arc (rim) and the attachment holds for every t. | |
| // - Crease line c(t): the normalized tangent of the arc (rim) at A, | |
| // ∂_lateralPoint/∂φ|_(θ/2) = (−sin(around), 0, cos(around)). A | |
| // horizontal (y=0) vector that turns within the x–z plane together with | |
| // changes in s (the lateral surface curling up). | |
| // - Axial direction axial(t): the normalized radial tangent at A, | |
| // ∂_lateralPoint/∂ρ|_(ρ=l) = (s·cos(around), axialUnit, s·sin(around)) | |
| // (already a unit vector). It matches the apex→A direction, so the base | |
| // center goes on A's outer side, +axial. The local tangent plane is | |
| // {c(t), axial(t)}. | |
| // - Dihedral angle δ(t)=t·δ1 is a linear interpolation with matching | |
| // endpoints. δ1=atan2(h, −R) (cosδ1=−R/L, sinδ1=h/L) is the angle that | |
| // raises the base circle, lying in the tangent plane, to the horizontal | |
| // cone bottom (center (0,h,0), radius R) at t=1. At t=0, δ=0 puts the | |
| // disc flat in the tangent plane (= horizontal plane), touching the arc. | |
| // In between the base tilts — the "folding up" motion. The old | |
| // "base stays horizontal at every t" behavior was dropped. | |
| final around = (theta / 2) / s; | |
| final anchor = _lateralPoint(l, theta / 2, s, axialUnit); // A(t) | |
| final hinge = Vec3(-math.sin(around), 0, math.cos(around)); // crease c(t) | |
| final axial = Vec3(s * math.cos(around), axialUnit, s * math.sin(around)); | |
| // The unfolded disc (lying in the tangent plane). Its center is R from A | |
| // along +axial. The circumference is the radius-R circle with A at φ=0 | |
| // (basis1=−axial, basis2=c(t)), so baseRim[0]=A and the attachment holds. | |
| final centerLocal = axial * r; | |
| Vec3 discLocal(double phi) => | |
| centerLocal + (axial * (-math.cos(phi)) + hinge * math.sin(phi)) * r; | |
| // Fold up around crease c(t) by dihedral δ(t) (rigid rotation around the | |
| // line through A). | |
| final delta = t * math.atan2(height, -r); | |
| Vec3 place(Vec3 local) => | |
| rotatePointAroundAxis(anchor + local, anchor, hinge, delta); | |
| final baseCenter = place(centerLocal); | |
| final baseRim = <Vec3>[ | |
| for (var i = 0; i <= segments; i++) | |
| place(discLocal(2 * math.pi * i / segments)), | |
| ]; | |
| final base = <List<Vec3>>[ | |
| for (var i = 0; i < segments; i++) | |
| [baseCenter, baseRim[i], baseRim[i + 1]], | |
| ]; | |
| return ConeMesh(lateral: lateral, base: base); | |
| } | |
| /// Part test on the flat net (t=0). Lateral sector = [netLateralPart], | |
| /// base disc = [netBasePartA], null if outside both. | |
| /// | |
| /// The flat layout matches foldedMesh(0): the lateral sector spans | |
| /// φ∈[0,θ], ρ∈[0,L] around the apex at the origin; the base circle has its | |
| /// center in the arc-midpoint direction θ/2 at distance L+R, radius R. | |
| @override | |
| int? hitPart(Offset uv) { | |
| const eps = 1e-9; | |
| final rho = uv.distance; | |
| if (rho <= slant + eps) { | |
| // atan2 is (-π,π]. Since θ<2π, shift negative angles by 2π into [0,2π) | |
| // before the range test. | |
| var ang = math.atan2(uv.dy, uv.dx); | |
| if (ang < -eps) ang += 2 * math.pi; | |
| if (ang >= -eps && ang <= sectorAngle + eps) return netLateralPart; | |
| } | |
| final half = sectorAngle / 2; | |
| final center = Offset( | |
| (slant + baseRadius) * math.cos(half), | |
| (slant + baseRadius) * math.sin(half), | |
| ); | |
| if ((uv - center).distance <= baseRadius + eps) return netBasePartA; | |
| return null; | |
| } | |
| /// Arc length of one subdivision of the sector's outer arc. The subdivision | |
| /// target that keeps chords from sinking into the curl direction. | |
| @override | |
| double get netTriangleSize => sectorAngle * slant / _segments; | |
| @override | |
| List<NetTriangle> foldedNetTriangles(double t, {int segments = _segments}) { | |
| // Build the folded (t) and flat (t=0) meshes with the same subdivision | |
| // and zip the same-order vertices. Net coordinates are the t=0 plane | |
| // (x,z); folded is the 3D at t. | |
| final folded = foldedMesh(t, segments: segments); | |
| final flat = foldedMesh(0, segments: segments); | |
| final out = <NetTriangle>[]; | |
| void addAll(List<List<Vec3>> ft, List<List<Vec3>> zt, int part) { | |
| for (var i = 0; i < ft.length; i++) { | |
| out.add( | |
| NetTriangle( | |
| part: part, | |
| net: [for (final v in zt[i]) Offset(v.x, v.z)], | |
| folded: ft[i], | |
| ), | |
| ); | |
| } | |
| } | |
| addAll(folded.lateral, flat.lateral, netLateralPart); | |
| addAll(folded.base, flat.base, netBasePartA); | |
| return out; | |
| } | |
| /// The lateral-surface point at slant distance ρ and sector angle φ | |
| /// (with sin of half-apex angle = s, axial coefficient = axialUnit). | |
| static Vec3 _lateralPoint( | |
| double rho, | |
| double phi, | |
| double s, | |
| double axialUnit, | |
| ) { | |
| final around = phi / s; // arc length preserved: ρ dφ = ρ s d(around) | |
| return Vec3( | |
| rho * s * math.cos(around), | |
| rho * axialUnit, | |
| rho * s * math.sin(around), | |
| ); | |
| } | |
| } | |
| /// Render mesh for the cone. Lateral surface and base kept separate for | |
| /// coloring. | |
| class ConeMesh { | |
| const ConeMesh({required this.lateral, required this.base}); | |
| /// Triangles of the lateral surface (sector → cone surface). | |
| final List<List<Vec3>> lateral; | |
| /// Triangles of the base circle. | |
| final List<List<Vec3>> base; | |
| /// All triangles, lateral then base. Used for depth sorting and projection. | |
| List<List<Vec3>> get all => [...lateral, ...base]; | |
| } | |
| /// Unfold ↔ assemble for a cylinder. The lateral surface is a rectangle of | |
| /// width = base circumference 2πR and height H, rolled continuously from flat | |
| /// (t=0) into the radius-R cylinder side (t=1) "with arc length preserved" | |
| /// (the paper never stretches). Like ConeNet this is an isometric deformation, | |
| /// so intermediate states never self-intersect. | |
| /// | |
| /// Curvature k goes 0 (flat) → 1/R (cylinder); a point at width w winds onto | |
| /// a circular arc of radius ρ=1/k: x = sin(kw)/k, y = (1−cos(kw))/k. Arc | |
| /// length = ∫|d/dw| dw = w is preserved (as k→0 it degenerates to the plane | |
| /// x→w, y→0). The height direction stays rigid along z, so the side becomes | |
| /// a cylinder with "axis along z, height range z∈[0,H]". To roll the net | |
| /// while it lies flat on the y=0 plane, this orientation (axis = z) is the | |
| /// only choice that stays isometric. | |
| class CylinderNet implements CurvedNet { | |
| const CylinderNet({required this.radius, required this.height}) | |
| : assert(radius > 0 && height > 0, 'radius and height must be positive'); | |
| /// Mesh subdivision count. The same value is used for rendering, the net | |
| /// correspondence, and part sizing to keep them consistent. | |
| static const int _segments = 48; | |
| /// Base radius R. | |
| final double radius; | |
| /// Cylinder height H (the other side of the lateral rectangle). | |
| final double height; | |
| /// Width of the lateral rectangle = base circumference 2πR. | |
| double get circumference => 2 * math.pi * radius; | |
| /// Triangle meshes of the lateral surface and both caps at fold amount [t] | |
| /// (0 = flat rectangle, 1 = cylinder). | |
| CylinderMesh foldedMesh(double t, {int segments = 48}) { | |
| final r = radius; | |
| final hgt = height; | |
| final w = circumference; | |
| final k = t / r; // curvature 0 → 1/R | |
| // The cap's attachment point must land on the rim via the same function, | |
| // so the rolling math lives in _rolled alone. | |
| Vec3 side(double wi, double z) => _rolled(wi, z, k); | |
| // Lateral surface: a triangle strip joining the two rings at heights 0 | |
| // and H. | |
| final lateral = <List<Vec3>>[]; | |
| for (var i = 0; i < segments; i++) { | |
| final w0 = w * i / segments; | |
| final w1 = w * (i + 1) / segments; | |
| final b0 = side(w0, 0); | |
| final b1 = side(w1, 0); | |
| final t0 = side(w0, hgt); | |
| final t1 = side(w1, hgt); | |
| lateral.add([b0, b1, t1]); | |
| lateral.add([b0, t1, t0]); | |
| } | |
| return CylinderMesh( | |
| lateral: lateral, | |
| top: _cap(t, segments, isTop: true), | |
| bottom: _cap(t, segments, isTop: false), | |
| ); | |
| } | |
| /// The top (isTop) / bottom cap. Built as "a rigid disc hinged at the | |
| /// attachment point A(t) on the lateral rim". Instead of interpolating the | |
| /// center independently (letting it drift), A is pinned on the rim and the | |
| /// disc, keeping its radius R, folds up into a lid around the crease line | |
| /// c(t) through A that follows the lateral surface's curl. | |
| /// | |
| /// A previous version hinged on the fixed world x axis, so the lid didn't | |
| /// turn with the curling side and the "fold up into place" feel was lost. | |
| /// Now the hinge axis follows the side's local frame: | |
| /// - Attachment point A(t): where the midpoint (w/2) of the top/bottom edge | |
| /// lands after rolling through the same [_rolled] as the side. | |
| /// - Crease line c(t): the width-direction tangent of the lateral rim, | |
| /// ∂_rolled/∂w|_(w/2) = (cos(k·w/2), sin(k·w/2), 0). It sweeps 0→π in the | |
| /// x–y plane from +x at t=0 to −x at t=1, turning in step with the curl. | |
| /// - The lid is built in the tangent frame {c(t), axial z} and folds around | |
| /// the line through A along c(t) by dihedral angle δ(t). δ is fixed by | |
| /// matching endpoints: top −π/2·t / bottom +π/2·t, so at t=1 the lids | |
| /// close the ends (z=H / z=0) horizontally. | |
| /// | |
| /// c(0)=+x, δ(0)=0 reproduces the old flat layout; c(1)=−x, δ(1)=∓π/2 | |
| /// reproduces the old assembled layout, so the endpoint meshes are | |
| /// unchanged. A lies on the circle containing c(t) (a point on the rotation | |
| /// axis), so the rotation fixes it and the attachment holds for every t. | |
| List<List<Vec3>> _cap(double t, int segments, {required bool isTop}) { | |
| final r = radius; | |
| final w = circumference; | |
| final k = t / r; // same curvature as the side keeps A on the rim | |
| final endZ = isTop ? height : 0.0; | |
| // Attachment point A(t) = the edge midpoint rolled by the same function | |
| // as the side. Always on the rim. | |
| final anchor = _rolled(w / 2, endZ, k); | |
| // Crease (hinge) direction c(t): the width tangent of the lateral rim. | |
| // Turns in the x–y plane together with the curl. | |
| final curl = k * w / 2; // = πt; 0 (+x) at t=0, π (−x) at t=1. | |
| final hinge = Vec3(math.cos(curl), math.sin(curl), 0); | |
| // The side's axial direction (height z; invariant under rolling). The lid | |
| // is built in this {hinge, axial} tangent plane. | |
| const axial = Vec3(0, 0, 1); | |
| // The unfolded lid (lying in the tangent plane). Its center is ±R from A | |
| // along the axial direction (outside the edge); the circumference is the | |
| // radius-R circle through A. Top lid centers on +axial, bottom on −axial. | |
| final centerLocal = axial * (isTop ? r : -r); | |
| Vec3 discLocal(double phi) => | |
| centerLocal + (hinge * math.cos(phi) + axial * math.sin(phi)) * r; | |
| // Fold up around crease c(t) by dihedral δ(t) (rigid rotation around the | |
| // line through A). | |
| final delta = (isTop ? -1.0 : 1.0) * (math.pi / 2) * t; | |
| Vec3 place(Vec3 local) => | |
| rotatePointAroundAxis(anchor + local, anchor, hinge, delta); | |
| final center = place(centerLocal); | |
| final rim = <Vec3>[ | |
| for (var i = 0; i <= segments; i++) | |
| place(discLocal(2 * math.pi * i / segments)), | |
| ]; | |
| return [ | |
| for (var i = 0; i < segments; i++) [center, rim[i], rim[i + 1]], | |
| ]; | |
| } | |
| /// Part test on the flat net (t=0). Lateral rectangle = [netLateralPart], | |
| /// top-cap circle = [netBasePartA], bottom-cap circle = [netBasePartB], | |
| /// null if outside. | |
| /// | |
| /// The flat layout matches foldedMesh(0): the side spans x∈[0,2πR], | |
| /// z∈[0,H]; the top cap is the circle centered (πR, H+R) with radius R | |
| /// (tangent to the z=H edge); the bottom cap is centered (πR, −R), radius R. | |
| @override | |
| int? hitPart(Offset uv) { | |
| const eps = 1e-9; | |
| final w = circumference; | |
| if (uv.dx >= -eps && | |
| uv.dx <= w + eps && | |
| uv.dy >= -eps && | |
| uv.dy <= height + eps) { | |
| return netLateralPart; | |
| } | |
| final topCenter = Offset(w / 2, height + radius); | |
| if ((uv - topCenter).distance <= radius + eps) return netBasePartA; | |
| final bottomCenter = Offset(w / 2, -radius); | |
| if ((uv - bottomCenter).distance <= radius + eps) return netBasePartB; | |
| return null; | |
| } | |
| /// One width subdivision of the lateral rectangle. The subdivision target | |
| /// that keeps chords from sinking into the curl direction. | |
| @override | |
| double get netTriangleSize => circumference / _segments; | |
| @override | |
| List<NetTriangle> foldedNetTriangles(double t, {int segments = _segments}) { | |
| // Build the folded (t) and flat (t=0) meshes with the same subdivision | |
| // and zip the same-order vertices. | |
| final folded = foldedMesh(t, segments: segments); | |
| final flat = foldedMesh(0, segments: segments); | |
| final out = <NetTriangle>[]; | |
| void addAll(List<List<Vec3>> ft, List<List<Vec3>> zt, int part) { | |
| for (var i = 0; i < ft.length; i++) { | |
| out.add( | |
| NetTriangle( | |
| part: part, | |
| net: [for (final v in zt[i]) Offset(v.x, v.z)], | |
| folded: ft[i], | |
| ), | |
| ); | |
| } | |
| } | |
| addAll(folded.lateral, flat.lateral, netLateralPart); | |
| addAll(folded.top, flat.top, netBasePartA); | |
| addAll(folded.bottom, flat.bottom, netBasePartB); | |
| return out; | |
| } | |
| /// The lateral point at width wi and height z. Curvature k=t/R rolls the | |
| /// flat plane (k≈0) into the cylinder side. Centralized here so the side | |
| /// and the cap attachment land on the exact same point. | |
| static Vec3 _rolled(double wi, double z, double k) { | |
| // k≈0 is the flat limit (avoid division by zero; return x=wi, y=0). | |
| if (k < 1e-12) return Vec3(wi, 0, z); | |
| final beta = wi * k; | |
| return Vec3(math.sin(beta) / k, (1 - math.cos(beta)) / k, z); | |
| } | |
| } | |
| /// Render mesh for the cylinder. Lateral surface and both caps kept separate | |
| /// for coloring. | |
| class CylinderMesh { | |
| const CylinderMesh({ | |
| required this.lateral, | |
| required this.top, | |
| required this.bottom, | |
| }); | |
| /// Triangles of the lateral surface (rectangle → cylinder side). | |
| final List<List<Vec3>> lateral; | |
| /// Triangles of the top cap. | |
| final List<List<Vec3>> top; | |
| /// Triangles of the bottom cap. | |
| final List<List<Vec3>> bottom; | |
| /// All triangles: lateral, then top, then bottom. Used for depth sorting | |
| /// and projection. | |
| List<List<Vec3>> get all => [...lateral, ...top, ...bottom]; | |
| } | |
| /// Center of the axis-aligned bounding box around all face vertices. Used to | |
| /// center the display: even as folding shifts the shape's balance point, it | |
| /// stays near the middle of the screen. | |
| Vec3 boundingBoxCenter(List<List<Vec3>> faces) { | |
| var minX = double.infinity, minY = double.infinity, minZ = double.infinity; | |
| var maxX = -double.infinity, maxY = -double.infinity, maxZ = -double.infinity; | |
| for (final face in faces) { | |
| for (final v in face) { | |
| minX = math.min(minX, v.x); | |
| minY = math.min(minY, v.y); | |
| minZ = math.min(minZ, v.z); | |
| maxX = math.max(maxX, v.x); | |
| maxY = math.max(maxY, v.y); | |
| maxZ = math.max(maxZ, v.z); | |
| } | |
| } | |
| return Vec3((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2); | |
| } | |
| /// Perspective-projects a 3D point to 2D (origin = screen center). | |
| /// The camera sits on the near side of the z axis; things farther away | |
| /// (larger z) appear smaller. | |
| Offset projectPoint( | |
| Vec3 v, { | |
| required double cameraDistance, | |
| required double viewScale, | |
| }) { | |
| final perspective = cameraDistance / (cameraDistance + v.z); | |
| return Offset(v.x * perspective * viewScale, v.y * perspective * viewScale); | |
| } | |
| /// Face indices ordered back-to-front for the painter's algorithm. | |
| /// While the folding keeps faces from intersecting each other, sorting by | |
| /// mean z preserves correct occlusion. | |
| List<int> faceOrderByDepth(List<List<Vec3>> faces) { | |
| double depth(List<Vec3> face) => | |
| face.map((v) => v.z).reduce((a, b) => a + b) / face.length; | |
| final indices = List<int>.generate(faces.length, (i) => i); | |
| indices.sort((a, b) => depth(faces[b]).compareTo(depth(faces[a]))); | |
| return indices; | |
| } | |
| /// The face normal (normalized). Used for shading. | |
| /// Both sides of a face are visible mid-fold, so the sign carries no meaning. | |
| Vec3 polygonNormal(List<Vec3> corners) { | |
| final a = corners[0]; | |
| final b = corners[1]; | |
| final c = corners[2]; | |
| return (b - a).cross(c - a).normalized(); | |
| } | |
| // ============================================================================= | |
| // 3. models: drawing hit-tests and mapping (net_drawing.dart) | |
| // | |
| // Approach: a stroke is stored as a list of points in the net coordinates | |
| // (u,v) of the face it was drawn on. That is the same coordinate system as | |
| // FoldableFace.corners, so passing it through FoldableNet.foldedNetPoints puts | |
| // it on the exact pipeline used by foldedFaces(t) — hinge rotation → view | |
| // rotation → perspective projection — and the line follows the face through | |
| // any fold state and viewpoint as if drawn on it. | |
| // | |
| // Only UI-independent math lives here (ray construction, ray-plane | |
| // intersection, (u,v) inversion, point-in-polygon, stroke splitting). | |
| // ============================================================================= | |
| /// One freehand line drawn on a face. Points are in the face's net | |
| /// coordinates (u,v). | |
| class NetStroke { | |
| NetStroke(this.faceIndex) : points = <Offset>[]; | |
| /// Index of the owning face (position in [FoldableNet.faces]). | |
| final int faceIndex; | |
| /// The points in net coordinates (u,v). | |
| final List<Offset> points; | |
| } | |
| /// A ray from the camera through a point on screen. Defined in the | |
| /// view-rotated space. | |
| class Ray { | |
| const Ray(this.origin, this.direction); | |
| final Vec3 origin; | |
| /// Travel direction (not normalized; the intersection parameter is only | |
| /// used relatively). | |
| final Vec3 direction; | |
| } | |
| /// Result of a ray-face intersection: which face's (u,v) was hit, plus the | |
| /// parameter for nearest-first ordering. | |
| class FaceHit { | |
| const FaceHit({ | |
| required this.faceIndex, | |
| required this.uv, | |
| required this.rayT, | |
| }); | |
| final int faceIndex; | |
| final Offset uv; | |
| /// The intersection is origin + rayT·direction. Smaller means closer to | |
| /// the camera. | |
| final double rayT; | |
| } | |
| /// A face prepared for hit-testing: its net-coordinate vertices paired with | |
| /// the same vertices mapped into view-rotated space. (u,v)→viewCorners is a | |
| /// rigid transform, so this pairing alone recovers the exact (u,v) of an | |
| /// intersection. | |
| class HittableFace { | |
| const HittableFace({ | |
| required this.faceIndex, | |
| required this.netCorners, | |
| required this.viewCorners, | |
| }); | |
| final int faceIndex; | |
| /// Vertices in net coordinates (adjacent order). | |
| final List<Offset> netCorners; | |
| /// The view-rotated 3D vertices, same order as [netCorners]. | |
| final List<Vec3> viewCorners; | |
| } | |
| /// Builds the ray through projected screen point [screen] (origin = screen | |
| /// center) using the same conventions as [projectPoint] (camera at | |
| /// z=-cameraDistance, projection plane z=0, scaled by viewScale). | |
| /// | |
| /// projectPoint maps a z=0 point to px=x·viewScale, so the point on the | |
| /// projection plane is (px/viewScale, py/viewScale, 0). The camera sits where | |
| /// the projection denominator (cameraDistance+z) equals the depth from the | |
| /// camera, i.e. z=-cameraDistance. | |
| Ray rayThroughScreenPoint( | |
| Offset screen, { | |
| required double cameraDistance, | |
| required double viewScale, | |
| }) { | |
| final origin = Vec3(0, 0, -cameraDistance); | |
| final onPlane = Vec3(screen.dx / viewScale, screen.dy / viewScale, 0); | |
| return Ray(origin, onPlane - origin); | |
| } | |
| /// The (u,v) where ray [ray] hits face [face]. Null if outside the face, | |
| /// behind the camera, or parallel. | |
| FaceHit? hitFace(HittableFace face, Ray ray) { | |
| final normal = polygonNormal(face.viewCorners); | |
| final denom = normal.dot(ray.direction); | |
| if (denom.abs() < 1e-9) return null; // ray parallel to the face | |
| final rayT = normal.dot(face.viewCorners[0] - ray.origin) / denom; | |
| if (rayT <= 0) return null; // behind the camera | |
| final point = ray.origin + ray.direction * rayT; | |
| final uv = viewPointToUv(face, point); | |
| if (uv == null) return null; | |
| if (!pointInPolygon(uv, face.netCorners)) return null; | |
| return FaceHit(faceIndex: face.faceIndex, uv: uv, rayT: rayT); | |
| } | |
| /// Returns the frontmost face whose polygon the ray hits. | |
| /// | |
| /// Taking the nearest hit (smallest rayT) matches the face the user actually | |
| /// sees — the one the painter's algorithm draws last (in front). Both sides | |
| /// of the paper are visible, and cube faces don't share a consistent winding, | |
| /// so picking front-facing normals would be wrong; visibility is decided by | |
| /// depth instead. | |
| FaceHit? hitTestFaces(Iterable<HittableFace> faces, Ray ray) { | |
| FaceHit? best; | |
| for (final face in faces) { | |
| final hit = hitFace(face, ray); | |
| if (hit == null) continue; | |
| if (best == null || hit.rayT < best.rayT) best = hit; | |
| } | |
| return best; | |
| } | |
| /// Inverts a view-rotated point [point] back to the face's net coordinates | |
| /// (u,v). | |
| /// | |
| /// (u,v)→viewCorners is a rigid transform (composed rotations + translation), | |
| /// so the images eu/ev of the +u/+v directions (orthonormal) can be recovered | |
| /// from 3 vertices, and projecting onto that basis inverts exactly. | |
| Offset? viewPointToUv(HittableFace face, Vec3 point) { | |
| final n0 = face.netCorners[0]; | |
| final n1 = face.netCorners[1]; | |
| final n2 = face.netCorners[2]; | |
| final v0 = face.viewCorners[0]; | |
| final v1 = face.viewCorners[1]; | |
| final v2 = face.viewCorners[2]; | |
| final du1 = n1.dx - n0.dx, dv1 = n1.dy - n0.dy; | |
| final du2 = n2.dx - n0.dx, dv2 = n2.dy - n0.dy; | |
| final det = du1 * dv2 - du2 * dv1; | |
| if (det.abs() < 1e-12) return null; // the 3 vertices are collinear | |
| final w1 = v1 - v0; // = du1·eu + dv1·ev | |
| final w2 = v2 - v0; // = du2·eu + dv2·ev | |
| final inv = 1 / det; | |
| final eu = (w1 * dv2 - w2 * dv1) * inv; | |
| final ev = (w2 * du1 - w1 * du2) * inv; | |
| final rel = point - v0; | |
| return Offset(n0.dx + rel.dot(eu), n0.dy + rel.dot(ev)); | |
| } | |
| /// Whether point [p] lies inside [polygon] (ray casting). Strictness near the | |
| /// boundary doesn't matter here. | |
| bool pointInPolygon(Offset p, List<Offset> polygon) { | |
| var inside = false; | |
| for (var i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { | |
| final a = polygon[i]; | |
| final b = polygon[j]; | |
| final straddles = (a.dy > p.dy) != (b.dy > p.dy); | |
| if (straddles && | |
| p.dx < (b.dx - a.dx) * (p.dy - a.dy) / (b.dy - a.dy) + a.dx) { | |
| inside = !inside; | |
| } | |
| } | |
| return inside; | |
| } | |
| /// Ray intersection against the flat net at t=0. Curved solids (cylinder / | |
| /// cone) restrict drawing to t=0, where the whole net lies on one plane | |
| /// (originally y=0). Three reference points [refNet]↔[refView] pin the affine | |
| /// correspondence between (u,v) and view space, recovering the plane hit's | |
| /// (u,v) exactly. The inside/outside part test is left to the caller | |
| /// ([CurvedNet.hitPart]). | |
| /// | |
| /// [refNet]/[refView] are corresponding (non-collinear) triples in net | |
| /// coordinates and view-rotated 3D. | |
| Offset? planeNetHit(List<Offset> refNet, List<Vec3> refView, Ray ray) { | |
| final normal = polygonNormal(refView); | |
| final denom = normal.dot(ray.direction); | |
| if (denom.abs() < 1e-9) return null; // ray parallel to the plane | |
| final rayT = normal.dot(refView[0] - ray.origin) / denom; | |
| if (rayT <= 0) return null; // behind the camera | |
| final point = ray.origin + ray.direction * rayT; | |
| return viewPointToUv( | |
| HittableFace(faceIndex: 0, netCorners: refNet, viewCorners: refView), | |
| point, | |
| ); | |
| } | |
| /// Linearly subdivides a net-coordinate polyline [points] so every segment is | |
| /// at most [maxLen]. Long segments would become chords sinking into the | |
| /// curved surface, so densify before mapping. | |
| List<Offset> subdivideNetPolyline(List<Offset> points, double maxLen) { | |
| if (points.length < 2 || maxLen <= 0) return List<Offset>.of(points); | |
| final out = <Offset>[points.first]; | |
| for (var i = 1; i < points.length; i++) { | |
| final a = points[i - 1]; | |
| final b = points[i]; | |
| final dist = (b - a).distance; | |
| final steps = dist <= maxLen ? 1 : (dist / maxLen).ceil(); | |
| for (var s = 1; s <= steps; s++) { | |
| out.add(Offset.lerp(a, b, s / steps)!); | |
| } | |
| } | |
| return out; | |
| } | |
| /// Result of mapping onto a [NetTriangle]: the owning [triangleIndex] plus | |
| /// the folded 3D point. | |
| class NetMapResult { | |
| const NetMapResult(this.triangleIndex, this.folded); | |
| /// Position within the given triangle list (used to draw "right after this | |
| /// triangle" for hidden-surface handling). | |
| final int triangleIndex; | |
| /// The folded 3D point, mapped via barycentric coordinates. | |
| final Vec3 folded; | |
| } | |
| /// Assigns net point [p] to the most-interior triangle of part [part] and | |
| /// returns the folded 3D point via barycentric interpolation. The curved | |
| /// outer arc has tiny gaps from the chord approximation, so points strictly | |
| /// inside no triangle are clamped to the nearest triangle's edge to keep | |
| /// lines unbroken. | |
| NetMapResult? mapNetPointOnTriangles( | |
| Offset p, | |
| List<NetTriangle> triangles, | |
| int part, | |
| ) { | |
| var bestIndex = -1; | |
| var bestScore = -double.infinity; | |
| var ba = 0.0, bb = 0.0, bc = 0.0; | |
| for (var i = 0; i < triangles.length; i++) { | |
| final tri = triangles[i]; | |
| if (tri.part != part) continue; | |
| final w = _barycentric(p, tri.net); | |
| if (w == null) continue; // degenerate triangle | |
| // The larger the minimum of the 3 weights, the more interior the point. | |
| // Nonnegative means fully contained. | |
| final score = math.min(w[0], math.min(w[1], w[2])); | |
| if (score > bestScore) { | |
| bestScore = score; | |
| bestIndex = i; | |
| ba = w[0]; | |
| bb = w[1]; | |
| bc = w[2]; | |
| } | |
| if (score >= 0) break; // found a containing triangle: done | |
| } | |
| if (bestIndex < 0) return null; | |
| // Clamp onto the simplex (out-of-bounds points land on the nearest | |
| // triangle's edge). | |
| var wa = math.max(0.0, ba); | |
| var wb = math.max(0.0, bb); | |
| var wc = math.max(0.0, bc); | |
| final sum = wa + wb + wc; | |
| if (sum <= 0) return null; | |
| wa /= sum; | |
| wb /= sum; | |
| wc /= sum; | |
| final f = triangles[bestIndex].folded; | |
| final folded = f[0] * wa + f[1] * wb + f[2] * wc; | |
| return NetMapResult(bestIndex, folded); | |
| } | |
| /// Barycentric coordinates [wa,wb,wc] of point [p] with respect to triangle | |
| /// [tri] (3 [Offset] vertices). Null if degenerate. | |
| List<double>? _barycentric(Offset p, List<Offset> tri) { | |
| final ax = tri[0].dx, ay = tri[0].dy; | |
| final bx = tri[1].dx, by = tri[1].dy; | |
| final cx = tri[2].dx, cy = tri[2].dy; | |
| final d = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy); | |
| if (d.abs() < 1e-12) return null; | |
| final wa = ((by - cy) * (p.dx - cx) + (cx - bx) * (p.dy - cy)) / d; | |
| final wb = ((cy - ay) * (p.dx - cx) + (ax - cx) * (p.dy - cy)) / d; | |
| return [wa, wb, 1 - wa - wb]; | |
| } | |
| /// Manages the set of freehand lines per face. Feed it hit results one point | |
| /// at a time and it splits continuous strokes at breaks — when the face | |
| /// changes or a point misses every face. The page owns this state. | |
| class NetDrawing { | |
| final List<NetStroke> strokes = <NetStroke>[]; | |
| NetStroke? _active; | |
| bool get isEmpty => strokes.isEmpty; | |
| /// Starts a new stroke; the previous one is finalized and detached. | |
| void beginStroke() => _active = null; | |
| /// Adds one hit result. Null (missed every face) is ignored and becomes a | |
| /// break. A face change starts a new stroke. | |
| void addHit(FaceHit? hit) { | |
| if (hit == null) { | |
| _active = null; | |
| return; | |
| } | |
| final active = _active; | |
| if (active == null || active.faceIndex != hit.faceIndex) { | |
| final stroke = NetStroke(hit.faceIndex)..points.add(hit.uv); | |
| strokes.add(stroke); | |
| _active = stroke; | |
| } else { | |
| active.points.add(hit.uv); | |
| } | |
| } | |
| void clear() { | |
| strokes.clear(); | |
| _active = null; | |
| } | |
| } | |
| // ============================================================================= | |
| // 4. widgets: the "puffy" tactile UI kit | |
| // (control_math.dart / ui_metrics.dart / puffy_pressable.dart / | |
| // puffy_button.dart / puffy_slider.dart / puffy_toggle.dart / | |
| // puffy_chip_grid.dart / puffy_stepper.dart) | |
| // ============================================================================= | |
| // --- control_math.dart: value ↔ positional fraction conversion ----------------- | |
| /// Where [value] sits within [min]..[max] as a 0..1 fraction. | |
| /// Out-of-range values clamp to the ends (a drag leaving the track must not | |
| /// break anything). | |
| double fractionOfValue( | |
| double value, { | |
| required double min, | |
| required double max, | |
| }) { | |
| assert(max > min, 'the range must have positive width'); | |
| return ((value - min) / (max - min)).clamp(0.0, 1.0); | |
| } | |
| /// Converts a 0..1 fraction back to a value in [min]..[max]. | |
| /// With [divisions] the value snaps to evenly spaced points (same meaning as | |
| /// Material's Slider.divisions). | |
| double valueOfFraction( | |
| double fraction, { | |
| required double min, | |
| required double max, | |
| int? divisions, | |
| }) { | |
| assert(max > min, 'the range must have positive width'); | |
| assert(divisions == null || divisions > 0, 'divisions must be positive'); | |
| final clamped = fraction.clamp(0.0, 1.0); | |
| final snapped = divisions == null | |
| ? clamped | |
| : (clamped * divisions).round() / divisions; | |
| return min + (max - min) * snapped; | |
| } | |
| // --- ui_metrics.dart: compact-size detection ------------------------------------ | |
| /// Shared check that lets the puffy widgets shrink automatically on phones. | |
| /// Each widget consults this extension so callers never pass a size flag. | |
| /// The test uses the shortest side, so phones are compact in both | |
| /// orientations and tablets always render at full size. | |
| extension CompactUi on BuildContext { | |
| /// Treats a shortest side < 600dp as compact (phone-sized). | |
| bool get isCompactUi => MediaQuery.sizeOf(this).shortestSide < 600; | |
| } | |
| // --- puffy_pressable.dart -------------------------------------------------------- | |
| /// The shared "pressable" base of the puffy widgets. To keep one metaphor | |
| /// across all parts — what floats (has a shadow) can be pressed — it sinks | |
| /// while pressed (shadow removed) and, when disabled, doesn't float and | |
| /// fades instead. Color and shape are the caller's choice. | |
| class PuffyPressable extends StatefulWidget { | |
| const PuffyPressable({ | |
| super.key, | |
| required this.onPressed, | |
| required this.color, | |
| required this.child, | |
| this.borderRadius = const BorderRadius.all(Radius.circular(20)), | |
| this.padding, | |
| }); | |
| /// Null renders the disabled look (not floating, taps ignored). | |
| final VoidCallback? onPressed; | |
| /// Base color. Highlight, shade, and drop shadow all derive from it. | |
| final Color color; | |
| final Widget child; | |
| final BorderRadius borderRadius; | |
| /// When omitted, compact screens automatically get tightened padding. | |
| /// An explicit value is respected (for arrows/play buttons that own their | |
| /// dimensions). | |
| final EdgeInsetsGeometry? padding; | |
| @override | |
| State<PuffyPressable> createState() => _PuffyPressableState(); | |
| } | |
| class _PuffyPressableState extends State<PuffyPressable> { | |
| /// Transient visual state solely for the pressed-sinking effect. | |
| /// No logical state (selection etc.) lives here, preserving the callers' | |
| /// setState policy. | |
| bool _pressed = false; | |
| /// Apparent floating height = distance sunk while pressed. | |
| static const _liftHeight = 4.0; | |
| void _setPressed(bool pressed) { | |
| if (widget.onPressed == null) return; | |
| setState(() => _pressed = pressed); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| final enabled = widget.onPressed != null; | |
| final base = enabled | |
| ? widget.color | |
| : Color.lerp(widget.color, Colors.white, 0.55)!; | |
| // Lighten the top and slightly darken the bottom to read as a rounded, | |
| // raised surface. | |
| final top = Color.lerp(base, Colors.white, 0.25)!; | |
| final bottom = Color.lerp(base, Colors.black, 0.08)!; | |
| final shadow = Color.lerp(base, Colors.black, 0.55)!; | |
| final floating = enabled && !_pressed; | |
| // Without explicit padding, use the compact default on phones (callers | |
| // stay unchanged). | |
| final padding = | |
| widget.padding ?? | |
| (context.isCompactUi | |
| ? const EdgeInsets.symmetric(horizontal: 14, vertical: 10) | |
| : const EdgeInsets.symmetric(horizontal: 20, vertical: 14)); | |
| return GestureDetector( | |
| onTapDown: (_) => _setPressed(true), | |
| onTapUp: (_) => _setPressed(false), | |
| onTapCancel: () => _setPressed(false), | |
| onTap: widget.onPressed, | |
| child: AnimatedContainer( | |
| duration: const Duration(milliseconds: 90), | |
| curve: Curves.easeOut, | |
| padding: padding, | |
| // transform doesn't affect layout, so sinking never shifts neighbors. | |
| transform: Matrix4.translationValues(0, floating ? 0 : _liftHeight, 0), | |
| decoration: BoxDecoration( | |
| borderRadius: widget.borderRadius, | |
| gradient: LinearGradient( | |
| begin: Alignment.topCenter, | |
| end: Alignment.bottomCenter, | |
| colors: [top, bottom], | |
| ), | |
| boxShadow: floating | |
| ? [ | |
| BoxShadow( | |
| color: shadow.withValues(alpha: 0.4), | |
| offset: const Offset(0, _liftHeight), | |
| blurRadius: 2, | |
| ), | |
| ] | |
| : const [], | |
| ), | |
| child: widget.child, | |
| ), | |
| ); | |
| } | |
| } | |
| // --- puffy_button.dart ----------------------------------------------------------- | |
| /// The general-purpose puffy button; the tactile replacement for Material's | |
| /// `ElevatedButton`. Holds no state; presses come back via [onPressed]. | |
| class PuffyButton extends StatelessWidget { | |
| const PuffyButton({ | |
| super.key, | |
| required this.onPressed, | |
| required this.child, | |
| this.color, | |
| }); | |
| /// Null renders the disabled look. | |
| final VoidCallback? onPressed; | |
| /// Label or icon. The button unifies text/icon colors to a light tone. | |
| final Widget child; | |
| /// Base color. Defaults to the theme's primary. | |
| final Color? color; | |
| @override | |
| Widget build(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| return PuffyPressable( | |
| onPressed: onPressed, | |
| color: color ?? scheme.primary, | |
| child: DefaultTextStyle.merge( | |
| style: TextStyle( | |
| color: scheme.onPrimary, | |
| // Slightly smaller text on phones, matching the tightened padding. | |
| fontSize: context.isCompactUi ? 15 : 18, | |
| fontWeight: FontWeight.bold, | |
| ), | |
| child: IconTheme.merge( | |
| data: IconThemeData(color: scheme.onPrimary), | |
| child: Center(widthFactor: 1, heightFactor: 1, child: child), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| // --- puffy_slider.dart ----------------------------------------------------------- | |
| /// The "thick" tactile horizontal slider; the replacement for Material's | |
| /// `Slider`. Instead of a thin line with a small thumb, it fills its full | |
| /// height like an HP bar, and the whole bar height is the touch target. | |
| /// Holds no state; changes come back via [onChanged]. | |
| class PuffySlider extends StatelessWidget { | |
| const PuffySlider({ | |
| super.key, | |
| required this.value, | |
| required this.onChanged, | |
| this.min = 0.0, | |
| this.max = 1.0, | |
| this.divisions, | |
| }); | |
| final double value; | |
| final ValueChanged<double> onChanged; | |
| final double min; | |
| final double max; | |
| /// When set, snaps to evenly spaced points (same as `Slider.divisions`). | |
| final int? divisions; | |
| @override | |
| Widget build(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| // One size down on phones so the settings area isn't crowded. The whole | |
| // bar is the touch target, so a smaller thumb doesn't hurt usability. | |
| final compact = context.isCompactUi; | |
| final height = compact ? 44.0 : 56.0; | |
| final trackHeight = compact ? 28.0 : 36.0; | |
| final thumbSize = compact ? 36.0 : 48.0; | |
| return SizedBox( | |
| height: height, | |
| child: LayoutBuilder( | |
| builder: (context, constraints) { | |
| final width = constraints.maxWidth; | |
| // Keep the thumb from clipping at the ends by shrinking the range | |
| // its center can travel. | |
| final inset = thumbSize / 2; | |
| final range = width - inset * 2; | |
| final fraction = fractionOfValue(value, min: min, max: max); | |
| final thumbCenterX = inset + range * fraction; | |
| void dragTo(Offset local) { | |
| final next = valueOfFraction( | |
| (local.dx - inset) / range, | |
| min: min, | |
| max: max, | |
| divisions: divisions, | |
| ); | |
| if (next != value) onChanged(next); | |
| } | |
| return GestureDetector( | |
| // The whole track is hit-testable so the thumb snaps to wherever | |
| // the finger lands. | |
| behavior: HitTestBehavior.opaque, | |
| onTapDown: (details) => dragTo(details.localPosition), | |
| onPanUpdate: (details) => dragTo(details.localPosition), | |
| child: Stack( | |
| children: [ | |
| _Groove( | |
| top: (height - trackHeight) / 2, | |
| height: trackHeight, | |
| color: scheme.surfaceContainerHighest, | |
| ), | |
| _Fill( | |
| top: (height - trackHeight) / 2, | |
| height: trackHeight, | |
| width: thumbCenterX + trackHeight / 2, | |
| color: scheme.primary, | |
| ), | |
| _Thumb( | |
| left: thumbCenterX - thumbSize / 2, | |
| top: (height - thumbSize) / 2, | |
| size: thumbSize, | |
| color: scheme.primary, | |
| ), | |
| ], | |
| ), | |
| ); | |
| }, | |
| ), | |
| ); | |
| } | |
| } | |
| /// The groove. Slightly darker at the top so it reads as a sunken tray, | |
| /// distinct from the fill and thumb. | |
| class _Groove extends StatelessWidget { | |
| const _Groove({required this.top, required this.height, required this.color}); | |
| final double top; | |
| final double height; | |
| final Color color; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Positioned( | |
| left: 0, | |
| right: 0, | |
| top: top, | |
| height: height, | |
| child: DecoratedBox( | |
| decoration: BoxDecoration( | |
| borderRadius: BorderRadius.circular(height / 2), | |
| gradient: LinearGradient( | |
| begin: Alignment.topCenter, | |
| end: Alignment.bottomCenter, | |
| colors: [ | |
| Color.lerp(color, Colors.black, 0.08)!, | |
| Color.lerp(color, Colors.white, 0.1)!, | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| /// The fill that lets the value be felt as a length. Runs up to the thumb's | |
| /// center. | |
| class _Fill extends StatelessWidget { | |
| const _Fill({ | |
| required this.top, | |
| required this.height, | |
| required this.width, | |
| required this.color, | |
| }); | |
| final double top; | |
| final double height; | |
| final double width; | |
| final Color color; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Positioned( | |
| left: 0, | |
| top: top, | |
| height: height, | |
| width: width, | |
| child: DecoratedBox( | |
| decoration: BoxDecoration( | |
| borderRadius: BorderRadius.circular(height / 2), | |
| gradient: LinearGradient( | |
| begin: Alignment.topCenter, | |
| end: Alignment.bottomCenter, | |
| colors: [ | |
| Color.lerp(color, Colors.white, 0.25)!, | |
| Color.lerp(color, Colors.black, 0.08)!, | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| /// The oversized thumb. A bright spherical face plus drop shadow signals | |
| /// "floating and grabbable". | |
| class _Thumb extends StatelessWidget { | |
| const _Thumb({ | |
| required this.left, | |
| required this.top, | |
| required this.size, | |
| required this.color, | |
| }); | |
| final double left; | |
| final double top; | |
| final double size; | |
| final Color color; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Positioned( | |
| left: left, | |
| top: top, | |
| width: size, | |
| height: size, | |
| child: DecoratedBox( | |
| decoration: BoxDecoration( | |
| shape: BoxShape.circle, | |
| border: Border.all(color: color, width: 3), | |
| gradient: LinearGradient( | |
| begin: Alignment.topCenter, | |
| end: Alignment.bottomCenter, | |
| colors: [Colors.white, Color.lerp(Colors.white, color, 0.25)!], | |
| ), | |
| boxShadow: [ | |
| BoxShadow( | |
| color: Color.lerp( | |
| color, | |
| Colors.black, | |
| 0.55, | |
| )!.withValues(alpha: 0.4), | |
| offset: const Offset(0, 3), | |
| blurRadius: 3, | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| // --- puffy_toggle.dart ----------------------------------------------------------- | |
| /// One option of a [PuffyToggle]. | |
| class PuffyToggleOption<T> { | |
| const PuffyToggleOption({required this.value, required this.label}); | |
| final T value; | |
| final String label; | |
| } | |
| /// The tactile single-select toggle; the replacement for Material's | |
| /// `SegmentedButton`. The selected segment floats; instead of sinking the | |
| /// others (a sunken side can be misread as ON), unselected segments are just | |
| /// dimmed. Holds no state; changes come back via [onChanged]. | |
| class PuffyToggle<T> extends StatelessWidget { | |
| const PuffyToggle({ | |
| super.key, | |
| required this.options, | |
| required this.value, | |
| required this.onChanged, | |
| this.isEnabled, | |
| }); | |
| final List<PuffyToggleOption<T>> options; | |
| /// Current selection. The calling State owns it. | |
| final T value; | |
| /// Called only when a different option is tapped (same behavior as | |
| /// SegmentedButton). | |
| final ValueChanged<T> onChanged; | |
| /// Temporarily disables options. Null (default) keeps everything enabled | |
| /// with the usual look. Disabled options are dimmed and ignore taps. | |
| final bool Function(T value)? isEnabled; | |
| @override | |
| Widget build(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| // Tighten the tray's padding and corner radius proportionally on phones. | |
| final compact = context.isCompactUi; | |
| final gap = compact ? 5.0 : 6.0; | |
| return Container( | |
| padding: EdgeInsets.all(gap), | |
| // The tray is a slightly sunken surface, making the floating selected | |
| // segment stand out. | |
| decoration: BoxDecoration( | |
| color: scheme.surfaceContainerHighest, | |
| borderRadius: BorderRadius.circular(compact ? 18 : 22), | |
| ), | |
| child: Row( | |
| children: [ | |
| for (final (index, option) in options.indexed) ...[ | |
| if (index > 0) SizedBox(width: gap), | |
| () { | |
| final enabled = isEnabled?.call(option.value) ?? true; | |
| final selected = option.value == value; | |
| return Expanded( | |
| child: _Segment( | |
| label: option.label, | |
| selected: selected, | |
| enabled: enabled, | |
| compact: compact, | |
| // Selected and disabled segments aren't tappable. | |
| onTap: (selected || !enabled) | |
| ? null | |
| : () => onChanged(option.value), | |
| ), | |
| ); | |
| }(), | |
| ], | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| class _Segment extends StatelessWidget { | |
| const _Segment({ | |
| required this.label, | |
| required this.selected, | |
| required this.compact, | |
| this.enabled = true, | |
| this.onTap, | |
| }); | |
| final String label; | |
| final bool selected; | |
| final bool compact; | |
| final bool enabled; | |
| final VoidCallback? onTap; | |
| @override | |
| Widget build(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| final top = Color.lerp(scheme.primary, Colors.white, 0.25)!; | |
| final bottom = Color.lerp(scheme.primary, Colors.black, 0.08)!; | |
| final shadow = Color.lerp(scheme.primary, Colors.black, 0.55)!; | |
| // Dim the whole disabled option to communicate "not pressable right now". | |
| final segment = Opacity( | |
| opacity: enabled ? 1.0 : 0.4, | |
| child: AnimatedContainer( | |
| duration: const Duration(milliseconds: 150), | |
| curve: Curves.easeOut, | |
| height: compact ? 40 : 48, | |
| // Only the selected segment floats. transform doesn't break layout. | |
| transform: Matrix4.translationValues(0, selected ? -2 : 0, 0), | |
| decoration: BoxDecoration( | |
| borderRadius: BorderRadius.circular(compact ? 14 : 16), | |
| gradient: selected | |
| ? LinearGradient( | |
| begin: Alignment.topCenter, | |
| end: Alignment.bottomCenter, | |
| colors: [top, bottom], | |
| ) | |
| : null, | |
| color: selected ? null : Colors.transparent, | |
| boxShadow: selected | |
| ? [ | |
| BoxShadow( | |
| color: shadow.withValues(alpha: 0.4), | |
| offset: const Offset(0, 4), | |
| blurRadius: 2, | |
| ), | |
| ] | |
| : const [], | |
| ), | |
| alignment: Alignment.center, | |
| // Never wrap or clip in a narrow segment; shrink to fit the fixed | |
| // height instead. | |
| child: FittedBox( | |
| fit: BoxFit.scaleDown, | |
| child: Text( | |
| label, | |
| maxLines: 1, | |
| style: TextStyle( | |
| fontSize: compact ? 14 : 16, | |
| fontWeight: selected ? FontWeight.bold : FontWeight.normal, | |
| color: selected | |
| ? scheme.onPrimary | |
| : scheme.onSurfaceVariant.withValues(alpha: 0.6), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| return GestureDetector( | |
| behavior: HitTestBehavior.opaque, | |
| onTap: onTap, | |
| child: segment, | |
| ); | |
| } | |
| } | |
| // --- puffy_chip_grid.dart -------------------------------------------------------- | |
| /// One option of a [PuffyChipGrid]. An icon plus short label representing a | |
| /// "thing" (solid, shape, scene). More robust than text-only toggles as | |
| /// options multiply or labels grow long. | |
| class PuffyChipOption<T> { | |
| const PuffyChipOption({ | |
| required this.value, | |
| this.icon, | |
| this.iconBuilder, | |
| required this.label, | |
| }) : assert( | |
| icon != null || iconBuilder != null, | |
| 'either icon or iconBuilder is required', | |
| ); | |
| final T value; | |
| /// The Material icon shown large in the chip's upper half. May be null when | |
| /// [iconBuilder] is provided. | |
| final IconData? icon; | |
| /// Builder for custom-drawn "things" that Material icons can't express | |
| /// (solid glyphs etc.). Receives the foreground color and icon size the | |
| /// chip derives from its selection state. | |
| final Widget Function(Color color, double size)? iconBuilder; | |
| /// Short label under the icon. Overlong words shrink via FittedBox. | |
| final String label; | |
| } | |
| /// A grid of square chips for picking a "thing". Replaces Material selection | |
| /// controls and text toggles; shares the visual grammar of [PuffyToggle] | |
| /// (sunken tray, selected chip floats -2px on a primary gradient with a drop | |
| /// shadow, unselected chips are dimmed). Holds no state; changes come back | |
| /// via [onChanged]. | |
| class PuffyChipGrid<T> extends StatelessWidget { | |
| const PuffyChipGrid({ | |
| super.key, | |
| required this.options, | |
| required this.value, | |
| required this.onChanged, | |
| this.columns, | |
| }); | |
| final List<PuffyChipOption<T>> options; | |
| /// Current selection. The calling State owns it. | |
| final T value; | |
| /// Called only when a different option is tapped (same behavior as | |
| /// [PuffyToggle]). | |
| final ValueChanged<T> onChanged; | |
| /// Column count. Defaults to 1 on compact (phone portrait) and 2 otherwise. | |
| final int? columns; | |
| @override | |
| Widget build(BuildContext context) { | |
| final compact = context.isCompactUi; | |
| final gap = compact ? 8.0 : 10.0; | |
| final columnCount = columns ?? (compact ? 1 : 2); | |
| return LayoutBuilder( | |
| builder: (context, constraints) { | |
| // Chip width follows from column count and gaps; height stays close | |
| // to square. | |
| final totalGap = gap * (columnCount - 1); | |
| final chipWidth = (constraints.maxWidth - totalGap) / columnCount; | |
| return Wrap( | |
| spacing: gap, | |
| runSpacing: gap, | |
| children: [ | |
| for (final option in options) | |
| SizedBox( | |
| width: chipWidth, | |
| child: _Chip<T>( | |
| option: option, | |
| selected: option.value == value, | |
| compact: compact, | |
| onTap: option.value == value | |
| ? null | |
| : () => onChanged(option.value), | |
| ), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ); | |
| } | |
| } | |
| class _Chip<T> extends StatelessWidget { | |
| const _Chip({ | |
| required this.option, | |
| required this.selected, | |
| required this.compact, | |
| this.onTap, | |
| }); | |
| final PuffyChipOption<T> option; | |
| final bool selected; | |
| final bool compact; | |
| final VoidCallback? onTap; | |
| @override | |
| Widget build(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| // Same derivation as PuffyToggle (from the theme, not hardcoded), so dark | |
| // mode holds up. | |
| final top = Color.lerp(scheme.primary, Colors.white, 0.25)!; | |
| final bottom = Color.lerp(scheme.primary, Colors.black, 0.08)!; | |
| final shadow = Color.lerp(scheme.primary, Colors.black, 0.55)!; | |
| final iconSize = compact ? 30.0 : 34.0; | |
| final labelSize = compact ? 11.0 : 12.0; | |
| // One foreground color shared by Icon / solid glyph / label, derived once | |
| // from the selection state (glyphs carry no colors of their own, keeping | |
| // the selection highlight centralized here). | |
| final foreground = selected | |
| ? scheme.onPrimary | |
| : scheme.onSurfaceVariant.withValues(alpha: 0.7); | |
| return GestureDetector( | |
| behavior: HitTestBehavior.opaque, | |
| onTap: onTap, | |
| child: AnimatedContainer( | |
| duration: const Duration(milliseconds: 150), | |
| curve: Curves.easeOut, | |
| // Only the selected chip floats. transform doesn't break layout. | |
| transform: Matrix4.translationValues(0, selected ? -2 : 0, 0), | |
| padding: EdgeInsets.symmetric( | |
| vertical: compact ? 10 : 12, | |
| horizontal: 8, | |
| ), | |
| decoration: BoxDecoration( | |
| borderRadius: BorderRadius.circular(compact ? 16 : 18), | |
| // Selected: primary gradient. Unselected: the sunken tray color. | |
| gradient: selected | |
| ? LinearGradient( | |
| begin: Alignment.topCenter, | |
| end: Alignment.bottomCenter, | |
| colors: [top, bottom], | |
| ) | |
| : null, | |
| color: selected ? null : scheme.surfaceContainerHighest, | |
| boxShadow: selected | |
| ? [ | |
| BoxShadow( | |
| color: shadow.withValues(alpha: 0.4), | |
| offset: const Offset(0, 4), | |
| blurRadius: 2, | |
| ), | |
| ] | |
| : const [], | |
| ), | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| // Either a Material icon or a custom-drawn solid glyph. Both get | |
| // the same color and size for visual consistency. | |
| if (option.iconBuilder != null) | |
| option.iconBuilder!(foreground, iconSize) | |
| else | |
| Icon(option.icon!, size: iconSize, color: foreground), | |
| const SizedBox(height: 6), | |
| // Long labels shrink to one line instead of wrapping (same policy | |
| // as PuffyToggle). | |
| FittedBox( | |
| fit: BoxFit.scaleDown, | |
| child: Text( | |
| option.label, | |
| maxLines: 1, | |
| style: TextStyle( | |
| fontSize: labelSize, | |
| fontWeight: selected ? FontWeight.bold : FontWeight.normal, | |
| color: foreground, | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| // --- puffy_stepper.dart ---------------------------------------------------------- | |
| /// The tactile previous/next selector; replaces `IconButton(<)` + value + | |
| /// `IconButton(>)`. Shows the current value large in the middle with puffy | |
| /// arrows on both ends. Pass null on the side that hit its end for the sunken | |
| /// disabled look. Holds no state. | |
| class PuffyStepper extends StatelessWidget { | |
| const PuffyStepper({ | |
| super.key, | |
| required this.display, | |
| required this.onPrevious, | |
| required this.onNext, | |
| }); | |
| /// The current value's label shown in the middle. | |
| final String display; | |
| final VoidCallback? onPrevious; | |
| final VoidCallback? onNext; | |
| @override | |
| Widget build(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| final compact = context.isCompactUi; | |
| return Row( | |
| children: [ | |
| _Arrow( | |
| icon: Icons.chevron_left, | |
| onPressed: onPrevious, | |
| compact: compact, | |
| ), | |
| Expanded( | |
| child: Text( | |
| display, | |
| textAlign: TextAlign.center, | |
| // The value is the emphasis target: large and in the primary | |
| // color ("motion over text"). | |
| style: TextStyle( | |
| fontSize: compact ? 18 : 22, | |
| fontWeight: FontWeight.bold, | |
| color: scheme.primary, | |
| ), | |
| ), | |
| ), | |
| _Arrow(icon: Icons.chevron_right, onPressed: onNext, compact: compact), | |
| ], | |
| ); | |
| } | |
| } | |
| class _Arrow extends StatelessWidget { | |
| const _Arrow({ | |
| required this.icon, | |
| required this.onPressed, | |
| required this.compact, | |
| }); | |
| final IconData icon; | |
| final VoidCallback? onPressed; | |
| final bool compact; | |
| @override | |
| Widget build(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| return PuffyPressable( | |
| onPressed: onPressed, | |
| color: scheme.primary, | |
| borderRadius: const BorderRadius.all(Radius.circular(999)), | |
| // Cap the padding at 8 on phones so the arrow's touch target doesn't | |
| // shrink too far. | |
| padding: EdgeInsets.all(compact ? 8 : 10), | |
| child: Icon(icon, size: compact ? 22 : 28, color: scheme.onPrimary), | |
| ); | |
| } | |
| } | |
| // ============================================================================= | |
| // 5. Simplified island layout | |
| // | |
| // A Gist stand-in extracted from the full app's ExperienceLayout / | |
| // IslandScaffold (~1,200 lines including the portrait sheet's collapsing, | |
| // detail groups, and yield logic), keeping only what this screen uses. | |
| // - Landscape: controls gather on a floating island (width 330) at the lower | |
| // right; the main view receives the island's right-side band as an avoid | |
| // Rect (it shifts toward the opposite side). Playback bar at the bottom. | |
| // - Portrait: the main view is maximized with the control panel and playback | |
| // bar stacked below (avoid is null). | |
| // ============================================================================= | |
| typedef IslandMainBuilder = | |
| Widget Function(BuildContext context, BoxConstraints usable, Rect? avoid); | |
| class ExperienceLayout extends StatelessWidget { | |
| const ExperienceLayout({ | |
| super.key, | |
| this.island = true, | |
| required this.islandMainBuilder, | |
| this.primaryGroups = const [], | |
| this.playback, | |
| }); | |
| /// Kept for call compatibility with the full app (this simplified version | |
| /// is always island mode). | |
| final bool island; | |
| final IslandMainBuilder islandMainBuilder; | |
| final List<Widget> primaryGroups; | |
| final Widget? playback; | |
| static const _islandWidth = 330.0; | |
| static const _margin = 16.0; | |
| @override | |
| Widget build(BuildContext context) { | |
| return SafeArea( | |
| child: LayoutBuilder( | |
| builder: (context, constraints) { | |
| final landscape = constraints.maxWidth >= constraints.maxHeight; | |
| return landscape | |
| ? _buildLandscape(context) | |
| : _buildPortrait(context, constraints); | |
| }, | |
| ), | |
| ); | |
| } | |
| /// The floating island panel that gathers the controls. Overflow scrolls on | |
| /// small screens. | |
| Widget _islandPanel(BuildContext context) { | |
| final scheme = Theme.of(context).colorScheme; | |
| return Container( | |
| width: _islandWidth, | |
| padding: const EdgeInsets.all(12), | |
| decoration: BoxDecoration( | |
| color: scheme.surfaceContainerLow, | |
| borderRadius: BorderRadius.circular(24), | |
| boxShadow: [ | |
| BoxShadow( | |
| color: Colors.black.withValues(alpha: 0.15), | |
| offset: const Offset(0, 4), | |
| blurRadius: 12, | |
| ), | |
| ], | |
| ), | |
| child: SingleChildScrollView( | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.stretch, | |
| children: primaryGroups, | |
| ), | |
| ), | |
| ); | |
| } | |
| Widget _buildLandscape(BuildContext context) { | |
| return Column( | |
| children: [ | |
| Expanded( | |
| child: LayoutBuilder( | |
| builder: (context, usable) { | |
| // The right band the island occupies. The main view keeps clear | |
| // of this Rect by shifting left. | |
| final avoid = Rect.fromLTRB( | |
| usable.maxWidth - _islandWidth - _margin * 2, | |
| 0, | |
| usable.maxWidth, | |
| usable.maxHeight, | |
| ); | |
| return Stack( | |
| children: [ | |
| Positioned.fill( | |
| child: islandMainBuilder(context, usable, avoid), | |
| ), | |
| Positioned( | |
| right: _margin, | |
| bottom: _margin, | |
| child: ConstrainedBox( | |
| constraints: BoxConstraints( | |
| maxHeight: usable.maxHeight - _margin * 2, | |
| ), | |
| child: _islandPanel(context), | |
| ), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ), | |
| ), | |
| if (playback != null) playback!, | |
| ], | |
| ); | |
| } | |
| Widget _buildPortrait(BuildContext context, BoxConstraints constraints) { | |
| return Column( | |
| children: [ | |
| Expanded( | |
| child: LayoutBuilder( | |
| builder: (context, usable) => | |
| islandMainBuilder(context, usable, null), | |
| ), | |
| ), | |
| // The control panel is capped at ~45% of the screen height; overflow | |
| // scrolls. | |
| ConstrainedBox( | |
| constraints: BoxConstraints(maxHeight: constraints.maxHeight * 0.45), | |
| child: Padding( | |
| padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), | |
| child: SingleChildScrollView( | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| crossAxisAlignment: CrossAxisAlignment.stretch, | |
| children: primaryGroups, | |
| ), | |
| ), | |
| ), | |
| ), | |
| if (playback != null) playback!, | |
| ], | |
| ); | |
| } | |
| } | |
| // ============================================================================= | |
| // 6. Solid glyphs for the shape chips (net_folding_glyphs.dart) | |
| // ============================================================================= | |
| /// Solid glyphs for this screen's "shape" chips. Receives the single color | |
| /// and square size that PuffyChipGrid's iconBuilder passes in, and draws the | |
| /// cube / pyramid / cylinder / cone as single-color line art. These are | |
| /// static, icon-like pictures with no 3D projection logic. | |
| enum NetFoldingGlyph { cube, tetra, cylinder, cone } | |
| /// Adapts a [NetFoldingGlyph] to the `Widget Function(Color color, double | |
| /// size)` that PuffyChipOption.iconBuilder expects. The chip decides color | |
| /// and size from its selection state; the glyph only uses what it is given. | |
| Widget Function(Color color, double size) glyphBuilder(NetFoldingGlyph shape) { | |
| return (color, size) => SizedBox( | |
| width: size, | |
| height: size, | |
| child: CustomPaint(painter: _GlyphPainter(shape, color)), | |
| ); | |
| } | |
| class _GlyphPainter extends CustomPainter { | |
| _GlyphPainter(this.shape, this.color); | |
| final NetFoldingGlyph shape; | |
| final Color color; | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| final s = size.shortestSide; | |
| final stroke = Paint() | |
| ..color = color | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = s * 0.06 | |
| ..strokeJoin = StrokeJoin.round | |
| ..strokeCap = StrokeCap.round; | |
| switch (shape) { | |
| case NetFoldingGlyph.cube: | |
| _paintCube(canvas, s, stroke); | |
| case NetFoldingGlyph.tetra: | |
| _paintTetra(canvas, s, stroke); | |
| case NetFoldingGlyph.cylinder: | |
| _paintCylinder(canvas, s, stroke); | |
| case NetFoldingGlyph.cone: | |
| _paintCone(canvas, s, stroke); | |
| } | |
| } | |
| /// Cube: the classic "box" line art — a front square joined to a back | |
| /// square offset into the distance. | |
| void _paintCube(Canvas canvas, double s, Paint stroke) { | |
| final d = s * 0.20; // depth offset (toward the upper right) | |
| final fs = s * 0.48; // front square edge length | |
| final x0 = s * 0.16; | |
| final y0 = s * 0.34; | |
| final fTL = Offset(x0, y0); | |
| final fTR = Offset(x0 + fs, y0); | |
| final fBR = Offset(x0 + fs, y0 + fs); | |
| final fBL = Offset(x0, y0 + fs); | |
| Offset back(Offset p) => p.translate(d, -d); | |
| final bTL = back(fTL); | |
| final bTR = back(fTR); | |
| final bBR = back(fBR); | |
| // The front square | |
| canvas.drawPath(Path()..addPolygon([fTL, fTR, fBR, fBL], true), stroke); | |
| // The visible back edges (top and right of the back square) plus the 3 | |
| // connectors between front and back | |
| canvas.drawLine(fTL, bTL, stroke); | |
| canvas.drawLine(fTR, bTR, stroke); | |
| canvas.drawLine(fBR, bBR, stroke); | |
| canvas.drawLine(bTL, bTR, stroke); | |
| canvas.drawLine(bTR, bBR, stroke); | |
| } | |
| /// Pyramid (regular tetrahedron): a wireframe of apex plus base triangle | |
| /// (6 edges). | |
| void _paintTetra(Canvas canvas, double s, Paint stroke) { | |
| final apex = Offset(s * 0.5, s * 0.14); | |
| final left = Offset(s * 0.18, s * 0.80); | |
| final right = Offset(s * 0.82, s * 0.80); | |
| final back = Offset(s * 0.56, s * 0.60); | |
| for (final base in [left, right, back]) { | |
| canvas.drawLine(apex, base, stroke); | |
| } | |
| canvas.drawLine(left, right, stroke); // front edge of the base | |
| canvas.drawLine(left, back, stroke); | |
| canvas.drawLine(back, right, stroke); | |
| } | |
| /// Cylinder: top and bottom ellipses (bases) with side lines. | |
| void _paintCylinder(Canvas canvas, double s, Paint stroke) { | |
| final cx = s * 0.5; | |
| final rx = s * 0.28; | |
| final ry = s * 0.10; | |
| final topY = s * 0.26; | |
| final botY = s * 0.74; | |
| canvas.drawOval( | |
| Rect.fromCenter(center: Offset(cx, topY), width: rx * 2, height: ry * 2), | |
| stroke, | |
| ); | |
| canvas.drawOval( | |
| Rect.fromCenter(center: Offset(cx, botY), width: rx * 2, height: ry * 2), | |
| stroke, | |
| ); | |
| canvas.drawLine(Offset(cx - rx, topY), Offset(cx - rx, botY), stroke); | |
| canvas.drawLine(Offset(cx + rx, topY), Offset(cx + rx, botY), stroke); | |
| } | |
| /// Cone: the base ellipse with slant lines from the apex to its left and | |
| /// right ends. | |
| void _paintCone(Canvas canvas, double s, Paint stroke) { | |
| final cx = s * 0.5; | |
| final rx = s * 0.30; | |
| final ry = s * 0.10; | |
| final apex = Offset(cx, s * 0.16); | |
| final baseY = s * 0.74; | |
| canvas.drawOval( | |
| Rect.fromCenter(center: Offset(cx, baseY), width: rx * 2, height: ry * 2), | |
| stroke, | |
| ); | |
| canvas.drawLine(apex, Offset(cx - rx, baseY), stroke); | |
| canvas.drawLine(apex, Offset(cx + rx, baseY), stroke); | |
| } | |
| @override | |
| bool shouldRepaint(_GlyphPainter old) => | |
| old.shape != shape || old.color != color; | |
| } | |
| // ============================================================================= | |
| // 7. CustomPainter (net_folding_painter.dart) | |
| // ============================================================================= | |
| /// A face ready to paint: projected, depth-sorted, and shaded. | |
| class PaintedFace { | |
| const PaintedFace({ | |
| required this.points, | |
| required this.color, | |
| this.strokes = const [], | |
| }); | |
| /// 2D coordinates with the screen center as origin. | |
| final List<Offset> points; | |
| final Color color; | |
| /// The projected freehand lines drawn on this face (each element is one | |
| /// point list). Painting them right after the face fill makes hidden- | |
| /// surface removal work correctly. | |
| final List<List<Offset>> strokes; | |
| } | |
| /// A painter that only draws pre-projected 2D coordinates. All 3D math | |
| /// happens on the models side. | |
| class NetFoldingPainter extends CustomPainter { | |
| const NetFoldingPainter({ | |
| required this.faces, | |
| required this.edgeColor, | |
| // Previews without strokes can keep the default, so this is optional. | |
| this.penColor = const Color(0xFF1A237E), | |
| }); | |
| /// Faces ordered back-to-front (painter's algorithm). | |
| final List<PaintedFace> faces; | |
| /// Line color of creases and edges. The caller passes the theme's | |
| /// onSurfaceVariant so it stays visible on dark backgrounds (the color is | |
| /// chrome, so it isn't fixed here). | |
| final Color edgeColor; | |
| /// Freehand pen color (single fixed color). A white halo goes underneath | |
| /// so it reads on any face color. | |
| final Color penColor; | |
| @override | |
| void paint(Canvas canvas, Size size) { | |
| canvas.translate(size.width / 2, size.height / 2); | |
| final fill = Paint()..style = PaintingStyle.fill; | |
| final edgeStroke = Paint() | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 2.5 | |
| ..strokeJoin = StrokeJoin.round | |
| ..color = edgeColor; | |
| // The pen is drawn twice: white halo, then dark core — visible on both | |
| // light and dark faces. | |
| final penHalo = Paint() | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 7 | |
| ..strokeJoin = StrokeJoin.round | |
| ..strokeCap = StrokeCap.round | |
| ..color = Colors.white; | |
| final penCore = Paint() | |
| ..style = PaintingStyle.stroke | |
| ..strokeWidth = 3.5 | |
| ..strokeJoin = StrokeJoin.round | |
| ..strokeCap = StrokeCap.round | |
| ..color = penColor; | |
| for (final face in faces) { | |
| final path = Path()..addPolygon(face.points, true); | |
| canvas.drawPath(path, fill..color = face.color); | |
| // The face outline = the net's creases and the solid's edges. Nearer | |
| // faces are painted later, so far edges are correctly hidden behind | |
| // faces. | |
| canvas.drawPath(path, edgeStroke); | |
| // Drawing a face's strokes right after its fill lets nearer faces hide | |
| // the strokes of farther faces. On curved surfaces one line splits into | |
| // many small triangles, so halo and core are drawn as two passes per | |
| // face ("all halos → all cores") — otherwise a segment's halo would | |
| // overwrite the previous segment's core and produce banding. | |
| _drawStrokes(canvas, face.strokes, penHalo, isHalo: true); | |
| _drawStrokes(canvas, face.strokes, penCore, isHalo: false); | |
| } | |
| } | |
| /// Draws one pass of a face's strokes. Called twice: thick white halo | |
| /// ([isHalo]) first, thin core second. A single point (a tap) becomes a | |
| /// filled dot; two or more points become a polyline. | |
| void _drawStrokes( | |
| Canvas canvas, | |
| List<List<Offset>> strokes, | |
| Paint paint, { | |
| required bool isHalo, | |
| }) { | |
| final dotRadius = isHalo ? 3.5 : 1.75; | |
| for (final stroke in strokes) { | |
| if (stroke.isEmpty) continue; | |
| if (stroke.length == 1) { | |
| paint.style = PaintingStyle.fill; | |
| canvas.drawCircle(stroke.first, dotRadius, paint); | |
| paint.style = PaintingStyle.stroke; | |
| continue; | |
| } | |
| canvas.drawPath(Path()..addPolygon(stroke, false), paint); | |
| } | |
| } | |
| @override | |
| bool shouldRepaint(NetFoldingPainter oldDelegate) => | |
| oldDelegate.faces != faces || | |
| oldDelegate.edgeColor != edgeColor || | |
| oldDelegate.penColor != penColor; | |
| } | |
| // ============================================================================= | |
| // 8. The experience screen (net_folding_page.dart) | |
| // ============================================================================= | |
| /// The kind of solid to assemble. Cube and pyramid use the hinge tree; | |
| /// cylinder and cone use the rolling meshes. | |
| enum _ShapeMode { cube, tetra, cylinder, cone } | |
| /// What a drag does. rotate = orbit the view, draw = draw on a face. | |
| enum _Interaction { rotate, draw } | |
| /// The "Nets of Solids" experience. Move the fold amount with the slider or | |
| /// buttons and watch the flat net assemble into a cube or cone, dragging to | |
| /// orbit around it. | |
| class NetFoldingPage extends StatefulWidget { | |
| const NetFoldingPage({super.key}); | |
| @override | |
| State<NetFoldingPage> createState() => _NetFoldingPageState(); | |
| } | |
| class _NetFoldingPageState extends State<NetFoldingPage> | |
| with SingleTickerProviderStateMixin { | |
| static const _rotationPerPixel = 0.01; | |
| static const _cameraDistance = 8.0; | |
| /// Curved solids (cylinder / cone) allow drawing only in the fully-opened | |
| /// flat state. Once t exceeds this epsilon, drawing is off (small enough to | |
| /// ignore rounding from the manual slider). | |
| static const _drawTEpsilon = 1e-6; | |
| /// On wide screens (tablets) the default 100 renders the solid too small, | |
| /// so scale 1.5×. Projection and hit-testing share this value. | |
| double get _viewScale => context.isCompactUi ? 100.0 : 150.0; | |
| /// Face colors. They follow fromCells' face order (breadth-first from the | |
| /// root), so positions vary per net, but the root being yellow is common. | |
| static const _faceColors = [ | |
| Color(0xFFFFEE58), // yellow (root) | |
| Color(0xFFFFA726), // orange | |
| Color(0xFF66BB6A), // green | |
| Color(0xFF42A5F5), // blue | |
| Color(0xFFEF5350), // red | |
| Color(0xFFAB47BC), // purple | |
| ]; | |
| /// Lateral and base colors for the cone/cylinder (shared by both). | |
| static const _lateralColor = Color(0xFF42A5F5); // lateral: blue | |
| static const _curvedBaseColor = Color(0xFFFFA726); // base: orange | |
| /// Freehand pen color (single fixed color); the core is navy. The painter | |
| /// lays a white halo underneath so it reads on any face color. | |
| static const _penColor = Color(0xFF1A237E); | |
| final List<CubeNetPattern> _patterns = FoldableNet.allCubeNets(); | |
| int _patternIndex = 0; | |
| /// The pyramid (regular tetrahedron) net. Fixed dimensions, built once. | |
| final FoldableNet _tetraNet = FoldableNet.tetrahedron(); | |
| _ShapeMode _mode = _ShapeMode.cube; | |
| // Cone dimensions. The slant always stays longer than the base radius | |
| // (the condition for a real height). | |
| double _coneRadius = 1.0; | |
| double _coneSlant = 2.4; | |
| // Cylinder dimensions (radius and height). | |
| double _cylinderRadius = 0.9; | |
| double _cylinderHeight = 2.2; | |
| FoldableNet get _net => _patterns[_patternIndex].net; | |
| /// The controller's value is used directly as the fold amount t | |
| /// (0 = flat, 1 = cube). | |
| late final AnimationController _foldController; | |
| // Initial pose: the flat net on the horizontal plane, viewed slightly from | |
| // above. | |
| double _angleX = 0.9; | |
| double _angleY = -0.5; | |
| /// What a drag does (rotate/draw). Curved solids always fall back to | |
| /// rotate. | |
| _Interaction _interaction = _Interaction.rotate; | |
| /// The freehand lines drawn on faces. State stays inside the page. | |
| final NetDrawing _drawing = NetDrawing(); | |
| @override | |
| void initState() { | |
| super.initState(); | |
| _foldController = | |
| AnimationController( | |
| vsync: this, | |
| duration: const Duration(milliseconds: 1800), | |
| )..addListener(() { | |
| setState(() { | |
| // If playback or interaction moves t away from 0 while drawing on | |
| // a curved solid, drawing becomes impossible — fall back to | |
| // rotate automatically (the toggle also shows as disabled). | |
| if (_interaction == _Interaction.draw && !_canDrawNow) { | |
| _interaction = _Interaction.rotate; | |
| } | |
| }); | |
| }); | |
| } | |
| @override | |
| void dispose() { | |
| _foldController.dispose(); | |
| super.dispose(); | |
| } | |
| /// Steps to the previous/next net (wrapping at the ends). | |
| /// The fold amount and viewpoint are kept so nets can be compared in the | |
| /// same pose. Face order changes, which would point strokes at different | |
| /// faces, so the drawing is cleared. | |
| void _stepPattern(int delta) { | |
| setState(() { | |
| _patternIndex = | |
| (_patternIndex + delta + _patterns.length) % _patterns.length; | |
| _drawing.clear(); | |
| }); | |
| } | |
| void _setMode(_ShapeMode mode) => setState(() { | |
| _mode = mode; | |
| // Changing shape reassigns stroke ownership, so clear. If the new shape | |
| // can't be drawn on right now (curved with t≠0), fall back to rotate. | |
| _drawing.clear(); | |
| if (!_canDrawNow) _interaction = _Interaction.rotate; | |
| }); | |
| void _setInteraction(_Interaction value) { | |
| // Ignore invalid picks (draw on a curved solid at t≠0). The toggle also | |
| // blocks this; this is a second guard. | |
| if (value == _Interaction.draw && !_canDrawNow) return; | |
| setState(() => _interaction = value); | |
| } | |
| void _clearDrawing() => setState(_drawing.clear); | |
| /// Whether the current solid is curved (cylinder / cone). Their drawing | |
| /// works differently from the hinge tree (cube / pyramid). | |
| bool get _isCurved => | |
| _mode == _ShapeMode.cylinder || _mode == _ShapeMode.cone; | |
| /// Whether "draw" can draw right now. Cube/pyramid: any t. Curved solids: | |
| /// only t=0 (flat). | |
| bool get _canDrawNow => | |
| _isCurved ? _foldController.value <= _drawTEpsilon : true; | |
| /// The hinge-tree drawing target (cube / pyramid). Null for curved solids | |
| /// (they draw through a different path). | |
| FoldableNet? get _drawableNet { | |
| switch (_mode) { | |
| case _ShapeMode.cube: | |
| return _net; | |
| case _ShapeMode.tetra: | |
| return _tetraNet; | |
| case _ShapeMode.cylinder: | |
| case _ShapeMode.cone: | |
| return null; | |
| } | |
| } | |
| /// The curved net (cylinder / cone) at the current dimensions. Null when | |
| /// the shape isn't curved. | |
| CurvedNet? get _curvedNet { | |
| switch (_mode) { | |
| case _ShapeMode.cylinder: | |
| return CylinderNet(radius: _cylinderRadius, height: _cylinderHeight); | |
| case _ShapeMode.cone: | |
| return ConeNet(baseRadius: _coneRadius, slant: _coneSlant); | |
| case _ShapeMode.cube: | |
| case _ShapeMode.tetra: | |
| return null; | |
| } | |
| } | |
| /// Builds the paint faces for the current shape. Cube/pyramid use the hinge | |
| /// tree while cylinder/cone use the rolling meshes — different mechanisms, | |
| /// so the construction paths split. | |
| List<PaintedFace> _buildFaces() { | |
| switch (_mode) { | |
| case _ShapeMode.cube: | |
| return _buildNetFaces(_net); | |
| case _ShapeMode.tetra: | |
| return _buildNetFaces(_tetraNet); | |
| case _ShapeMode.cylinder: | |
| case _ShapeMode.cone: | |
| return _buildCurvedFaces(_curvedNet!); | |
| } | |
| } | |
| void _setConeRadius(double value) { | |
| setState(() { | |
| _coneRadius = value; | |
| // Keep the slant longer than the base radius (growing the radius pushes | |
| // the slant up too). | |
| _coneSlant = math.max(_coneSlant, _coneRadius + 0.3); | |
| _clearOnReshape(); | |
| }); | |
| } | |
| void _setConeSlant(double value) { | |
| // Stop at the lower bound so the slant never drops to the base radius. | |
| setState(() { | |
| _coneSlant = math.max(value, _coneRadius + 0.3); | |
| _clearOnReshape(); | |
| }); | |
| } | |
| void _setCylinderRadius(double value) => setState(() { | |
| _cylinderRadius = value; | |
| _clearOnReshape(); | |
| }); | |
| void _setCylinderHeight(double value) => setState(() { | |
| _cylinderHeight = value; | |
| _clearOnReshape(); | |
| }); | |
| /// Changing a curved solid's dimensions reshapes its net, making saved | |
| /// stroke (u,v) point at different parts. Clear the drawing on curved | |
| /// solids to avoid the mismatch (cube/pyramid have fixed dimensions). | |
| void _clearOnReshape() { | |
| if (!_drawing.isEmpty) _drawing.clear(); | |
| } | |
| /// Converts the curved solid's triangle mesh into paint faces colored by | |
| /// part (lateral/base). Drawings (net strokes saved at t=0) are mapped onto | |
| /// the curved surface through the flat↔folded triangle correspondence and | |
| /// split per triangle so each piece draws right after its triangle (for | |
| /// hidden-surface removal). | |
| List<PaintedFace> _buildCurvedFaces(CurvedNet net) { | |
| final t = _foldController.value; | |
| final tris = net.foldedNetTriangles(t); | |
| // Folding moves the shape's center, so recenter on the bounding box every | |
| // frame before rotating. | |
| final center = boundingBoxCenter([for (final tri in tris) tri.folded]); | |
| Vec3 toView(Vec3 v) => (v - center).rotatedX(_angleX).rotatedY(_angleY); | |
| Offset project(Vec3 v) => | |
| projectPoint(v, cameraDistance: _cameraDistance, viewScale: _viewScale); | |
| final rotated = [ | |
| for (final tri in tris) [for (final v in tri.folded) toView(v)], | |
| ]; | |
| // Distribute the strokes into projected polylines per triangle. Drawing | |
| // each right after its triangle keeps hidden-surface removal correct even | |
| // when the curved surface occludes itself. | |
| final strokesByTri = _mapStrokesToTriangles( | |
| net, | |
| tris, | |
| (v) => project(toView(v)), | |
| ); | |
| return [ | |
| for (final i in faceOrderByDepth(rotated)) | |
| PaintedFace( | |
| points: [for (final v in rotated[i]) project(v)], | |
| color: _shadedColor( | |
| tris[i].part == netLateralPart ? _lateralColor : _curvedBaseColor, | |
| polygonNormal(rotated[i]), | |
| ), | |
| strokes: strokesByTri[i] ?? const [], | |
| ), | |
| ]; | |
| } | |
| /// Subdivides each stroke → maps it onto its triangles with barycentric | |
| /// coordinates → gathers projected polylines per triangle. [toScreen] is | |
| /// "folded 3D → recenter, rotate, project". | |
| Map<int, List<List<Offset>>> _mapStrokesToTriangles( | |
| CurvedNet net, | |
| List<NetTriangle> tris, | |
| Offset Function(Vec3) toScreen, | |
| ) { | |
| final byTri = <int, List<List<Offset>>>{}; | |
| for (final stroke in _drawing.strokes) { | |
| final dense = subdivideNetPolyline(stroke.points, net.netTriangleSize); | |
| // Map subdivided points to (owning triangle, projected point). | |
| // Unmappable points become breaks. | |
| final mapped = <({int tri, Offset screen})>[]; | |
| for (final p in dense) { | |
| final m = mapNetPointOnTriangles(p, tris, stroke.faceIndex); | |
| if (m == null) continue; | |
| mapped.add((tri: m.triangleIndex, screen: toScreen(m.folded))); | |
| } | |
| if (mapped.isEmpty) continue; | |
| // Merge consecutive same-triangle points into one polyline; when | |
| // crossing triangles, include the boundary point in both so the line | |
| // joins seamlessly. | |
| var curTri = mapped.first.tri; | |
| var poly = <Offset>[mapped.first.screen]; | |
| for (var i = 1; i < mapped.length; i++) { | |
| final m = mapped[i]; | |
| poly.add(m.screen); | |
| if (m.tri != curTri) { | |
| (byTri[curTri] ??= []).add(poly); | |
| poly = [mapped[i - 1].screen, m.screen]; | |
| curTri = m.tri; | |
| } | |
| } | |
| (byTri[curTri] ??= []).add(poly); | |
| } | |
| return byTri; | |
| } | |
| void _onPanUpdate(DragUpdateDetails details) { | |
| setState(() { | |
| // Signs chosen so the near side follows the drag direction. | |
| _angleY -= details.delta.dx * _rotationPerPixel; | |
| _angleX += details.delta.dy * _rotationPerPixel; | |
| }); | |
| } | |
| /// A drag on the main view. In draw mode (and drawable) it draws; | |
| /// otherwise it orbits the view. | |
| void _onMainPanStart(DragStartDetails details, Size size) { | |
| if (_interaction == _Interaction.draw && _canDrawNow) { | |
| _drawing.beginStroke(); | |
| _addDrawPoint(details.localPosition, size); | |
| } | |
| // Rotate mode only uses deltas, so nothing happens on start. | |
| } | |
| void _onMainPanUpdate(DragUpdateDetails details, Size size) { | |
| if (_interaction == _Interaction.draw && _canDrawNow) { | |
| _addDrawPoint(details.localPosition, size); | |
| } else { | |
| _onPanUpdate(details); | |
| } | |
| } | |
| /// Converts one screen point into the hit face/part's (u,v) and appends it | |
| /// to the stroke. Cube/pyramid intersect the folded faces; curved solids | |
| /// intersect the t=0 plane. | |
| void _addDrawPoint(Offset local, Size size) { | |
| // The painter draws with size/2 as origin; align to the same origin | |
| // before building the ray. | |
| final screen = local - Offset(size.width / 2, size.height / 2); | |
| final ray = rayThroughScreenPoint( | |
| screen, | |
| cameraDistance: _cameraDistance, | |
| viewScale: _viewScale, | |
| ); | |
| final net = _drawableNet; | |
| final FaceHit? hit; | |
| if (net != null) { | |
| hit = hitTestFaces(_hittableFaces(net), ray); | |
| } else { | |
| hit = _curvedHit(ray); | |
| } | |
| setState(() => _drawing.addHit(hit)); | |
| } | |
| /// Ray intersection with the curved solid's t=0 plane. Recovers the (u,v) | |
| /// on the plane holding the whole net and identifies the part analytically. | |
| /// The part ID acts as the stroke's faceIndex, so crossing a part boundary | |
| /// makes [NetDrawing] split the stroke automatically. | |
| FaceHit? _curvedHit(Ray ray) { | |
| final net = _curvedNet; | |
| if (net == null) return null; | |
| // Recenter on the flat (t=0) layout (originally y=0), then rotate — same | |
| // as the rendering's toView. | |
| final flat = net.foldedNetTriangles(0); | |
| final center = boundingBoxCenter([for (final tri in flat) tri.folded]); | |
| // Pin the affine correspondence with 3 non-collinear reference points | |
| // between net (u,v) and flat 3D (u,0,v). | |
| const refNet = [Offset(0, 0), Offset(1, 0), Offset(0, 1)]; | |
| const refWorld = [Vec3(0, 0, 0), Vec3(1, 0, 0), Vec3(0, 0, 1)]; | |
| final refView = [ | |
| for (final v in refWorld) | |
| (v - center).rotatedX(_angleX).rotatedY(_angleY), | |
| ]; | |
| final uv = planeNetHit(refNet, refView, ray); | |
| if (uv == null) return null; | |
| final part = net.hitPart(uv); | |
| if (part == null) return null; | |
| return FaceHit(faceIndex: part, uv: uv, rayT: 0); | |
| } | |
| /// Every face's vertices with folding → recentering → view rotation | |
| /// applied. Shared by rendering and hit-testing. | |
| List<List<Vec3>> _foldedRotatedFaces(FoldableNet net) { | |
| final folded = net.foldedFaces(_foldController.value); | |
| // Folding moves the shape's center, so recenter on the bounding box every | |
| // frame before rotating. | |
| final center = boundingBoxCenter(folded); | |
| return [ | |
| for (final face in folded) | |
| [ | |
| for (final v in face) | |
| (v - center).rotatedX(_angleX).rotatedY(_angleY), | |
| ], | |
| ]; | |
| } | |
| /// Each face's hit-test data (net coordinates ↔ view-rotated coordinates) | |
| /// at the current fold and viewpoint. | |
| List<HittableFace> _hittableFaces(FoldableNet net) { | |
| final rotated = _foldedRotatedFaces(net); | |
| return [ | |
| for (var i = 0; i < net.faces.length; i++) | |
| HittableFace( | |
| faceIndex: i, | |
| netCorners: net.faces[i].corners, | |
| viewCorners: rotated[i], | |
| ), | |
| ]; | |
| } | |
| List<PaintedFace> _buildNetFaces(FoldableNet net) { | |
| final t = _foldController.value; | |
| final rotated = _foldedRotatedFaces(net); | |
| Offset project(Vec3 v) => | |
| projectPoint(v, cameraDistance: _cameraDistance, viewScale: _viewScale); | |
| // Gather the strokes into projected point lists per face. They go through | |
| // the same transform as the corners (foldedNetPoints → recenter → view | |
| // rotation), so they track the fold and the viewpoint. | |
| final center = boundingBoxCenter(net.foldedFaces(t)); | |
| Vec3 toView(Vec3 v) => (v - center).rotatedX(_angleX).rotatedY(_angleY); | |
| final strokesByFace = <int, List<List<Offset>>>{}; | |
| for (final stroke in _drawing.strokes) { | |
| final worldPts = net.foldedNetPoints(stroke.faceIndex, stroke.points, t); | |
| (strokesByFace[stroke.faceIndex] ??= []).add([ | |
| for (final v in worldPts) project(toView(v)), | |
| ]); | |
| } | |
| return [ | |
| for (final i in faceOrderByDepth(rotated)) | |
| PaintedFace( | |
| points: [for (final v in rotated[i]) project(v)], | |
| color: _shadedColor(_faceColors[i], polygonNormal(rotated[i])), | |
| strokes: strokesByFace[i] ?? const [], | |
| ), | |
| ]; | |
| } | |
| /// Varies brightness with the face's orientation for a solid look. Both | |
| /// sides of a face are visible mid-fold, so the absolute value of the dot | |
| /// product keeps the brightness identical regardless of the normal's sign. | |
| Color _shadedColor(Color base, Vec3 normal) { | |
| // Directional light from the upper-left front (direction roughly | |
| // normalized). | |
| const light = Vec3(-0.3, -0.5, -0.8); | |
| final brightness = normal.dot(light).abs(); | |
| return Color.lerp(Colors.black, base, 0.6 + 0.4 * brightness)!; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| const l10n = _L10n(); | |
| // Everything gathers on the island: main = the 3D view (drag to orbit), | |
| // controls = the island's primary groups (shape chips + mode-dependent | |
| // controls), playback = the fold bar. | |
| return Scaffold( | |
| body: ExperienceLayout( | |
| island: true, | |
| islandMainBuilder: (context, usable, avoid) => | |
| _islandMain(context, usable, avoid), | |
| primaryGroups: _primaryGroups(context, l10n), | |
| playback: _foldControls(), | |
| ), | |
| ); | |
| } | |
| /// The main display: the 3D view (drag to orbit + NetFoldingPainter), | |
| /// shifted to the opposite side of the island's avoid Rect. Padding is | |
| /// applied only when avoid is a vertical band (landscape); avoid==null | |
| /// (portrait) passes through centered. | |
| Widget _islandMain(BuildContext context, BoxConstraints usable, Rect? avoid) { | |
| final w = usable.maxWidth; | |
| final h = usable.maxHeight; | |
| var insetLeft = 0.0; | |
| var insetRight = 0.0; | |
| if (avoid != null) { | |
| final fullHeight = avoid.top <= 1 && avoid.bottom >= h - 1; | |
| final fullWidth = avoid.left <= 1 && avoid.right >= w - 1; | |
| // Landscape: the island is a vertical band → push the solid toward the | |
| // opposite side. Portrait passes through with avoid=null, keeping the | |
| // solid centered in the main area. | |
| if (fullHeight && !fullWidth) { | |
| if (avoid.left > 1) { | |
| insetRight = w - avoid.left; | |
| } else { | |
| insetLeft = avoid.right; | |
| } | |
| } | |
| } | |
| return Padding( | |
| padding: EdgeInsets.only(left: insetLeft, right: insetRight), | |
| // Draw-mode hit tests need the paint area's size. Grab it with a | |
| // LayoutBuilder and align local coordinates to the size/2 origin (same | |
| // as the painter) before casting rays. | |
| child: LayoutBuilder( | |
| builder: (context, constraints) { | |
| final size = constraints.biggest; | |
| return GestureDetector( | |
| behavior: HitTestBehavior.opaque, | |
| onPanStart: (d) => _onMainPanStart(d, size), | |
| onPanUpdate: (d) => _onMainPanUpdate(d, size), | |
| child: CustomPaint( | |
| painter: NetFoldingPainter( | |
| faces: _buildFaces(), | |
| // Crease/edge color comes from the theme so it stays visible | |
| // on dark backgrounds. | |
| edgeColor: Theme.of(context).colorScheme.onSurfaceVariant, | |
| penColor: _penColor, | |
| ), | |
| size: Size.infinite, | |
| ), | |
| ); | |
| }, | |
| ), | |
| ); | |
| } | |
| /// The primary control groups: ① mode-dependent controls (cube = net | |
| /// stepper / cylinder & cone = two stacked dimension sliders / pyramid = | |
| /// none) on top, ② the shape PuffyChipGrid (solid glyphs; phone portrait = | |
| /// one row, island = 2×2) below. The frequently tapped chips pin to the | |
| /// bottom edge. | |
| List<Widget> _primaryGroups(BuildContext context, _L10n l10n) { | |
| // Mode-dependent settings (net stepper / dimension sliders) change height | |
| // per shape, so they go on top with the shape chips below. The island | |
| // anchors at the bottom and grows upward, so the chips at the bottom edge | |
| // never move when the shape changes — the most-tapped controls stay in | |
| // one place. | |
| return [ | |
| // Every shape shows the rotate/draw toggle and the clear button. Curved | |
| // solids (cylinder/cone) can only be drawn on in the flat t=0 state, so | |
| // "Draw" shows as disabled while t≠0 (handled by the toggle). | |
| _drawControls(l10n), | |
| const SizedBox(height: 8), | |
| _modeControls(l10n), | |
| // Spacing between the mode-dependent settings and the shape chips. | |
| const SizedBox(height: 8), | |
| PuffyChipGrid<_ShapeMode>( | |
| value: _mode, | |
| onChanged: _setMode, | |
| // Phone portrait sits at the bottom with plenty of width, so pack the | |
| // shapes into one row (4 columns) to save height. The narrow floating | |
| // island on tablets/landscape keeps the usual 2×2. | |
| columns: context.isCompactUi ? 4 : 2, | |
| options: [ | |
| PuffyChipOption( | |
| value: _ShapeMode.cube, | |
| iconBuilder: glyphBuilder(NetFoldingGlyph.cube), | |
| label: l10n.netFoldingCube, | |
| ), | |
| PuffyChipOption( | |
| value: _ShapeMode.tetra, | |
| iconBuilder: glyphBuilder(NetFoldingGlyph.tetra), | |
| label: l10n.netFoldingTetra, | |
| ), | |
| PuffyChipOption( | |
| value: _ShapeMode.cylinder, | |
| iconBuilder: glyphBuilder(NetFoldingGlyph.cylinder), | |
| label: l10n.netFoldingCylinder, | |
| ), | |
| PuffyChipOption( | |
| value: _ShapeMode.cone, | |
| iconBuilder: glyphBuilder(NetFoldingGlyph.cone), | |
| label: l10n.netFoldingCone, | |
| ), | |
| ], | |
| ), | |
| ]; | |
| } | |
| /// The rotate/draw mode toggle plus the "Clear all" button in draw mode. | |
| /// While a curved solid has t≠0, "Draw" shows as disabled to signal that | |
| /// drawing only works in the flat state. | |
| Widget _drawControls(_L10n l10n) { | |
| return Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| PuffyToggle<_Interaction>( | |
| value: _interaction, | |
| onChanged: _setInteraction, | |
| // Disable "Draw" (dimmed, taps ignored) on curved solids at t≠0. | |
| // Cube and pyramid keep it always enabled. | |
| isEnabled: (v) => v == _Interaction.draw ? _canDrawNow : true, | |
| options: [ | |
| PuffyToggleOption( | |
| value: _Interaction.rotate, | |
| label: l10n.netFoldingRotateMode, | |
| ), | |
| PuffyToggleOption( | |
| value: _Interaction.draw, | |
| label: l10n.netFoldingDrawMode, | |
| ), | |
| ], | |
| ), | |
| if (_interaction == _Interaction.draw) ...[ | |
| const SizedBox(height: 8), | |
| PuffyButton( | |
| // Pressing with nothing drawn is meaningless, so disable. | |
| onPressed: _drawing.isEmpty ? null : _clearDrawing, | |
| child: Row( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| const Icon(Icons.delete_outline), | |
| const SizedBox(width: 8), | |
| Text(l10n.netFoldingClearDrawing), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ], | |
| ); | |
| } | |
| /// The controls for the current mode, spanning the island width. | |
| Widget _modeControls(_L10n l10n) { | |
| switch (_mode) { | |
| case _ShapeMode.cube: | |
| // The net stepper, stretched to the island width. | |
| return Padding( | |
| padding: const EdgeInsets.only(top: 12), | |
| child: PuffyStepper( | |
| display: | |
| '${_patternIndex + 1} / ${_patterns.length}' | |
| ' ${_patterns[_patternIndex].name}', | |
| onPrevious: () => _stepPattern(-1), | |
| onNext: () => _stepPattern(1), | |
| ), | |
| ); | |
| case _ShapeMode.tetra: | |
| // The pyramid has fixed dimensions: no controls (empty element). | |
| return const SizedBox.shrink(); | |
| case _ShapeMode.cylinder: | |
| return _DimensionSliders( | |
| topLabel: l10n.netFoldingBaseRadius, | |
| topValue: _cylinderRadius, | |
| topMin: 0.5, | |
| topMax: 1.4, | |
| onTopChanged: _setCylinderRadius, | |
| bottomLabel: l10n.netFoldingHeight, | |
| bottomValue: _cylinderHeight, | |
| bottomMin: 1.0, | |
| bottomMax: 3.0, | |
| onBottomChanged: _setCylinderHeight, | |
| ); | |
| case _ShapeMode.cone: | |
| return _DimensionSliders( | |
| topLabel: l10n.netFoldingBaseRadius, | |
| topValue: _coneRadius, | |
| topMin: 0.5, | |
| topMax: 1.6, | |
| onTopChanged: _setConeRadius, | |
| bottomLabel: l10n.netFoldingSlant, | |
| bottomValue: _coneSlant, | |
| bottomMin: 1.2, | |
| bottomMax: 3.2, | |
| onBottomChanged: _setConeSlant, | |
| ); | |
| } | |
| } | |
| /// The fold controls (playback bar). A full-width Row; the island side | |
| /// leaves room for it, so no self-managed bottom pinning or padding. | |
| Widget _foldControls() { | |
| return _FoldControls( | |
| t: _foldController.value, | |
| onChanged: (value) { | |
| // Assigning value stops any running animation and hands control to | |
| // manual input. | |
| _foldController.value = value; | |
| }, | |
| onUnfold: () => _foldController.animateTo(0, curve: Curves.easeInOut), | |
| onFold: () => _foldController.animateTo(1, curve: Curves.easeInOut), | |
| ); | |
| } | |
| } | |
| /// Two dimension sliders for the cylinder/cone, stacked to the island width. | |
| /// A side-by-side Row wouldn't fit the island width (~300), so they stack. | |
| /// Top = base radius, bottom = height/slant. | |
| class _DimensionSliders extends StatelessWidget { | |
| const _DimensionSliders({ | |
| required this.topLabel, | |
| required this.topValue, | |
| required this.topMin, | |
| required this.topMax, | |
| required this.onTopChanged, | |
| required this.bottomLabel, | |
| required this.bottomValue, | |
| required this.bottomMin, | |
| required this.bottomMax, | |
| required this.onBottomChanged, | |
| }); | |
| final String topLabel; | |
| final double topValue; | |
| final double topMin; | |
| final double topMax; | |
| final ValueChanged<double> onTopChanged; | |
| final String bottomLabel; | |
| final double bottomValue; | |
| final double bottomMin; | |
| final double bottomMax; | |
| final ValueChanged<double> onBottomChanged; | |
| @override | |
| Widget build(BuildContext context) { | |
| final textTheme = Theme.of(context).textTheme; | |
| return Padding( | |
| padding: const EdgeInsets.only(top: 12), | |
| child: Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| _LabeledSlider( | |
| label: topLabel, | |
| value: topValue, | |
| min: topMin, | |
| max: topMax, | |
| onChanged: onTopChanged, | |
| textTheme: textTheme, | |
| ), | |
| const SizedBox(height: 8), | |
| _LabeledSlider( | |
| label: bottomLabel, | |
| value: bottomValue, | |
| min: bottomMin, | |
| max: bottomMax, | |
| onChanged: onBottomChanged, | |
| textTheme: textTheme, | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| /// A labeled horizontal slider; a small helper for packing the cone's | |
| /// dimension controls. | |
| class _LabeledSlider extends StatelessWidget { | |
| const _LabeledSlider({ | |
| required this.label, | |
| required this.value, | |
| required this.min, | |
| required this.max, | |
| required this.onChanged, | |
| required this.textTheme, | |
| }); | |
| final String label; | |
| final double value; | |
| final double min; | |
| final double max; | |
| final ValueChanged<double> onChanged; | |
| final TextTheme textTheme; | |
| @override | |
| Widget build(BuildContext context) { | |
| return Column( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| Text(label, style: textTheme.bodySmall), | |
| PuffySlider( | |
| value: value.clamp(min, max), | |
| min: min, | |
| max: max, | |
| onChanged: onChanged, | |
| ), | |
| ], | |
| ); | |
| } | |
| } | |
| /// The fold-amount controls: a slider plus "Unfold" / "Fold up" buttons. | |
| class _FoldControls extends StatelessWidget { | |
| const _FoldControls({ | |
| required this.t, | |
| required this.onChanged, | |
| required this.onUnfold, | |
| required this.onFold, | |
| }); | |
| final double t; | |
| final ValueChanged<double> onChanged; | |
| final VoidCallback onUnfold; | |
| final VoidCallback onFold; | |
| @override | |
| Widget build(BuildContext context) { | |
| const l10n = _L10n(); | |
| return Padding( | |
| padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), | |
| child: Row( | |
| children: [ | |
| PuffyButton( | |
| onPressed: onUnfold, | |
| child: Row( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| const Icon(Icons.crop_square), | |
| const SizedBox(width: 8), | |
| Text(l10n.netFoldingUnfold), | |
| ], | |
| ), | |
| ), | |
| Expanded( | |
| // Side padding keeps the slider from crowding the buttons. | |
| child: Padding( | |
| padding: const EdgeInsets.symmetric(horizontal: 8), | |
| child: PuffySlider(value: t, onChanged: onChanged), | |
| ), | |
| ), | |
| PuffyButton( | |
| onPressed: onFold, | |
| child: Row( | |
| mainAxisSize: MainAxisSize.min, | |
| children: [ | |
| const Icon(Icons.view_in_ar), | |
| const SizedBox(width: 8), | |
| Text(l10n.netFoldingFold), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment