Files
BadNote/lib/editor/engine/shape_geometry.dart

139 lines
5.5 KiB
Dart
Raw Normal View History

// lib/editor/engine/shape_geometry.dart
//
// Pure geometry for the SHAPE tool. Each shape is generated as a list of
// NORMALIZED [PenPoint]s (the same model freehand strokes use), so a shape is
// just a [PenStroke] — it reuses stroke rendering, persistence, erase, and undo
// with NO new model or storage. Points carry a constant pressure (1.0) so the
// brush renders them at a steady width (shapes don't taper with pressure).
//
// All inputs/outputs are in normalized page coordinates ([0,1] x [0,1]); the
// caller wraps the points in a PenStroke with the current brush color/width.
import 'dart:math' as math;
import '../canvas/editor_tool.dart';
import '../canvas/pen_stroke.dart';
/// Number of points sampled around an ellipse. Kept as a const so tests can pin
/// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the
/// last point repeats the first ⇒ [kEllipseSamples] + 1 total points.
const int kEllipseSamples = 48;
/// Constant pressure baked into every shape point so the brush renders a steady
/// width (no pressure taper for geometric shapes).
const double _kShapePressure = 1.0;
/// Generate the normalized polyline for [kind] spanning [start] → [end].
///
/// * [ShapeKind.line] → 2 points.
/// * [ShapeKind.rectangle] → 5 points (closed: 4 corners + repeat of the
/// first), an axis-aligned box whose opposite corners are [start]/[end].
/// * [ShapeKind.ellipse] → [kEllipseSamples] + 1 points (closed), inscribed
/// in the [start]→[end] bounding box.
/// * [ShapeKind.arrow] → shaft (start → end) + two arrowhead segments,
/// emitted as a single polyline so it renders as one stroke.
List<PenPoint> generateShapePoints(ShapeKind kind, PenPoint start, PenPoint end) {
switch (kind) {
case ShapeKind.line:
return [
PenPoint(start.x, start.y, _kShapePressure),
PenPoint(end.x, end.y, _kShapePressure),
];
case ShapeKind.rectangle:
final l = math.min(start.x, end.x);
final r = math.max(start.x, end.x);
final t = math.min(start.y, end.y);
final b = math.max(start.y, end.y);
return [
PenPoint(l, t, _kShapePressure),
PenPoint(r, t, _kShapePressure),
PenPoint(r, b, _kShapePressure),
PenPoint(l, b, _kShapePressure),
PenPoint(l, t, _kShapePressure), // close
];
case ShapeKind.ellipse:
final cx = (start.x + end.x) / 2;
final cy = (start.y + end.y) / 2;
final rx = (end.x - start.x).abs() / 2;
final ry = (end.y - start.y).abs() / 2;
final pts = <PenPoint>[];
for (var i = 0; i <= kEllipseSamples; i++) {
final a = (i / kEllipseSamples) * 2 * math.pi;
pts.add(PenPoint(
cx + rx * math.cos(a),
cy + ry * math.sin(a),
_kShapePressure,
));
}
return pts;
case ShapeKind.arrow:
// Shaft start→end, then back up the shaft to draw the two head barbs so
// the whole arrow is one continuous polyline (no pen lifts).
final dx = end.x - start.x;
final dy = end.y - start.y;
final len = math.sqrt(dx * dx + dy * dy);
final pts = <PenPoint>[
PenPoint(start.x, start.y, _kShapePressure),
PenPoint(end.x, end.y, _kShapePressure),
];
if (len <= 1e-6) return pts; // degenerate: just the (near-zero) shaft
// Arrowhead: barbs at ±[_kArrowAngle] from the reversed shaft direction,
// [_kArrowHead] of the shaft length (capped) long.
final ang = math.atan2(dy, dx);
final head = math.min(len * _kArrowHeadFraction, _kArrowHeadMax);
for (final sign in const [1.0, -1.0]) {
final a = ang + math.pi + sign * _kArrowAngle;
pts.add(PenPoint(
end.x + head * math.cos(a),
end.y + head * math.sin(a),
_kShapePressure,
));
pts.add(PenPoint(end.x, end.y, _kShapePressure)); // back to the tip
}
return pts;
}
}
/// Arrowhead barb length as a fraction of the shaft length.
const double _kArrowHeadFraction = 0.25;
/// Hard cap on the barb length (normalized) so a long arrow's head stays sane.
const double _kArrowHeadMax = 0.06;
/// Half-angle of the arrowhead barbs from the shaft (radians ≈ 28°).
const double _kArrowAngle = 0.5;
/// Return a copy of [points] translated by ([dx],[dy]) in normalized coords,
/// preserving pressure/tilt. Used by the SELECT tool to drag a stroke.
List<PenPoint> translatePoints(List<PenPoint> points, double dx, double dy) =>
[for (final p in points) PenPoint(p.x + dx, p.y + dy, p.pressure, tilt: p.tilt)];
/// A translated copy of [stroke] (its points shifted by [dx],[dy]); color,
/// width, kind, and brush are preserved.
PenStroke translateStroke(PenStroke stroke, double dx, double dy) => PenStroke(
points: translatePoints(stroke.points, dx, dy),
color: stroke.color,
width: stroke.width,
kind: stroke.kind,
brush: stroke.brush,
);
/// Tight normalized bounds of [stroke]'s points, or null when it has no points.
/// Used by the SELECT tool to draw the selection bounding box.
({double left, double top, double right, double bottom})? penStrokeBounds(
PenStroke stroke) {
if (stroke.points.isEmpty) return null;
var l = double.infinity, t = double.infinity;
var r = double.negativeInfinity, b = double.negativeInfinity;
for (final p in stroke.points) {
if (p.x < l) l = p.x;
if (p.y < t) t = p.y;
if (p.x > r) r = p.x;
if (p.y > b) b = p.y;
}
return (left: l, top: t, right: r, bottom: b);
}