Skip to content

Instantly share code, notes, and snippets.

@chooyan-eng
Last active July 15, 2026 13:41
Show Gist options
  • Select an option

  • Save chooyan-eng/6c461cc98aedaf806259b6e8e6c00ed0 to your computer and use it in GitHub Desktop.

Select an option

Save chooyan-eng/6c461cc98aedaf806259b6e8e6c00ed0 to your computer and use it in GitHub Desktop.
main.dart
// "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