Version: 1.0 Date: 2025-11-08 Status: Active Related Documents: Event Schema Reference | Data Snapshot ERD | System Structure and Data Model
This document provides the authoritative specification for WireTuner's immutable vector domain model. The vector model represents the in-memory document structure that is reconstructed from events during application runtime.
Key Design Principles:
- Immutability: All domain objects are immutable; modifications create new instances using
copyWithmethods - Event Sourcing Integration: Model structures align with event payloads for deterministic reconstruction
- Freezed Code Generation: Core aggregates leverage Freezed for automatic immutability, equality, and JSON serialization
- Snapshot Compatibility: All entities support JSON serialization for snapshot persistence (gzip-compressed, stored every 1000 events)
- Rendering Order Determinism: Layers and objects maintain explicit ordering for consistent visual output
- Universal Concepts
- Core Aggregates
- Geometry Primitives
- Visual Properties
- UI State
- Data Model Diagram
- JSON Serialization Examples
- Cross-References
All domain objects follow the immutability pattern enforced by Dart's @immutable annotation or Freezed code generation. Modifications always create new instances rather than mutating existing objects.
Standard copyWith Pattern:
// Modify a path by adding an anchor
final newAnchors = [...path.anchors, newAnchor];
final updatedPath = path.copyWith(anchors: newAnchors);Nullable Field Handling:
For nullable fields (e.g., handleIn, handleOut on AnchorPoint), use the function wrapper pattern to distinguish between "don't change" and "set to null":
// Set handleIn to null (remove handle)
final updated = anchor.copyWith(
handleIn: () => null,
);
// Leave handleIn unchanged, update position
final moved = anchor.copyWith(
position: Point(x: 20, y: 20),
);World Coordinates: All model positions (Point.x, Point.y) are in world-space units (typically pixels at 100% zoom). The origin (0, 0) is at the top-left of the infinite canvas.
Screen Coordinates: Viewport transformations (Viewport.toScreen, Viewport.toWorld) convert between world space and screen pixel coordinates, accounting for pan and zoom.
Relative Offsets: Anchor handles (handleIn, handleOut) are stored as relative offsets from the anchor's position, not absolute coordinates.
All object IDs (Document.id, Layer.id, VectorObject.id, etc.) use UUIDv4 format as defined in RFC 4122. This ensures globally unique identifiers across documents and sessions.
Example: 550e8400-e29b-41d4-a716-446655440000
Requirement: IDs in event payloads and model instances must match exactly to enable event replay and snapshot reconstruction.
The root aggregate representing an entire vector document. Documents contain layers, manage global selection state, and persist viewport settings.
Location: lib/domain/document/document.dart
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
id |
string (UUID) | Yes | Immutable | Unique identifier for this document; matches documentId in event envelopes |
title |
string | Yes | Immutable | Display name for the document; shown in UI and file system |
schemaVersion |
integer | Yes | Immutable | Schema version for serialization migrations; current value: kDocumentSchemaVersion = 1 |
layers |
List<Layer> | Yes | Immutable | Ordered list of layers; rendered bottom-to-top (first layer is bottom-most) |
selection |
Selection | Yes | Immutable | Current selection state (selected objects and anchor indices) |
viewport |
Viewport | Yes | Immutable | Current viewport state (pan, zoom, canvas size) |
Layers are rendered in array order: layers[0] is the bottom-most layer, layers[n-1] is the top-most. Within each layer, objects render in their array order. This deterministic ordering ensures consistent visual output during replay.
getAllObjects()- Returns all VectorObjects from all layers in rendering order (bottom-to-top)getObjectById(id)- Finds an object by ID across all layers; returns null if not foundgetLayerContainingObject(id)- Returns the layer containing the specified object IDobjectsAtPoint(point)- Returns objects at given point in reverse rendering order (top-most first)objectsInBounds(bounds)- Returns objects whose bounds intersect the given rectanglegetSelectedObjects()- Returns all selected objects in rendering order
Documents serialize to JSON snapshots using Freezed's automatic JSON serialization. The schemaVersion field enables future migrations when the document structure evolves.
Snapshot Cadence: Every 1000 events (see Event Schema - Snapshot Policy)
Storage Format: gzip-compressed JSON BLOB in SQLite snapshots table
// Update document title
final renamed = doc.copyWith(title: 'New Title');
// Add a layer
final withNewLayer = doc.copyWith(
layers: [...doc.layers, newLayer],
);
// Update selection
final selected = doc.copyWith(
selection: doc.selection.addObject('path-123'),
);A layer is a collection of vector objects with visibility and locking properties. Layers provide organizational structure for complex documents.
Location: lib/domain/document/document.dart (co-located with Document)
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
id |
string (UUID) | Yes | Immutable | Unique identifier for this layer |
name |
string | Yes | Immutable | Display name shown in layers panel; defaults to 'Layer' |
visible |
boolean | Yes | Immutable | Whether layer is rendered in viewport; defaults to true |
locked |
boolean | Yes | Immutable | Whether layer is locked for editing; locked layers cannot be selected or modified; defaults to false |
objects |
List<VectorObject> | Yes | Immutable | Ordered list of vector objects; first object is bottom-most within the layer |
Visibility: Invisible layers (visible: false) are not rendered but remain in the document. They are excluded from hit-testing and viewport culling.
Locking: Locked layers (locked: true) are visible but cannot be selected or modified. They are excluded from Document.objectsAtPoint() queries.
allObjects- Returns all objects in rendering orderfindById(objectId)- Finds an object by ID; returns null if not foundobjectsAtPoint(point)- Returns objects that intersect the point (in reverse order, top-most first)objectsInBounds(bounds)- Returns objects whose bounds intersect the rectangle
// Toggle visibility
final hidden = layer.copyWith(visible: false);
// Rename layer
final renamed = layer.copyWith(name: 'Background');
// Add an object
final withObject = layer.copyWith(
objects: [...layer.objects, newPath],
);A discriminated union representing either a Path or a Shape. VectorObject enables polymorphic storage while maintaining type safety and immutability.
Location: lib/domain/document/document.dart
Uses Freezed's union types instead of class inheritance to:
- Maintain full immutability (no mutable base class state)
- Enable exhaustive pattern matching
- Simplify JSON serialization
- Avoid vtable overhead
PathObject:
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Unique identifier for this object |
path |
Path | The path data (anchors, segments, closed flag) |
ShapeObject:
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Unique identifier for this object |
shape |
Shape | The parametric shape data (kind, center, dimensions) |
// Extract bounds using pattern matching
final bounds = vectorObj.when(
path: (id, path) => path.bounds(),
shape: (id, shape) => shape.toPath().bounds(),
);
// Type-specific operations
vectorObj.map(
path: (pathObj) => print('Path has ${pathObj.path.anchors.length} anchors'),
shape: (shapeObj) => print('Shape is ${shapeObj.shape.kind}'),
);getBounds()- Returns bounding rectangle (delegates to path/shape)hitTest(point)- Returns true if point is within object bounds (simple bounds-based test)
Freezed automatically includes a discriminator field (runtimeType) to distinguish between PathObject and ShapeObject during deserialization.
An immutable vector path composed of anchor points and segments. Paths are the fundamental curve primitive in WireTuner.
Location: lib/domain/models/path.dart
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
anchors |
List<AnchorPoint> | Yes | Immutable | Ordered list of anchor points defining positions and control points |
segments |
List<Segment> | Yes | Immutable | Ordered list of segments connecting anchors (by index); typically anchors.length - 1 for open paths |
closed |
boolean | Yes | Immutable | Whether the path loops back to the start; closed paths have an implicit segment from last anchor to first; defaults to false |
- Anchor Ownership: Paths own the authoritative list of AnchorPoint objects; segments reference anchors by index
- Implicit Closing Segment: For closed paths with n anchors, there are effectively n segments (n-1 explicit + 1 implicit from last to first)
- Index Validity: Segment indices (
startAnchorIndex,endAnchorIndex) must be valid indices into theanchorslist (not validated at construction, but enforced during geometric operations)
bounds()- Returns control point bounding rectangle (includes all anchor positions and handle absolute positions)length()- Returns total arc length (approximated for Bezier segments using 10 subdivisions)pointAt(t)- Returns point at normalized parameter t ∈ [0, 1] along the path
Anchor handles (handleIn, handleOut) are stored as relative offsets from the anchor position, not absolute canvas coordinates. Geometric calculations must convert to absolute positions:
final anchor = path.anchors[0];
final handleOutAbsolute = anchor.position + anchor.handleOut!;// Add an anchor
final withAnchor = path.copyWith(
anchors: [...path.anchors, newAnchor],
segments: [...path.segments, Segment.line(startIndex: path.anchors.length - 1, endIndex: path.anchors.length)],
);
// Close the path
final closed = path.copyWith(closed: true);Path.empty()- Creates an empty path with no anchors or segmentsPath.fromAnchors(anchors, closed)- Creates a path with automatic line segments connecting consecutive anchorsPath.line(start, end)- Creates a simple two-point line path
An immutable parametric shape (rectangle, ellipse, polygon, or star) defined by geometric parameters rather than explicit anchor points.
Location: lib/domain/models/shape.dart
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
center |
Point | Yes | Immutable | Center point of the shape in world coordinates |
kind |
ShapeKind | Yes | Immutable | Type of shape: rectangle, ellipse, polygon, or star |
width |
double? | Conditional | Immutable | Width for rectangle/ellipse; required for kind: rectangle or ellipse |
height |
double? | Conditional | Immutable | Height for rectangle/ellipse; required for kind: rectangle or ellipse |
cornerRadius |
double | No | Immutable | Corner radius for rectangles (0 = sharp corners); defaults to 0 |
radius |
double? | Conditional | Immutable | Radius for polygon/star outer points; required for kind: polygon or star |
innerRadius |
double? | Conditional | Immutable | Inner radius for stars; required for kind: star |
sides |
integer | Yes | Immutable | Number of sides (polygon) or points (star); defaults to 5 |
rotation |
double | Yes | Immutable | Rotation angle in radians; defaults to 0 |
rectangle- Rectangle with optional rounded cornersellipse- Ellipse (or circle when width equals height)polygon- Regular polygon with configurable number of sides (minimum 3)star- Star shape with inner and outer radii
Shapes are stored in parametric form to enable intuitive editing:
- Rectangle width/height can be resized without recreating all anchors
- Star point count can be adjusted dynamically
- Ellipses maintain perfect circular symmetry
Conversion: Call toPath() to generate the explicit Path representation for rendering or geometric operations.
- Positive Dimensions:
width,height,radius,innerRadiusmust be positive - Corner Radius Bounds: For rectangles,
cornerRadius ≤ min(width/2, height/2) - Star Radii Ordering: For stars,
innerRadius < outerRadius - Minimum Sides: Polygons and stars require
sides ≥ 3
final shape = Shape.rectangle(
center: Point(x: 100, y: 100),
width: 200,
height: 150,
cornerRadius: 10,
);
// Convert to path for rendering
final path = shape.toPath();
// Returns a closed path with 8 anchors (rounded corners use Bezier curves)Shape.rectangle(center, width, height, cornerRadius, rotation)- Creates a rectangleShape.ellipse(center, width, height, rotation)- Creates an ellipseShape.polygon(center, radius, sides, rotation)- Creates a regular polygonShape.star(center, outerRadius, innerRadius, pointCount, rotation)- Creates a star
An immutable segment connecting two anchor points by index. Segments define how anchors are connected (line or Bezier curve).
Location: lib/domain/models/segment.dart
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
startAnchorIndex |
integer | Yes | Immutable | Index of the start anchor in the path's anchors list |
endAnchorIndex |
integer | Yes | Immutable | Index of the end anchor in the path's anchors list |
segmentType |
SegmentType | Yes | Immutable | Type of connection: line, bezier, or arc (arc is a future enhancement) |
line- Straight line segment (ignores control points)bezier- Cubic Bezier curve usingstartAnchor.handleOutandendAnchor.handleInas control pointsarc- Circular arc segment (placeholder for future implementation)
Segments store anchor indices (not AnchorPoint references) to:
- Avoid circular dependencies
- Simplify immutability (no need to update segment references when anchors change)
- Enable efficient anchor list mutations (add/remove anchors by index)
For Bezier segments, the actual control points come from the anchors' handles:
- Control Point 1:
anchors[startAnchorIndex].handleOut(absolute position:anchor.position + handleOut) - Control Point 2:
anchors[endAnchorIndex].handleIn(absolute position:anchor.position + handleIn)
If handles are null, the segment degrades to a straight line.
Segment.line(startIndex, endIndex)- Creates a straight line segmentSegment.bezier(startIndex, endIndex)- Creates a Bezier curve segment
An immutable anchor point in a vector path, defining a vertex position and optional Bezier control point handles.
Location: lib/domain/models/anchor_point.dart
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
position |
Point | Yes | Immutable | Anchor position in world coordinates |
handleIn |
Point? | No | Immutable | Incoming Bezier control point (BCP) as a relative offset from position; null indicates no incoming curve control |
handleOut |
Point? | No | Immutable | Outgoing Bezier control point (BCP) as a relative offset from position; null indicates no outgoing curve control |
anchorType |
AnchorType | Yes | Immutable | Type defining handle behavior: corner, smooth, or symmetric; defaults to corner |
corner- Handles are independent (or absent); allows sharp corners or asymmetric curvessmooth- Handles are mirrored (same angle, same magnitude); creates smooth flowing curvessymmetric- Handles are collinear (opposite angles, different lengths); creates smooth curves with different curvature on each side
Handles are stored as relative offsets from the anchor position, not absolute canvas coordinates. This design choice:
- Simplifies anchor translation (moving an anchor doesn't require updating handle positions)
- Matches common vector editing tools (Illustrator, Figma)
- Aligns with Bezier curve mathematics (control points relative to endpoints)
Example:
final anchor = AnchorPoint(
position: Point(x: 100, y: 100),
handleOut: Point(x: 50, y: 0), // 50 units to the right
);
// Absolute position of handleOut: (150, 100)For anchorType: smooth, editing one handle automatically mirrors the other:
- If
handleOutis set toPoint(x: 20, y: 10), thenhandleInbecomesPoint(x: -20, y: -10) - This constraint is enforced by the
AnchorPoint.smooth()factory and must be maintained when usingcopyWith
To explicitly set a handle to null (remove it), use the function wrapper pattern:
// Remove handleIn (set to null)
final updated = anchor.copyWith(
handleIn: () => null,
);
// Update position without changing handles
final moved = anchor.copyWith(
position: Point(x: 60, y: 60),
);AnchorPoint.corner(position)- Creates a sharp corner anchor with no handlesAnchorPoint.smooth(position, handleOut)- Creates a smooth anchor with symmetric handles (automatically setshandleIn = -handleOut)
Represents visual styling properties for vector objects (fill, stroke, opacity). While a dedicated Style class is planned, current implementations use event payloads and inline properties.
Status: Partial implementation (properties defined in ModifyStyleEvent)
Location: lib/domain/events/style_events.dart (event definitions)
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
fillColor |
string? | No | Immutable | Fill color as hex string (e.g., "#FF5733"); null indicates no fill |
strokeColor |
string? | No | Immutable | Stroke color as hex string (e.g., "#000000"); null indicates no stroke |
strokeWidth |
double? | No | Immutable | Stroke width in pixels; must be ≥ 0; null uses default (1.0) |
opacity |
double? | No | Immutable | Opacity value in range [0.0, 1.0]; 0.0 = fully transparent, 1.0 = fully opaque; null uses default (1.0) |
blendMode |
BlendMode? | No | Immutable | Blend mode for compositing (future enhancement) |
Style modifications are captured by ModifyStyleEvent, which includes all style fields as optional properties. This enables partial updates (e.g., change only fillColor without affecting strokeWidth).
A dedicated Style class will be implemented in Iteration 3 (I3) to:
- Store style properties directly on VectorObject instances
- Enable style presets and reusable style definitions
- Support advanced properties (gradients, patterns, blend modes)
An immutable 2D affine transformation wrapping a Matrix4 for translate, rotate, scale, and skew operations.
Location: lib/domain/models/transform.dart
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
matrix |
Matrix4 | Yes | Immutable | Underlying transformation matrix from vector_math package; stored as a 4×4 matrix for homogeneous coordinates |
- Translate: Offset by (dx, dy)
- Rotate: Rotate by angle in radians (counter-clockwise)
- Scale: Scale by factors (sx, sy)
- Skew: Skew by angles in radians
- Compose: Combine transformations via matrix multiplication
Order matters! t1.compose(t2) applies t1 first, then t2.
Example:
final t1 = Transform.translate(10, 0);
final t2 = Transform.scale(2, 1);
final combined = t1.compose(t2);
// Transforms point (5, 0):
// 1. Translate: (5, 0) → (15, 0)
// 2. Scale: (15, 0) → (30, 0)transformPoint(point)- Applies transformation to a pointtransformRectangle(rect)- Transforms rectangle (returns axis-aligned bounding box of transformed corners)invert()- Returns inverse transformation (or null if not invertible)
translation- Extracts (dx, dy) as a Pointrotation- Extracts rotation angle in radiansscale- Extracts (scaleX, scaleY) as a Point
Transform.identity()- No transformationTransform.translate(dx, dy)- TranslationTransform.rotate(angleInRadians)- Rotation around originTransform.rotateAround(angle, center)- Rotation around a specific pointTransform.scale(sx, sy)- Non-uniform scaleTransform.uniformScale(scale)- Uniform scale (same in x and y)Transform.scaleAround(sx, sy, center)- Scale around a specific pointTransform.skew(angleX, angleY)- Skew transformation
Transforms will be applied to VectorObject instances in Iteration 3 (I3) to support rotation, scaling, and translation of objects independently of their anchor positions.
An immutable selection state tracking selected objects and anchor points. Selection is persisted in snapshots to enable undo/redo of selection changes.
Location: lib/domain/document/selection.dart
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
objectIds |
Set<string> | Yes | Immutable | Set of selected object IDs (UUIDs); using a Set ensures uniqueness and O(1) lookup; defaults to empty set |
anchorIndices |
Map<string, Set<int>> | Yes | Immutable | Map of object ID to selected anchor indices (zero-based); enables direct selection for individual anchor manipulation; defaults to empty map |
The anchorIndices map allows selecting individual anchor points within paths:
{
'path-1': {0, 2, 5}, // Anchors 0, 2, 5 selected in path-1
'path-2': {1}, // Anchor 1 selected in path-2
}isEmpty/isNotEmpty- Whether any objects are selectedselectedCount- Number of selected objectscontains(objectId)- Whether the object is selectedhasSelectedAnchors(objectId)- Whether the object has anchor selectionsgetSelectedAnchors(objectId)- Returns set of selected anchor indices for the object
addObject(id)- Adds object to selectionaddObjects(ids)- Adds multiple objectsremoveObject(id)- Removes object (also clears anchor selections)removeObjects(ids)- Removes multiple objectsselectOnly(id)- Replaces selection with single objectselectOnlyMultiple(ids)- Replaces selection with multiple objectsclear()- Clears all selectionsaddAnchor(objectId, anchorIndex)- Adds anchor to object's selectionaddAnchors(objectId, indices)- Adds multiple anchorsremoveAnchor(objectId, anchorIndex)- Removes anchor from selectionclearAnchors()- Clears all anchor selectionsclearAnchorsForObject(objectId)- Clears anchor selections for specific object
Freezed handles Set and Map serialization automatically, preserving selection state in snapshots.
An immutable viewport state controlling how the document is viewed and providing coordinate transformations between world space and screen space.
Location: lib/domain/document/document.dart (co-located with Document)
| Field Name | Type | Required | Immutability | Description |
|---|---|---|---|---|
pan |
Point | Yes | Immutable | Pan offset in world coordinates; represents how much the view has been panned from the origin; defaults to Point(x: 0, y: 0) |
zoom |
double | Yes | Immutable | Zoom level (1.0 = 100%, 2.0 = 200%, 0.5 = 50%); must be positive; typical range is 0.1 to 10.0; defaults to 1.0 |
canvasSize |
Size | Yes | Immutable | Size of the canvas in screen pixels; represents the viewport dimensions; defaults to Size(width: 800, height: 600) |
World to Screen:
Point toScreen(Point worldPoint) {
return Point(
x: (worldPoint.x - pan.x) * zoom + canvasSize.width / 2,
y: (worldPoint.y - pan.y) * zoom + canvasSize.height / 2,
);
}Screen to World:
Point toWorld(Point screenPoint) {
return Point(
x: (screenPoint.x - canvasSize.width / 2) / zoom + pan.x,
y: (screenPoint.y - canvasSize.height / 2) / zoom + pan.y,
);
}// Create viewport
final viewport = Viewport(
pan: Point(x: 0, y: 0),
zoom: 1.0,
canvasSize: Size(width: 1920, height: 1080),
);
// Transform coordinates
final worldPoint = Point(x: 100, y: 200);
final screenPoint = viewport.toScreen(worldPoint);
final backToWorld = viewport.toWorld(screenPoint);
assert(worldPoint == backToWorld);Viewport state changes are captured by ViewportPanEvent and ViewportZoomEvent (sampled at 50ms intervals during continuous navigation).
The following PlantUML diagram illustrates the relationships between domain entities:
@startuml
title WireTuner Vector Model - Domain Entities
' Core aggregates
class Document {
+id : String (UUID)
+title : String
+schemaVersion : int
+layers : List<Layer>
+selection : Selection
+viewport : Viewport
--
+getAllObjects() : List<VectorObject>
+getObjectById(id) : VectorObject?
+objectsAtPoint(point) : List<VectorObject>
}
class Layer {
+id : String (UUID)
+name : String
+visible : bool
+locked : bool
+objects : List<VectorObject>
--
+findById(id) : VectorObject?
+objectsAtPoint(point) : List<VectorObject>
}
' Discriminated union
abstract class VectorObject {
+id : String (UUID)
--
+getBounds() : Rectangle
+hitTest(point) : bool
}
class PathObject {
+id : String (UUID)
+path : Path
}
class ShapeObject {
+id : String (UUID)
+shape : Shape
}
' Geometry primitives
class Path {
+anchors : List<AnchorPoint>
+segments : List<Segment>
+closed : bool
--
+bounds() : Rectangle
+length() : double
+pointAt(t) : Point
}
class Shape {
+center : Point
+kind : ShapeKind
+width : double?
+height : double?
+cornerRadius : double
+radius : double?
+innerRadius : double?
+sides : int
+rotation : double
--
+toPath() : Path
}
enum ShapeKind {
rectangle
ellipse
polygon
star
}
class Segment {
+startAnchorIndex : int
+endAnchorIndex : int
+segmentType : SegmentType
}
enum SegmentType {
line
bezier
arc
}
class AnchorPoint {
+position : Point
+handleIn : Point?
+handleOut : Point?
+anchorType : AnchorType
}
enum AnchorType {
corner
smooth
symmetric
}
' UI state
class Selection {
+objectIds : Set<String>
+anchorIndices : Map<String, Set<int>>
--
+contains(objectId) : bool
+addObject(id) : Selection
+addAnchor(objectId, index) : Selection
}
class Viewport {
+pan : Point
+zoom : double
+canvasSize : Size
--
+toScreen(worldPoint) : Point
+toWorld(screenPoint) : Point
}
class Size {
+width : double
+height : double
}
' Visual properties (future)
class Style <<planned>> {
+fillColor : String?
+strokeColor : String?
+strokeWidth : double?
+opacity : double?
+blendMode : BlendMode?
}
class Transform {
+matrix : Matrix4
--
+compose(other) : Transform
+transformPoint(point) : Point
+invert() : Transform?
}
' Relationships
Document "1" *-- "0..*" Layer : contains >
Layer "1" *-- "0..*" VectorObject : contains >
VectorObject <|-- PathObject
VectorObject <|-- ShapeObject
PathObject "1" *-- "1" Path : references >
ShapeObject "1" *-- "1" Shape : references >
Path "1" *-- "2..*" AnchorPoint : owns >
Path "1" *-- "1..*" Segment : owns >
Segment "1" --> "2" AnchorPoint : references by index
Shape "1" -- "1" ShapeKind : is a
AnchorPoint "1" -- "1" AnchorType : has type
Segment "1" -- "1" SegmentType : has type
Document "1" *-- "1" Selection : tracks >
Document "1" *-- "1" Viewport : views through >
Viewport "1" *-- "1" Size : has canvas size >
note right of VectorObject
Freezed discriminated union.
Pattern match using .when() or .map()
end note
note bottom of AnchorPoint
Handles (handleIn, handleOut) are
stored as relative offsets from
the anchor position, not absolute
coordinates.
end note
note bottom of Segment
Segments reference anchors by index
(not direct references) to simplify
immutability and avoid circular deps.
end note
note right of Path
For closed paths, there is an
implicit segment from the last
anchor to the first anchor.
end note
@endumlDiagram Notes:
- Immutability: All entities are immutable; modifications use
copyWithmethods - Rendering Order: Layers and objects maintain explicit array ordering (bottom-to-top)
- Index-Based References: Segments reference anchors by index, not direct object references
- Relative Handles: Anchor handles are stored as offsets from anchor position
- Union Types: VectorObject uses Freezed's discriminated union pattern (PathObject | ShapeObject)
A simple closed rectangular path with four corner anchors and line segments.
{
"id": "path-rect-001",
"path": {
"anchors": [
{
"position": {"x": 50.0, "y": 50.0},
"handleIn": null,
"handleOut": null,
"anchorType": "corner"
},
{
"position": {"x": 250.0, "y": 50.0},
"handleIn": null,
"handleOut": null,
"anchorType": "corner"
},
{
"position": {"x": 250.0, "y": 150.0},
"handleIn": null,
"handleOut": null,
"anchorType": "corner"
},
{
"position": {"x": 50.0, "y": 150.0},
"handleIn": null,
"handleOut": null,
"anchorType": "corner"
}
],
"segments": [
{
"startAnchorIndex": 0,
"endAnchorIndex": 1,
"segmentType": "line"
},
{
"startAnchorIndex": 1,
"endAnchorIndex": 2,
"segmentType": "line"
},
{
"startAnchorIndex": 2,
"endAnchorIndex": 3,
"segmentType": "line"
}
],
"closed": true
}
}Notes:
- 4 corner anchors with no handles (null
handleIn/handleOut) - 3 explicit line segments + 1 implicit closing segment (from anchor 3 to anchor 0)
closed: truecreates the implicit closing segment- All coordinates in world space
A smooth Bezier curve with two anchors and symmetric handles.
{
"id": "path-curve-002",
"path": {
"anchors": [
{
"position": {"x": 100.0, "y": 100.0},
"handleIn": null,
"handleOut": {"x": 50.0, "y": 0.0},
"anchorType": "smooth"
},
{
"position": {"x": 300.0, "y": 200.0},
"handleIn": {"x": -50.0, "y": 0.0},
"handleOut": null,
"anchorType": "smooth"
}
],
"segments": [
{
"startAnchorIndex": 0,
"endAnchorIndex": 1,
"segmentType": "bezier"
}
],
"closed": false
}
}Notes:
- 2 smooth anchors with Bezier control point handles
- Handles stored as relative offsets: anchor 0's
handleOutabsolute position is (150, 100) - 1 Bezier segment using handles for curve control
closed: false- open path with no implicit closing segment- Control points define a smooth S-curve
Bezier Curve Formula:
P(t) = (1-t)³·P₀ + 3(1-t)²t·P₁ + 3(1-t)t²·P₂ + t³·P₃
Where:
P₀ = anchor[0].position = (100, 100)
P₁ = anchor[0].position + anchor[0].handleOut = (150, 100)
P₂ = anchor[1].position + anchor[1].handleIn = (250, 200)
P₃ = anchor[1].position = (300, 200)
- System Structure and Data Model - Domain Model ERD - Visual representation of entity relationships
- Design Rationale - Decision 1 (Event Sourcing) - Immutability and event reconstruction rationale
- Design Rationale - Decision 3 (SQLite) - Snapshot storage format and schema versioning
- Event Schema Reference - UUID format requirements, event type discriminators, and payload specifications
- Event Schema - Universal Envelope - Document ID and event sequence numbering
- Event Schema - Snapshot Policy - 1000-event snapshot cadence and compression
- Data Snapshot ERD - SQLite persistence schema (metadata, events, snapshots tables)
- Domain Model Diagram - PlantUML diagram of in-memory domain entities
- Document Aggregates:
lib/domain/document/document.dart- Document, Layer, VectorObject, Viewport - Selection:
lib/domain/document/selection.dart- Selection state - Path:
lib/domain/models/path.dart- Path geometry - Shape:
lib/domain/models/shape.dart- Parametric shapes - Segment:
lib/domain/models/segment.dart- Segment definitions - AnchorPoint:
lib/domain/models/anchor_point.dart- Anchor point and handle semantics - Transform:
lib/domain/models/transform.dart- Affine transformations - Style Events:
lib/domain/events/style_events.dart- ModifyStyleEvent payload
Document Maintainer: WireTuner Architecture Team Last Updated: 2025-11-08 Next Review: After completion of I3.T1 (Tool Agent Integration)