detached 65370d5

feat(graphics): add generic graphics scene

commit
65370d536714a4af45f4228629fefd19a3641390
author
brice · · 2026-08-20 14:22 UTC
parent
b222670
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +1526 -0
A graphics/consumer_external_test.go +54 -0
@@ -0,0 +1,54 @@
1+package graphics_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/graphics"
8+)
9+
10+func TestExternalConsumerCanBuildAndReadImmutableScene(t *testing.T) {
11+ scene := graphics.NewScene(graphics.Config{
12+ MaxAssets: 4,
13+ MaxPlacements: 4,
14+ MaxEncodedBytes: 32,
15+ MaxDecodedPixels: 64,
16+ })
17+ data := []byte("encoded")
18+ assetID, err := scene.RegisterAsset(graphics.AssetInput{Encoded: data, Width: 8, Height: 4})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ data[0] = 'X'
23+
24+ placementID, err := scene.AddPlacement(graphics.PlacementSpec{
25+ AssetID: assetID,
26+ Destination: graphics.PixelRect{X: -2, Y: 3, Width: 16, Height: 8},
27+ })
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ snapshot := scene.Snapshot()
32+ asset, ok := snapshot.GetAsset(assetID)
33+ if !ok || string(asset.Bytes()) != "encoded" {
34+ t.Fatalf("asset = %#v, ok=%v", asset, ok)
35+ }
36+ placement, ok := snapshot.GetPlacement(placementID)
37+ if !ok || placement.AssetID() != assetID {
38+ t.Fatalf("placement = %#v, ok=%v", placement, ok)
39+ }
40+ fragments := snapshot.VisiblePixelFragments(graphics.PixelRect{X: 0, Y: 3, Width: 4, Height: 8})
41+ if len(fragments) != 1 || fragments[0].AssetID != assetID || fragments[0].PlacementID != placementID {
42+ t.Fatalf("fragments = %#v", fragments)
43+ }
44+
45+ if err := scene.RemoveAsset(assetID); !errors.Is(err, graphics.ErrAssetInUse) {
46+ t.Fatalf("remove in-use asset error = %v", err)
47+ }
48+ if err := scene.RemoveAssetCascade(assetID); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, ok := snapshot.GetAsset(assetID); !ok {
52+ t.Fatal("immutable snapshot changed after scene mutation")
53+ }
54+}
@@ -0,0 +1,54 @@
1+package graphics_test
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/graphics"
8+)
9+
10+func TestExternalConsumerCanBuildAndReadImmutableScene(t *testing.T) {
11+ scene := graphics.NewScene(graphics.Config{
12+ MaxAssets: 4,
13+ MaxPlacements: 4,
14+ MaxEncodedBytes: 32,
15+ MaxDecodedPixels: 64,
16+ })
17+ data := []byte("encoded")
18+ assetID, err := scene.RegisterAsset(graphics.AssetInput{Encoded: data, Width: 8, Height: 4})
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+ data[0] = 'X'
23+
24+ placementID, err := scene.AddPlacement(graphics.PlacementSpec{
25+ AssetID: assetID,
26+ Destination: graphics.PixelRect{X: -2, Y: 3, Width: 16, Height: 8},
27+ })
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ snapshot := scene.Snapshot()
32+ asset, ok := snapshot.GetAsset(assetID)
33+ if !ok || string(asset.Bytes()) != "encoded" {
34+ t.Fatalf("asset = %#v, ok=%v", asset, ok)
35+ }
36+ placement, ok := snapshot.GetPlacement(placementID)
37+ if !ok || placement.AssetID() != assetID {
38+ t.Fatalf("placement = %#v, ok=%v", placement, ok)
39+ }
40+ fragments := snapshot.VisiblePixelFragments(graphics.PixelRect{X: 0, Y: 3, Width: 4, Height: 8})
41+ if len(fragments) != 1 || fragments[0].AssetID != assetID || fragments[0].PlacementID != placementID {
42+ t.Fatalf("fragments = %#v", fragments)
43+ }
44+
45+ if err := scene.RemoveAsset(assetID); !errors.Is(err, graphics.ErrAssetInUse) {
46+ t.Fatalf("remove in-use asset error = %v", err)
47+ }
48+ if err := scene.RemoveAssetCascade(assetID); err != nil {
49+ t.Fatal(err)
50+ }
51+ if _, ok := snapshot.GetAsset(assetID); !ok {
52+ t.Fatal("immutable snapshot changed after scene mutation")
53+ }
54+}
A graphics/graphics.go +189 -0
@@ -0,0 +1,189 @@
1+// Package graphics provides a renderer-neutral, bounded graphics scene.
2+//
3+// The package stores immutable encoded asset blobs and sparse placements. A
4+// scene is mutated by its owner and publishes immutable snapshots for
5+// consumers. It deliberately knows nothing about terminal protocols,
6+// renderers, image formats, or text state.
7+package graphics
8+
9+import "errors"
10+
11+var (
12+ ErrInvalidRect = errors.New("invalid rectangle")
13+ ErrInvalidAsset = errors.New("invalid asset")
14+ ErrInvalidPlacement = errors.New("invalid placement")
15+ ErrAssetNotFound = errors.New("asset not found")
16+ ErrPlacementNotFound = errors.New("placement not found")
17+ ErrAssetInUse = errors.New("asset is still in use")
18+ ErrTooManyAssets = errors.New("asset quota exceeded")
19+ ErrTooManyPlacements = errors.New("placement quota exceeded")
20+ ErrEncodedBudget = errors.New("encoded byte budget exceeded")
21+ ErrDecodedPixelBudget = errors.New("decoded pixel budget exceeded")
22+ ErrGenerationOverflow = errors.New("generation overflow")
23+ ErrIdentifierOverflow = errors.New("identifier overflow")
24+ ErrInvalidOperation = errors.New("invalid graphics operation")
25+ ErrInvalidSceneLimits = errors.New("invalid scene limits")
26+)
27+
28+// AssetID identifies an asset in a scene. Its representation is deliberately
29+// private: IDs are only meaningful to the scene that issued them and are not
30+// interchangeable with placement IDs.
31+type AssetID struct{ value uint64 }
32+
33+// PlacementID identifies a sparse placement in a scene.
34+type PlacementID struct{ value uint64 }
35+
36+// Valid reports whether the ID was issued by a scene.
37+func (id AssetID) Valid() bool { return id.value != 0 }
38+
39+// Valid reports whether the ID was issued by a scene.
40+func (id PlacementID) Valid() bool { return id.value != 0 }
41+
42+// String returns a stable diagnostic form of the opaque ID.
43+func (id AssetID) String() string { return formatID('a', id.value) }
44+
45+// String returns a stable diagnostic form of the opaque ID.
46+func (id PlacementID) String() string { return formatID('p', id.value) }
47+
48+func formatID(prefix byte, value uint64) string {
49+ if value == 0 {
50+ return "<invalid>"
51+ }
52+ var buf [1 + 20]byte
53+ buf[0] = prefix
54+ i := len(buf)
55+ for value != 0 {
56+ i--
57+ buf[i] = byte('0' + value%10)
58+ value /= 10
59+ }
60+ return string(buf[0:1]) + string(buf[i:])
61+}
62+
63+// Limits bounds live scene resources. Zero-valued limits use the package
64+// defaults; this keeps a zero-value configuration bounded as well.
65+type Limits struct {
66+ MaxAssets uint64
67+ MaxPlacements uint64
68+ MaxEncodedBytes uint64
69+ MaxDecodedPixels uint64
70+ MaxEncodedBytesPerAsset uint64
71+ MaxDecodedPixelsPerAsset uint64
72+}
73+
74+// Config is an alternate name for Limits for callers that prefer constructor
75+// configuration terminology.
76+type Config = Limits
77+
78+// SceneConfig is a descriptive alias for Config.
79+type SceneConfig = Limits
80+
81+// Generation is the monotonically increasing scene generation number.
82+type Generation = uint64
83+
84+// SnapshotRef is an explicit name for an immutable snapshot reference.
85+type SnapshotRef = Snapshot
86+
87+// AssetInput is an alternate name for an asset registration description.
88+type AssetInput = AssetBlob
89+
90+const (
91+ DefaultMaxAssets = uint64(1024)
92+ DefaultMaxPlacements = uint64(65536)
93+ DefaultMaxEncodedBytes = uint64(64 << 20)
94+ DefaultMaxDecodedPixels = uint64(256 << 20)
95+ DefaultMaxEncodedBytesPerAsset = uint64(16 << 20)
96+ DefaultMaxDecodedPixelsPerAsset = uint64(128 << 20)
97+)
98+
99+// AssetBlob is the caller-owned description of one encoded asset. AddAsset
100+// copies Encoded before returning, so later caller mutations cannot alter a
101+// scene or any snapshot. Width and Height describe the decoded pixel extent
102+// used for clipping and quota accounting.
103+type AssetBlob struct {
104+ Encoded []byte
105+ Data []byte
106+ Width, Height int64
107+ DecodedPixels uint64
108+}
109+
110+// AssetSpec is the conventional name for an asset input description.
111+type AssetSpec = AssetBlob
112+
113+// PlacementSpec describes one sparse placement. Source defaults to the full
114+// asset. Destination is a pixel rectangle; Dest is accepted as a concise alias
115+// for callers that use that spelling. Cells optionally records the cell-space
116+// extent associated with the same placement.
117+type PlacementSpec struct {
118+ Asset AssetID
119+ AssetID AssetID
120+ Source PixelRect
121+ Destination PixelRect
122+ Dest PixelRect
123+ Cells CellRect
124+ CellBounds CellRect
125+ Layer int64
126+}
127+
128+// Viewport can constrain visible fragments in pixel space, cell space, or
129+// both. A zero-valued rectangle means that coordinate space is not supplied.
130+type Viewport struct {
131+ Pixels PixelRect
132+ Cells CellRect
133+}
134+
135+// VisibleFragment is the visible part of a placement. Source and Destination
136+// are clipped together, preserving the placement's source-to-destination
137+// mapping even when the destination is scaled. Cells is populated when the
138+// placement and viewport carry cell geometry.
139+type VisibleFragment struct {
140+ PlacementID PlacementID
141+ AssetID AssetID
142+ Source PixelRect
143+ Destination PixelRect
144+ Cells CellRect
145+}
146+
147+// Fragment is a short alias for VisibleFragment.
148+type Fragment = VisibleFragment
149+
150+// Usage reports resources held by the current scene or an immutable snapshot.
151+type Usage struct {
152+ Assets uint64
153+ Placements uint64
154+ EncodedBytes uint64
155+ DecodedPixels uint64
156+}
157+
158+// OperationKind identifies one transactional scene operation.
159+type OperationKind uint8
160+
161+const (
162+ OperationAddAsset OperationKind = iota + 1
163+ OperationRemoveAsset
164+ OperationPlace
165+ OperationUpdatePlacement
166+ OperationRemovePlacement
167+)
168+
169+// Operation is a sparse scene operation. Add-asset operations allocate an
170+// opaque ID internally; use AddAsset directly when the ID is needed by the
171+// caller for a subsequent placement.
172+type Operation struct {
173+ Kind OperationKind
174+ Blob AssetBlob
175+ Asset AssetID
176+ AssetID AssetID
177+ Placement PlacementSpec
178+ PlacementID PlacementID
179+}
180+
181+// Operation aliases make operation construction readable without introducing
182+// protocol-specific terminology.
183+const (
184+ AddAssetOperation = OperationAddAsset
185+ RemoveAssetOperation = OperationRemoveAsset
186+ PlaceOperation = OperationPlace
187+ UpdatePlacementOperation = OperationUpdatePlacement
188+ RemovePlacementOperation = OperationRemovePlacement
189+)
@@ -0,0 +1,189 @@
1+// Package graphics provides a renderer-neutral, bounded graphics scene.
2+//
3+// The package stores immutable encoded asset blobs and sparse placements. A
4+// scene is mutated by its owner and publishes immutable snapshots for
5+// consumers. It deliberately knows nothing about terminal protocols,
6+// renderers, image formats, or text state.
7+package graphics
8+
9+import "errors"
10+
11+var (
12+ ErrInvalidRect = errors.New("invalid rectangle")
13+ ErrInvalidAsset = errors.New("invalid asset")
14+ ErrInvalidPlacement = errors.New("invalid placement")
15+ ErrAssetNotFound = errors.New("asset not found")
16+ ErrPlacementNotFound = errors.New("placement not found")
17+ ErrAssetInUse = errors.New("asset is still in use")
18+ ErrTooManyAssets = errors.New("asset quota exceeded")
19+ ErrTooManyPlacements = errors.New("placement quota exceeded")
20+ ErrEncodedBudget = errors.New("encoded byte budget exceeded")
21+ ErrDecodedPixelBudget = errors.New("decoded pixel budget exceeded")
22+ ErrGenerationOverflow = errors.New("generation overflow")
23+ ErrIdentifierOverflow = errors.New("identifier overflow")
24+ ErrInvalidOperation = errors.New("invalid graphics operation")
25+ ErrInvalidSceneLimits = errors.New("invalid scene limits")
26+)
27+
28+// AssetID identifies an asset in a scene. Its representation is deliberately
29+// private: IDs are only meaningful to the scene that issued them and are not
30+// interchangeable with placement IDs.
31+type AssetID struct{ value uint64 }
32+
33+// PlacementID identifies a sparse placement in a scene.
34+type PlacementID struct{ value uint64 }
35+
36+// Valid reports whether the ID was issued by a scene.
37+func (id AssetID) Valid() bool { return id.value != 0 }
38+
39+// Valid reports whether the ID was issued by a scene.
40+func (id PlacementID) Valid() bool { return id.value != 0 }
41+
42+// String returns a stable diagnostic form of the opaque ID.
43+func (id AssetID) String() string { return formatID('a', id.value) }
44+
45+// String returns a stable diagnostic form of the opaque ID.
46+func (id PlacementID) String() string { return formatID('p', id.value) }
47+
48+func formatID(prefix byte, value uint64) string {
49+ if value == 0 {
50+ return "<invalid>"
51+ }
52+ var buf [1 + 20]byte
53+ buf[0] = prefix
54+ i := len(buf)
55+ for value != 0 {
56+ i--
57+ buf[i] = byte('0' + value%10)
58+ value /= 10
59+ }
60+ return string(buf[0:1]) + string(buf[i:])
61+}
62+
63+// Limits bounds live scene resources. Zero-valued limits use the package
64+// defaults; this keeps a zero-value configuration bounded as well.
65+type Limits struct {
66+ MaxAssets uint64
67+ MaxPlacements uint64
68+ MaxEncodedBytes uint64
69+ MaxDecodedPixels uint64
70+ MaxEncodedBytesPerAsset uint64
71+ MaxDecodedPixelsPerAsset uint64
72+}
73+
74+// Config is an alternate name for Limits for callers that prefer constructor
75+// configuration terminology.
76+type Config = Limits
77+
78+// SceneConfig is a descriptive alias for Config.
79+type SceneConfig = Limits
80+
81+// Generation is the monotonically increasing scene generation number.
82+type Generation = uint64
83+
84+// SnapshotRef is an explicit name for an immutable snapshot reference.
85+type SnapshotRef = Snapshot
86+
87+// AssetInput is an alternate name for an asset registration description.
88+type AssetInput = AssetBlob
89+
90+const (
91+ DefaultMaxAssets = uint64(1024)
92+ DefaultMaxPlacements = uint64(65536)
93+ DefaultMaxEncodedBytes = uint64(64 << 20)
94+ DefaultMaxDecodedPixels = uint64(256 << 20)
95+ DefaultMaxEncodedBytesPerAsset = uint64(16 << 20)
96+ DefaultMaxDecodedPixelsPerAsset = uint64(128 << 20)
97+)
98+
99+// AssetBlob is the caller-owned description of one encoded asset. AddAsset
100+// copies Encoded before returning, so later caller mutations cannot alter a
101+// scene or any snapshot. Width and Height describe the decoded pixel extent
102+// used for clipping and quota accounting.
103+type AssetBlob struct {
104+ Encoded []byte
105+ Data []byte
106+ Width, Height int64
107+ DecodedPixels uint64
108+}
109+
110+// AssetSpec is the conventional name for an asset input description.
111+type AssetSpec = AssetBlob
112+
113+// PlacementSpec describes one sparse placement. Source defaults to the full
114+// asset. Destination is a pixel rectangle; Dest is accepted as a concise alias
115+// for callers that use that spelling. Cells optionally records the cell-space
116+// extent associated with the same placement.
117+type PlacementSpec struct {
118+ Asset AssetID
119+ AssetID AssetID
120+ Source PixelRect
121+ Destination PixelRect
122+ Dest PixelRect
123+ Cells CellRect
124+ CellBounds CellRect
125+ Layer int64
126+}
127+
128+// Viewport can constrain visible fragments in pixel space, cell space, or
129+// both. A zero-valued rectangle means that coordinate space is not supplied.
130+type Viewport struct {
131+ Pixels PixelRect
132+ Cells CellRect
133+}
134+
135+// VisibleFragment is the visible part of a placement. Source and Destination
136+// are clipped together, preserving the placement's source-to-destination
137+// mapping even when the destination is scaled. Cells is populated when the
138+// placement and viewport carry cell geometry.
139+type VisibleFragment struct {
140+ PlacementID PlacementID
141+ AssetID AssetID
142+ Source PixelRect
143+ Destination PixelRect
144+ Cells CellRect
145+}
146+
147+// Fragment is a short alias for VisibleFragment.
148+type Fragment = VisibleFragment
149+
150+// Usage reports resources held by the current scene or an immutable snapshot.
151+type Usage struct {
152+ Assets uint64
153+ Placements uint64
154+ EncodedBytes uint64
155+ DecodedPixels uint64
156+}
157+
158+// OperationKind identifies one transactional scene operation.
159+type OperationKind uint8
160+
161+const (
162+ OperationAddAsset OperationKind = iota + 1
163+ OperationRemoveAsset
164+ OperationPlace
165+ OperationUpdatePlacement
166+ OperationRemovePlacement
167+)
168+
169+// Operation is a sparse scene operation. Add-asset operations allocate an
170+// opaque ID internally; use AddAsset directly when the ID is needed by the
171+// caller for a subsequent placement.
172+type Operation struct {
173+ Kind OperationKind
174+ Blob AssetBlob
175+ Asset AssetID
176+ AssetID AssetID
177+ Placement PlacementSpec
178+ PlacementID PlacementID
179+}
180+
181+// Operation aliases make operation construction readable without introducing
182+// protocol-specific terminology.
183+const (
184+ AddAssetOperation = OperationAddAsset
185+ RemoveAssetOperation = OperationRemoveAsset
186+ PlaceOperation = OperationPlace
187+ UpdatePlacementOperation = OperationUpdatePlacement
188+ RemovePlacementOperation = OperationRemovePlacement
189+)
A graphics/rect.go +163 -0
@@ -0,0 +1,163 @@
1+package graphics
2+
3+import "fmt"
4+
5+const (
6+ maxInt64 = int64(^uint64(0) >> 1)
7+ minInt64 = -maxInt64 - 1
8+)
9+
10+// PixelRect is a half-open rectangle in pixel coordinates. Width and Height
11+// must be non-negative, and the exclusive right and bottom edges must fit in
12+// int64. Coordinates may be negative so that scenes can be clipped without
13+// first translating them into a viewport.
14+type PixelRect struct {
15+ X, Y int64
16+ Width, Height int64
17+}
18+
19+// CellRect is a half-open rectangle in cell coordinates. It has the same
20+// overflow rules as PixelRect but is kept as a distinct type so that pixel
21+// and cell coordinates cannot be accidentally mixed.
22+type CellRect struct {
23+ X, Y int64
24+ Width, Height int64
25+}
26+
27+// NewPixelRect constructs a checked pixel rectangle.
28+func NewPixelRect(x, y, width, height int64) (PixelRect, error) {
29+ r := PixelRect{X: x, Y: y, Width: width, Height: height}
30+ if !r.Valid() {
31+ return PixelRect{}, fmt.Errorf("pixel rectangle: %w", ErrInvalidRect)
32+ }
33+ return r, nil
34+}
35+
36+// NewCellRect constructs a checked cell rectangle.
37+func NewCellRect(x, y, width, height int64) (CellRect, error) {
38+ r := CellRect{X: x, Y: y, Width: width, Height: height}
39+ if !r.Valid() {
40+ return CellRect{}, fmt.Errorf("cell rectangle: %w", ErrInvalidRect)
41+ }
42+ return r, nil
43+}
44+
45+// Valid reports whether the rectangle's dimensions and exclusive edges are
46+// representable without signed overflow.
47+func (r PixelRect) Valid() bool {
48+ return r.Width >= 0 && r.Height >= 0 && edge(r.X, r.Width) && edge(r.Y, r.Height)
49+}
50+
51+// Valid reports whether the rectangle's dimensions and exclusive edges are
52+// representable without signed overflow.
53+func (r CellRect) Valid() bool {
54+ return r.Width >= 0 && r.Height >= 0 && edge(r.X, r.Width) && edge(r.Y, r.Height)
55+}
56+
57+// Empty reports whether the rectangle has no area.
58+func (r PixelRect) Empty() bool { return r.Width == 0 || r.Height == 0 }
59+
60+// Empty reports whether the rectangle has no area.
61+func (r CellRect) Empty() bool { return r.Width == 0 || r.Height == 0 }
62+
63+// Right returns the exclusive right edge. The boolean is false for an
64+// invalid rectangle; it is never produced by arithmetic that wraps.
65+func (r PixelRect) Right() (int64, bool) { return checkedEdge(r.X, r.Width) }
66+
67+// Bottom returns the exclusive bottom edge. The boolean is false for an
68+// invalid rectangle; it is never produced by arithmetic that wraps.
69+func (r PixelRect) Bottom() (int64, bool) { return checkedEdge(r.Y, r.Height) }
70+
71+// Right returns the exclusive right edge. The boolean is false for an
72+// invalid rectangle; it is never produced by arithmetic that wraps.
73+func (r CellRect) Right() (int64, bool) { return checkedEdge(r.X, r.Width) }
74+
75+// Bottom returns the exclusive bottom edge. The boolean is false for an
76+// invalid rectangle; it is never produced by arithmetic that wraps.
77+func (r CellRect) Bottom() (int64, bool) { return checkedEdge(r.Y, r.Height) }
78+
79+// Contains reports whether a point is inside the half-open rectangle.
80+func (r PixelRect) Contains(x, y int64) bool {
81+ right, okRight := r.Right()
82+ bottom, okBottom := r.Bottom()
83+ return okRight && okBottom && x >= r.X && x < right && y >= r.Y && y < bottom
84+}
85+
86+// Contains reports whether a point is inside the half-open rectangle.
87+func (r CellRect) Contains(x, y int64) bool {
88+ right, okRight := r.Right()
89+ bottom, okBottom := r.Bottom()
90+ return okRight && okBottom && x >= r.X && x < right && y >= r.Y && y < bottom
91+}
92+
93+// Intersect returns the non-empty intersection of two rectangles. Invalid or
94+// empty rectangles do not intersect. The result is safe even when the input
95+// edges are close to the int64 limits.
96+func (r PixelRect) Intersect(other PixelRect) (PixelRect, bool) {
97+ if !r.Valid() || !other.Valid() || r.Empty() || other.Empty() {
98+ return PixelRect{}, false
99+ }
100+ right, _ := r.Right()
101+ otherRight, _ := other.Right()
102+ bottom, _ := r.Bottom()
103+ otherBottom, _ := other.Bottom()
104+ left := max(r.X, other.X)
105+ top := max(r.Y, other.Y)
106+ right = min(right, otherRight)
107+ bottom = min(bottom, otherBottom)
108+ if right <= left || bottom <= top {
109+ return PixelRect{}, false
110+ }
111+ return PixelRect{X: left, Y: top, Width: right - left, Height: bottom - top}, true
112+}
113+
114+// Intersect returns the non-empty intersection of two rectangles.
115+func (r CellRect) Intersect(other CellRect) (CellRect, bool) {
116+ if !r.Valid() || !other.Valid() || r.Empty() || other.Empty() {
117+ return CellRect{}, false
118+ }
119+ right, _ := r.Right()
120+ otherRight, _ := other.Right()
121+ bottom, _ := r.Bottom()
122+ otherBottom, _ := other.Bottom()
123+ left := max(r.X, other.X)
124+ top := max(r.Y, other.Y)
125+ right = min(right, otherRight)
126+ bottom = min(bottom, otherBottom)
127+ if right <= left || bottom <= top {
128+ return CellRect{}, false
129+ }
130+ return CellRect{X: left, Y: top, Width: right - left, Height: bottom - top}, true
131+}
132+
133+func edge(start, length int64) bool {
134+ _, ok := checkedEdge(start, length)
135+ return ok
136+}
137+
138+func checkedEdge(start, length int64) (int64, bool) {
139+ if length < 0 {
140+ return 0, false
141+ }
142+ // For a non-negative length, a negative start cannot overflow the
143+ // positive edge: even MinInt64 + MaxInt64 is -1. Avoid computing
144+ // maxInt64-start in that case because the subtraction itself could wrap.
145+ if start >= 0 && length > maxInt64-start {
146+ return 0, false
147+ }
148+ return start + length, true
149+}
150+
151+func max(a, b int64) int64 {
152+ if a > b {
153+ return a
154+ }
155+ return b
156+}
157+
158+func min(a, b int64) int64 {
159+ if a < b {
160+ return a
161+ }
162+ return b
163+}
@@ -0,0 +1,163 @@
1+package graphics
2+
3+import "fmt"
4+
5+const (
6+ maxInt64 = int64(^uint64(0) >> 1)
7+ minInt64 = -maxInt64 - 1
8+)
9+
10+// PixelRect is a half-open rectangle in pixel coordinates. Width and Height
11+// must be non-negative, and the exclusive right and bottom edges must fit in
12+// int64. Coordinates may be negative so that scenes can be clipped without
13+// first translating them into a viewport.
14+type PixelRect struct {
15+ X, Y int64
16+ Width, Height int64
17+}
18+
19+// CellRect is a half-open rectangle in cell coordinates. It has the same
20+// overflow rules as PixelRect but is kept as a distinct type so that pixel
21+// and cell coordinates cannot be accidentally mixed.
22+type CellRect struct {
23+ X, Y int64
24+ Width, Height int64
25+}
26+
27+// NewPixelRect constructs a checked pixel rectangle.
28+func NewPixelRect(x, y, width, height int64) (PixelRect, error) {
29+ r := PixelRect{X: x, Y: y, Width: width, Height: height}
30+ if !r.Valid() {
31+ return PixelRect{}, fmt.Errorf("pixel rectangle: %w", ErrInvalidRect)
32+ }
33+ return r, nil
34+}
35+
36+// NewCellRect constructs a checked cell rectangle.
37+func NewCellRect(x, y, width, height int64) (CellRect, error) {
38+ r := CellRect{X: x, Y: y, Width: width, Height: height}
39+ if !r.Valid() {
40+ return CellRect{}, fmt.Errorf("cell rectangle: %w", ErrInvalidRect)
41+ }
42+ return r, nil
43+}
44+
45+// Valid reports whether the rectangle's dimensions and exclusive edges are
46+// representable without signed overflow.
47+func (r PixelRect) Valid() bool {
48+ return r.Width >= 0 && r.Height >= 0 && edge(r.X, r.Width) && edge(r.Y, r.Height)
49+}
50+
51+// Valid reports whether the rectangle's dimensions and exclusive edges are
52+// representable without signed overflow.
53+func (r CellRect) Valid() bool {
54+ return r.Width >= 0 && r.Height >= 0 && edge(r.X, r.Width) && edge(r.Y, r.Height)
55+}
56+
57+// Empty reports whether the rectangle has no area.
58+func (r PixelRect) Empty() bool { return r.Width == 0 || r.Height == 0 }
59+
60+// Empty reports whether the rectangle has no area.
61+func (r CellRect) Empty() bool { return r.Width == 0 || r.Height == 0 }
62+
63+// Right returns the exclusive right edge. The boolean is false for an
64+// invalid rectangle; it is never produced by arithmetic that wraps.
65+func (r PixelRect) Right() (int64, bool) { return checkedEdge(r.X, r.Width) }
66+
67+// Bottom returns the exclusive bottom edge. The boolean is false for an
68+// invalid rectangle; it is never produced by arithmetic that wraps.
69+func (r PixelRect) Bottom() (int64, bool) { return checkedEdge(r.Y, r.Height) }
70+
71+// Right returns the exclusive right edge. The boolean is false for an
72+// invalid rectangle; it is never produced by arithmetic that wraps.
73+func (r CellRect) Right() (int64, bool) { return checkedEdge(r.X, r.Width) }
74+
75+// Bottom returns the exclusive bottom edge. The boolean is false for an
76+// invalid rectangle; it is never produced by arithmetic that wraps.
77+func (r CellRect) Bottom() (int64, bool) { return checkedEdge(r.Y, r.Height) }
78+
79+// Contains reports whether a point is inside the half-open rectangle.
80+func (r PixelRect) Contains(x, y int64) bool {
81+ right, okRight := r.Right()
82+ bottom, okBottom := r.Bottom()
83+ return okRight && okBottom && x >= r.X && x < right && y >= r.Y && y < bottom
84+}
85+
86+// Contains reports whether a point is inside the half-open rectangle.
87+func (r CellRect) Contains(x, y int64) bool {
88+ right, okRight := r.Right()
89+ bottom, okBottom := r.Bottom()
90+ return okRight && okBottom && x >= r.X && x < right && y >= r.Y && y < bottom
91+}
92+
93+// Intersect returns the non-empty intersection of two rectangles. Invalid or
94+// empty rectangles do not intersect. The result is safe even when the input
95+// edges are close to the int64 limits.
96+func (r PixelRect) Intersect(other PixelRect) (PixelRect, bool) {
97+ if !r.Valid() || !other.Valid() || r.Empty() || other.Empty() {
98+ return PixelRect{}, false
99+ }
100+ right, _ := r.Right()
101+ otherRight, _ := other.Right()
102+ bottom, _ := r.Bottom()
103+ otherBottom, _ := other.Bottom()
104+ left := max(r.X, other.X)
105+ top := max(r.Y, other.Y)
106+ right = min(right, otherRight)
107+ bottom = min(bottom, otherBottom)
108+ if right <= left || bottom <= top {
109+ return PixelRect{}, false
110+ }
111+ return PixelRect{X: left, Y: top, Width: right - left, Height: bottom - top}, true
112+}
113+
114+// Intersect returns the non-empty intersection of two rectangles.
115+func (r CellRect) Intersect(other CellRect) (CellRect, bool) {
116+ if !r.Valid() || !other.Valid() || r.Empty() || other.Empty() {
117+ return CellRect{}, false
118+ }
119+ right, _ := r.Right()
120+ otherRight, _ := other.Right()
121+ bottom, _ := r.Bottom()
122+ otherBottom, _ := other.Bottom()
123+ left := max(r.X, other.X)
124+ top := max(r.Y, other.Y)
125+ right = min(right, otherRight)
126+ bottom = min(bottom, otherBottom)
127+ if right <= left || bottom <= top {
128+ return CellRect{}, false
129+ }
130+ return CellRect{X: left, Y: top, Width: right - left, Height: bottom - top}, true
131+}
132+
133+func edge(start, length int64) bool {
134+ _, ok := checkedEdge(start, length)
135+ return ok
136+}
137+
138+func checkedEdge(start, length int64) (int64, bool) {
139+ if length < 0 {
140+ return 0, false
141+ }
142+ // For a non-negative length, a negative start cannot overflow the
143+ // positive edge: even MinInt64 + MaxInt64 is -1. Avoid computing
144+ // maxInt64-start in that case because the subtraction itself could wrap.
145+ if start >= 0 && length > maxInt64-start {
146+ return 0, false
147+ }
148+ return start + length, true
149+}
150+
151+func max(a, b int64) int64 {
152+ if a > b {
153+ return a
154+ }
155+ return b
156+}
157+
158+func min(a, b int64) int64 {
159+ if a < b {
160+ return a
161+ }
162+ return b
163+}
A graphics/scene.go +954 -0
@@ -0,0 +1,954 @@
1+package graphics
2+
3+import (
4+ "fmt"
5+ "math/big"
6+ "sort"
7+ "sync"
8+)
9+
10+type assetRecord struct {
11+ id AssetID
12+ encoded []byte
13+ width int64
14+ height int64
15+ pixels uint64
16+}
17+
18+type placementRecord struct {
19+ id PlacementID
20+ asset AssetID
21+ source PixelRect
22+ destination PixelRect
23+ cells CellRect
24+ layer int64
25+ order uint64
26+}
27+
28+type sceneState struct {
29+ generation uint64
30+ nextAsset uint64
31+ nextPlace uint64
32+ nextOrder uint64
33+ assets map[AssetID]assetRecord
34+ placements map[PlacementID]placementRecord
35+ usage Usage
36+}
37+
38+// Scene owns a bounded set of immutable assets and sparse placements. Scene
39+// methods are safe for concurrent callers; snapshots never share mutable maps
40+// with the scene. The scene itself contains no text or renderer state.
41+type Scene struct {
42+ mu sync.RWMutex
43+ limits Limits
44+ state *sceneState
45+}
46+
47+// NewScene creates an empty bounded graphics scene. Zero-valued limits use
48+// package defaults. Limits are copied and may be safely reused by the caller.
49+func NewScene(limits Limits) *Scene {
50+ return &Scene{limits: normalizeLimits(limits), state: emptyState()}
51+}
52+
53+// New is a concise constructor alias.
54+func New(limits Limits) *Scene { return NewScene(limits) }
55+
56+// NewSceneWithLimits is a descriptive constructor alias.
57+func NewSceneWithLimits(limits Limits) *Scene { return NewScene(limits) }
58+
59+func emptyState() *sceneState {
60+ return &sceneState{
61+ nextAsset: 1,
62+ nextPlace: 1,
63+ nextOrder: 1,
64+ assets: make(map[AssetID]assetRecord),
65+ placements: make(map[PlacementID]placementRecord),
66+ }
67+}
68+
69+func normalizeLimits(limits Limits) Limits {
70+ if limits.MaxAssets == 0 {
71+ limits.MaxAssets = DefaultMaxAssets
72+ }
73+ if limits.MaxPlacements == 0 {
74+ limits.MaxPlacements = DefaultMaxPlacements
75+ }
76+ if limits.MaxEncodedBytes == 0 {
77+ limits.MaxEncodedBytes = DefaultMaxEncodedBytes
78+ }
79+ if limits.MaxDecodedPixels == 0 {
80+ limits.MaxDecodedPixels = DefaultMaxDecodedPixels
81+ }
82+ if limits.MaxEncodedBytesPerAsset == 0 {
83+ limits.MaxEncodedBytesPerAsset = DefaultMaxEncodedBytesPerAsset
84+ }
85+ if limits.MaxDecodedPixelsPerAsset == 0 {
86+ limits.MaxDecodedPixelsPerAsset = DefaultMaxDecodedPixelsPerAsset
87+ }
88+ return limits
89+}
90+
91+// Limits returns the immutable limits configured for the scene.
92+func (s *Scene) Limits() Limits {
93+ if s == nil {
94+ return Limits{}
95+ }
96+ s.mu.RLock()
97+ defer s.mu.RUnlock()
98+ return s.limits
99+}
100+
101+// AddAsset copies and registers an encoded asset, returning an opaque ID.
102+func (s *Scene) AddAsset(blob AssetBlob) (AssetID, error) {
103+ if s == nil {
104+ return AssetID{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
105+ }
106+ s.mu.Lock()
107+ defer s.mu.Unlock()
108+ candidate := cloneState(s.state)
109+ record, err := s.prepareAsset(blob, candidate)
110+ if err != nil {
111+ return AssetID{}, err
112+ }
113+ id, ok := takeAssetID(candidate)
114+ if !ok {
115+ return AssetID{}, fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
116+ }
117+ record.id = id
118+ candidate.assets[id] = record
119+ candidate.usage.Assets++
120+ candidate.usage.EncodedBytes += uint64(len(record.encoded))
121+ candidate.usage.DecodedPixels += record.pixels
122+ if err := advanceGeneration(candidate); err != nil {
123+ return AssetID{}, err
124+ }
125+ s.state = candidate
126+ return id, nil
127+}
128+
129+// RegisterAsset is an alias for AddAsset.
130+func (s *Scene) RegisterAsset(blob AssetBlob) (AssetID, error) { return s.AddAsset(blob) }
131+
132+// AddEncodedAsset registers an asset from its encoded bytes and decoded pixel
133+// dimensions. The input bytes are copied before this method returns.
134+func (s *Scene) AddEncodedAsset(encoded []byte, width, height int64) (AssetID, error) {
135+ return s.AddAsset(AssetBlob{Encoded: encoded, Width: width, Height: height})
136+}
137+
138+// RemoveAsset removes an unreferenced asset. Use RemoveAssetCascade when the
139+// asset's placements should be removed as one transaction.
140+func (s *Scene) RemoveAsset(id AssetID) error {
141+ if s == nil {
142+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
143+ }
144+ s.mu.Lock()
145+ defer s.mu.Unlock()
146+ candidate := cloneState(s.state)
147+ if err := removeAsset(candidate, id, false); err != nil {
148+ return err
149+ }
150+ if err := advanceGeneration(candidate); err != nil {
151+ return fmt.Errorf("remove asset: %w", err)
152+ }
153+ s.state = candidate
154+ return nil
155+}
156+
157+// RemoveAssetCascade removes an asset and all placements that reference it in
158+// one generation.
159+func (s *Scene) RemoveAssetCascade(id AssetID) error {
160+ if s == nil {
161+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
162+ }
163+ s.mu.Lock()
164+ defer s.mu.Unlock()
165+ candidate := cloneState(s.state)
166+ if err := removeAsset(candidate, id, true); err != nil {
167+ return err
168+ }
169+ if err := advanceGeneration(candidate); err != nil {
170+ return fmt.Errorf("remove asset: %w", err)
171+ }
172+ s.state = candidate
173+ return nil
174+}
175+
176+// Place adds one sparse placement and returns its opaque ID.
177+func (s *Scene) Place(spec PlacementSpec) (PlacementID, error) {
178+ if s == nil {
179+ return PlacementID{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
180+ }
181+ s.mu.Lock()
182+ defer s.mu.Unlock()
183+ candidate := cloneState(s.state)
184+ record, err := s.preparePlacement(candidate, spec, nil)
185+ if err != nil {
186+ return PlacementID{}, err
187+ }
188+ if uint64(len(candidate.placements)) >= s.limits.MaxPlacements {
189+ return PlacementID{}, fmt.Errorf("place: %w", ErrTooManyPlacements)
190+ }
191+ id, ok := takePlacementID(candidate)
192+ if !ok {
193+ return PlacementID{}, fmt.Errorf("place: %w", ErrIdentifierOverflow)
194+ }
195+ record.id = id
196+ record.order = takeOrder(candidate)
197+ candidate.placements[id] = record
198+ candidate.usage.Placements++
199+ if err := advanceGeneration(candidate); err != nil {
200+ return PlacementID{}, fmt.Errorf("place: %w", err)
201+ }
202+ s.state = candidate
203+ return id, nil
204+}
205+
206+// AddPlacement is an alias for Place.
207+func (s *Scene) AddPlacement(spec PlacementSpec) (PlacementID, error) { return s.Place(spec) }
208+
209+// PlaceAsset creates a full-source placement of an asset.
210+func (s *Scene) PlaceAsset(asset AssetID, destination PixelRect) (PlacementID, error) {
211+ return s.Place(PlacementSpec{Asset: asset, Destination: destination})
212+}
213+
214+// UpdatePlacement replaces the geometry and optional metadata of a placement.
215+// If spec.Asset and spec.AssetID are both invalid, the existing asset is kept.
216+func (s *Scene) UpdatePlacement(id PlacementID, spec PlacementSpec) error {
217+ if s == nil {
218+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
219+ }
220+ s.mu.Lock()
221+ defer s.mu.Unlock()
222+ candidate := cloneState(s.state)
223+ existing, ok := candidate.placements[id]
224+ if !ok {
225+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
226+ }
227+ record, err := s.preparePlacement(candidate, spec, &existing)
228+ if err != nil {
229+ return err
230+ }
231+ record.id = id
232+ record.order = existing.order
233+ candidate.placements[id] = record
234+ if err := advanceGeneration(candidate); err != nil {
235+ return fmt.Errorf("update placement: %w", err)
236+ }
237+ s.state = candidate
238+ return nil
239+}
240+
241+// MovePlacement changes only the destination rectangle of a placement.
242+func (s *Scene) MovePlacement(id PlacementID, destination PixelRect) error {
243+ return s.UpdatePlacement(id, PlacementSpec{Destination: destination})
244+}
245+
246+// RemovePlacement removes one sparse placement.
247+func (s *Scene) RemovePlacement(id PlacementID) error {
248+ if s == nil {
249+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
250+ }
251+ s.mu.Lock()
252+ defer s.mu.Unlock()
253+ candidate := cloneState(s.state)
254+ if _, ok := candidate.placements[id]; !ok {
255+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
256+ }
257+ delete(candidate.placements, id)
258+ candidate.usage.Placements--
259+ if err := advanceGeneration(candidate); err != nil {
260+ return fmt.Errorf("remove placement: %w", err)
261+ }
262+ s.state = candidate
263+ return nil
264+}
265+
266+// Clear removes every asset and placement while preserving the monotonic ID
267+// counters. It publishes a new generation only when the scene is non-empty.
268+func (s *Scene) Clear() error {
269+ if s == nil {
270+ return nil
271+ }
272+ s.mu.Lock()
273+ defer s.mu.Unlock()
274+ if len(s.state.assets) == 0 && len(s.state.placements) == 0 {
275+ return nil
276+ }
277+ candidate := cloneState(s.state)
278+ candidate.assets = make(map[AssetID]assetRecord)
279+ candidate.placements = make(map[PlacementID]placementRecord)
280+ candidate.usage = Usage{}
281+ if err := advanceGeneration(candidate); err != nil {
282+ return fmt.Errorf("clear scene: %w", err)
283+ }
284+ s.state = candidate
285+ return nil
286+}
287+
288+// Apply commits sparse operations transactionally. If any operation fails,
289+// neither the state nor the generation changes.
290+func (s *Scene) Apply(operations ...Operation) error {
291+ if s == nil {
292+ return fmt.Errorf("apply operations: %w", ErrInvalidOperation)
293+ }
294+ if len(operations) == 0 {
295+ return nil
296+ }
297+ s.mu.Lock()
298+ defer s.mu.Unlock()
299+ candidate := cloneState(s.state)
300+ for i, operation := range operations {
301+ if err := s.applyOperation(candidate, operation); err != nil {
302+ return fmt.Errorf("apply operation %d: %w", i, err)
303+ }
304+ }
305+ if err := advanceGeneration(candidate); err != nil {
306+ return fmt.Errorf("apply operations: %w", err)
307+ }
308+ s.state = candidate
309+ return nil
310+}
311+
312+// ApplyOperations is the slice-taking form of Apply.
313+func (s *Scene) ApplyOperations(operations []Operation) error { return s.Apply(operations...) }
314+
315+// Snapshot returns an immutable reference to the current scene state. Future
316+// scene mutations use copy-on-write maps and cannot alter this snapshot.
317+func (s *Scene) Snapshot() *Snapshot {
318+ if s == nil {
319+ return &Snapshot{state: emptyState()}
320+ }
321+ s.mu.RLock()
322+ defer s.mu.RUnlock()
323+ return &Snapshot{state: s.state}
324+}
325+
326+// Generation returns the current scene generation.
327+func (s *Scene) Generation() Generation {
328+ if s == nil {
329+ return 0
330+ }
331+ s.mu.RLock()
332+ defer s.mu.RUnlock()
333+ return s.state.generation
334+}
335+
336+// Usage returns the current live resource usage.
337+func (s *Scene) Usage() Usage {
338+ if s == nil {
339+ return Usage{}
340+ }
341+ s.mu.RLock()
342+ defer s.mu.RUnlock()
343+ return s.state.usage
344+}
345+
346+func (s *Scene) prepareAsset(blob AssetBlob, state *sceneState) (assetRecord, error) {
347+ data := blob.Encoded
348+ if data == nil {
349+ data = blob.Data
350+ }
351+ if len(data) == 0 && blob.Encoded != nil && blob.Data != nil && len(blob.Data) != 0 {
352+ data = blob.Data
353+ }
354+ if blob.Width <= 0 || blob.Height <= 0 {
355+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
356+ }
357+ pixels, ok := decodedPixels(blob.Width, blob.Height, blob.DecodedPixels)
358+ if !ok {
359+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
360+ }
361+ encoded := uint64(len(data))
362+ if encoded > s.limits.MaxEncodedBytesPerAsset {
363+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
364+ }
365+ if pixels > s.limits.MaxDecodedPixelsPerAsset {
366+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
367+ }
368+ if uint64(len(state.assets)) >= s.limits.MaxAssets {
369+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrTooManyAssets)
370+ }
371+ if !within(state.usage.EncodedBytes, encoded, s.limits.MaxEncodedBytes) {
372+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
373+ }
374+ if !within(state.usage.DecodedPixels, pixels, s.limits.MaxDecodedPixels) {
375+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
376+ }
377+ return assetRecord{encoded: append([]byte(nil), data...), width: blob.Width, height: blob.Height, pixels: pixels}, nil
378+}
379+
380+func (s *Scene) preparePlacement(state *sceneState, spec PlacementSpec, existing *placementRecord) (placementRecord, error) {
381+ asset := spec.Asset
382+ if !asset.Valid() {
383+ asset = spec.AssetID
384+ }
385+ if !asset.Valid() && existing != nil {
386+ asset = existing.asset
387+ }
388+ recordAsset, ok := state.assets[asset]
389+ if !ok {
390+ return placementRecord{}, fmt.Errorf("place: %w", ErrAssetNotFound)
391+ }
392+ destination := spec.Destination
393+ if destination == (PixelRect{}) {
394+ destination = spec.Dest
395+ }
396+ if existing != nil && destination == (PixelRect{}) {
397+ destination = existing.destination
398+ }
399+ if !destination.Valid() || destination.Empty() {
400+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
401+ }
402+ source := spec.Source
403+ if source == (PixelRect{}) {
404+ if existing != nil && spec.Source == (PixelRect{}) {
405+ source = existing.source
406+ } else {
407+ source = PixelRect{Width: recordAsset.width, Height: recordAsset.height}
408+ }
409+ }
410+ if !source.Valid() || source.Empty() {
411+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
412+ }
413+ assetBounds := PixelRect{Width: recordAsset.width, Height: recordAsset.height}
414+ if _, ok := assetBounds.Intersect(source); !ok || source != assetBounds && !containsRect(assetBounds, source) {
415+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
416+ }
417+ cells := spec.Cells
418+ if cells == (CellRect{}) {
419+ cells = spec.CellBounds
420+ }
421+ if existing != nil && cells == (CellRect{}) {
422+ cells = existing.cells
423+ }
424+ if cells != (CellRect{}) && (!cells.Valid() || cells.Empty()) {
425+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
426+ }
427+ layer := spec.Layer
428+ if existing != nil && layer == 0 {
429+ layer = existing.layer
430+ }
431+ return placementRecord{asset: asset, source: source, destination: destination, cells: cells, layer: layer}, nil
432+}
433+
434+func containsRect(outer, inner PixelRect) bool {
435+ right, okRight := outer.Right()
436+ innerRight, okInnerRight := inner.Right()
437+ bottom, okBottom := outer.Bottom()
438+ innerBottom, okInnerBottom := inner.Bottom()
439+ return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
440+}
441+
442+func removeAsset(state *sceneState, id AssetID, cascade bool) error {
443+ record, ok := state.assets[id]
444+ if !ok {
445+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
446+ }
447+ for placementID, placement := range state.placements {
448+ if placement.asset != id {
449+ continue
450+ }
451+ if !cascade {
452+ return fmt.Errorf("remove asset %s: %w", id, ErrAssetInUse)
453+ }
454+ delete(state.placements, placementID)
455+ state.usage.Placements--
456+ }
457+ delete(state.assets, id)
458+ state.usage.Assets--
459+ state.usage.EncodedBytes -= uint64(len(record.encoded))
460+ state.usage.DecodedPixels -= record.pixels
461+ return nil
462+}
463+
464+func (s *Scene) applyOperation(state *sceneState, operation Operation) error {
465+ switch operation.Kind {
466+ case OperationAddAsset:
467+ record, err := s.prepareAsset(operation.Blob, state)
468+ if err != nil {
469+ return err
470+ }
471+ id, ok := takeAssetID(state)
472+ if !ok {
473+ return fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
474+ }
475+ record.id = id
476+ state.assets[id] = record
477+ state.usage.Assets++
478+ state.usage.EncodedBytes += uint64(len(record.encoded))
479+ state.usage.DecodedPixels += record.pixels
480+ case OperationRemoveAsset:
481+ id := operation.Asset
482+ if !id.Valid() {
483+ id = operation.AssetID
484+ }
485+ if err := removeAsset(state, id, false); err != nil {
486+ return err
487+ }
488+ case OperationPlace:
489+ record, err := s.preparePlacement(state, operation.Placement, nil)
490+ if err != nil {
491+ return err
492+ }
493+ if uint64(len(state.placements)) >= s.limits.MaxPlacements {
494+ return fmt.Errorf("place: %w", ErrTooManyPlacements)
495+ }
496+ id, ok := takePlacementID(state)
497+ if !ok {
498+ return fmt.Errorf("place: %w", ErrIdentifierOverflow)
499+ }
500+ record.id = id
501+ record.order = takeOrder(state)
502+ state.placements[id] = record
503+ state.usage.Placements++
504+ case OperationUpdatePlacement:
505+ existing, ok := state.placements[operation.PlacementID]
506+ if !ok {
507+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
508+ }
509+ record, err := s.preparePlacement(state, operation.Placement, &existing)
510+ if err != nil {
511+ return err
512+ }
513+ record.id = existing.id
514+ record.order = existing.order
515+ state.placements[existing.id] = record
516+ case OperationRemovePlacement:
517+ if _, ok := state.placements[operation.PlacementID]; !ok {
518+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
519+ }
520+ delete(state.placements, operation.PlacementID)
521+ state.usage.Placements--
522+ default:
523+ return fmt.Errorf("operation kind %d: %w", operation.Kind, ErrInvalidOperation)
524+ }
525+ return nil
526+}
527+
528+func cloneState(state *sceneState) *sceneState {
529+ if state == nil {
530+ return emptyState()
531+ }
532+ clone := *state
533+ clone.assets = make(map[AssetID]assetRecord, len(state.assets))
534+ for id, record := range state.assets {
535+ clone.assets[id] = record
536+ }
537+ clone.placements = make(map[PlacementID]placementRecord, len(state.placements))
538+ for id, record := range state.placements {
539+ clone.placements[id] = record
540+ }
541+ return &clone
542+}
543+
544+func advanceGeneration(state *sceneState) error {
545+ if state.generation == ^uint64(0) {
546+ return ErrGenerationOverflow
547+ }
548+ state.generation++
549+ return nil
550+}
551+
552+func takeAssetID(state *sceneState) (AssetID, bool) {
553+ if state.nextAsset == 0 {
554+ return AssetID{}, false
555+ }
556+ id := AssetID{value: state.nextAsset}
557+ if state.nextAsset == ^uint64(0) {
558+ state.nextAsset = 0
559+ } else {
560+ state.nextAsset++
561+ }
562+ return id, true
563+}
564+
565+func takePlacementID(state *sceneState) (PlacementID, bool) {
566+ if state.nextPlace == 0 {
567+ return PlacementID{}, false
568+ }
569+ id := PlacementID{value: state.nextPlace}
570+ if state.nextPlace == ^uint64(0) {
571+ state.nextPlace = 0
572+ } else {
573+ state.nextPlace++
574+ }
575+ return id, true
576+}
577+
578+func takeOrder(state *sceneState) uint64 {
579+ order := state.nextOrder
580+ if state.nextOrder != ^uint64(0) {
581+ state.nextOrder++
582+ } else {
583+ state.nextOrder = 0
584+ }
585+ return order
586+}
587+
588+func within(current, additional, limit uint64) bool {
589+ if current > limit {
590+ return false
591+ }
592+ return additional <= limit-current
593+}
594+
595+func decodedPixels(width, height int64, declared uint64) (uint64, bool) {
596+ if width <= 0 || height <= 0 {
597+ return 0, false
598+ }
599+ w, h := uint64(width), uint64(height)
600+ if h != 0 && w > ^uint64(0)/h {
601+ return 0, false
602+ }
603+ computed := w * h
604+ if declared != 0 {
605+ return declared, true
606+ }
607+ return computed, true
608+}
609+
610+// AssetView is an immutable view of a registered asset. Encoded returns a new
611+// byte slice on every call; no caller can mutate scene or snapshot storage.
612+type AssetView struct {
613+ id AssetID
614+ encoded []byte
615+ width int64
616+ height int64
617+ pixels uint64
618+}
619+
620+// Asset is an alias for the immutable snapshot view.
621+type Asset = AssetView
622+
623+func (a AssetView) ID() AssetID { return a.id }
624+func (a AssetView) Width() int64 { return a.width }
625+func (a AssetView) Height() int64 { return a.height }
626+func (a AssetView) DecodedPixels() uint64 { return a.pixels }
627+func (a AssetView) EncodedSize() uint64 { return uint64(len(a.encoded)) }
628+func (a AssetView) Encoded() []byte { return append([]byte(nil), a.encoded...) }
629+func (a AssetView) Bytes() []byte { return a.Encoded() }
630+func (a AssetView) Blob() AssetBlob {
631+ return AssetBlob{Encoded: a.Encoded(), Width: a.width, Height: a.height, DecodedPixels: a.pixels}
632+}
633+
634+// PlacementView is an immutable view of a sparse placement.
635+type PlacementView struct {
636+ id PlacementID
637+ asset AssetID
638+ source PixelRect
639+ destination PixelRect
640+ cells CellRect
641+ layer int64
642+}
643+
644+// Placement is an alias for the immutable snapshot view.
645+type Placement = PlacementView
646+
647+func (p PlacementView) ID() PlacementID { return p.id }
648+func (p PlacementView) AssetID() AssetID { return p.asset }
649+func (p PlacementView) Source() PixelRect { return p.source }
650+func (p PlacementView) Destination() PixelRect { return p.destination }
651+func (p PlacementView) Cells() CellRect { return p.cells }
652+func (p PlacementView) Layer() int64 { return p.layer }
653+func (p PlacementView) Spec() PlacementSpec {
654+ return PlacementSpec{Asset: p.asset, Source: p.source, Destination: p.destination, Cells: p.cells, Layer: p.layer}
655+}
656+
657+// Snapshot is an immutable reference to a scene state.
658+type Snapshot struct{ state *sceneState }
659+
660+// Generation is a monotonically increasing scene generation.
661+func (s *Snapshot) Generation() uint64 {
662+ if s == nil || s.state == nil {
663+ return 0
664+ }
665+ return s.state.generation
666+}
667+
668+// Usage reports the resources retained by this snapshot's state.
669+func (s *Snapshot) Usage() Usage {
670+ if s == nil || s.state == nil {
671+ return Usage{}
672+ }
673+ return s.state.usage
674+}
675+
676+// Asset returns an immutable asset view by ID.
677+func (s *Snapshot) Asset(id AssetID) (AssetView, bool) {
678+ if s == nil || s.state == nil {
679+ return AssetView{}, false
680+ }
681+ record, ok := s.state.assets[id]
682+ if !ok {
683+ return AssetView{}, false
684+ }
685+ return AssetView{id: record.id, encoded: record.encoded, width: record.width, height: record.height, pixels: record.pixels}, true
686+}
687+
688+// GetAsset is an alias for Asset.
689+func (s *Snapshot) GetAsset(id AssetID) (AssetView, bool) { return s.Asset(id) }
690+
691+// Assets returns immutable asset views in ID order.
692+func (s *Snapshot) Assets() []AssetView {
693+ if s == nil || s.state == nil || len(s.state.assets) == 0 {
694+ return nil
695+ }
696+ ids := make([]AssetID, 0, len(s.state.assets))
697+ for id := range s.state.assets {
698+ ids = append(ids, id)
699+ }
700+ sort.Slice(ids, func(i, j int) bool { return ids[i].value < ids[j].value })
701+ assets := make([]AssetView, 0, len(ids))
702+ for _, id := range ids {
703+ asset, _ := s.Asset(id)
704+ assets = append(assets, asset)
705+ }
706+ return assets
707+}
708+
709+// Placement returns an immutable placement view by ID.
710+func (s *Snapshot) Placement(id PlacementID) (PlacementView, bool) {
711+ if s == nil || s.state == nil {
712+ return PlacementView{}, false
713+ }
714+ record, ok := s.state.placements[id]
715+ if !ok {
716+ return PlacementView{}, false
717+ }
718+ return PlacementView{id: record.id, asset: record.asset, source: record.source, destination: record.destination, cells: record.cells, layer: record.layer}, true
719+}
720+
721+// GetPlacement is an alias for Placement.
722+func (s *Snapshot) GetPlacement(id PlacementID) (PlacementView, bool) { return s.Placement(id) }
723+
724+// Placements returns immutable placement views in layer and insertion order.
725+func (s *Snapshot) Placements() []PlacementView {
726+ if s == nil || s.state == nil || len(s.state.placements) == 0 {
727+ return nil
728+ }
729+ placements := make([]placementRecord, 0, len(s.state.placements))
730+ for _, placement := range s.state.placements {
731+ placements = append(placements, placement)
732+ }
733+ sort.SliceStable(placements, func(i, j int) bool {
734+ if placements[i].layer != placements[j].layer {
735+ return placements[i].layer < placements[j].layer
736+ }
737+ return placements[i].order < placements[j].order
738+ })
739+ views := make([]PlacementView, 0, len(placements))
740+ for _, placement := range placements {
741+ views = append(views, PlacementView{id: placement.id, asset: placement.asset, source: placement.source, destination: placement.destination, cells: placement.cells, layer: placement.layer})
742+ }
743+ return views
744+}
745+
746+// VisibleFragments returns clipped placement fragments. viewport may be a
747+// PixelRect, CellRect, or Viewport. Unsupported or invalid viewport values
748+// produce no fragments rather than wrapping coordinates.
749+func (s *Snapshot) VisibleFragments(viewport any) []VisibleFragment {
750+ if s == nil || s.state == nil {
751+ return nil
752+ }
753+ pixels, cells, hasPixels, hasCells := viewportParts(viewport)
754+ if !hasPixels && !hasCells {
755+ return nil
756+ }
757+ placements := s.Placements()
758+ fragments := make([]VisibleFragment, 0, len(placements))
759+ for _, placement := range placements {
760+ visible, ok := visiblePlacement(placement, pixels, cells, hasPixels, hasCells)
761+ if ok {
762+ fragments = append(fragments, visible)
763+ }
764+ }
765+ return fragments
766+}
767+
768+// VisiblePixelFragments is the typed pixel-space form of VisibleFragments.
769+func (s *Snapshot) VisiblePixelFragments(viewport PixelRect) []VisibleFragment {
770+ return s.VisibleFragments(viewport)
771+}
772+
773+// VisibleCellFragments is the typed cell-space form of VisibleFragments.
774+func (s *Snapshot) VisibleCellFragments(viewport CellRect) []VisibleFragment {
775+ return s.VisibleFragments(viewport)
776+}
777+
778+func viewportParts(viewport any) (PixelRect, CellRect, bool, bool) {
779+ switch value := viewport.(type) {
780+ case PixelRect:
781+ return value, CellRect{}, value.Valid() && !value.Empty(), false
782+ case CellRect:
783+ return PixelRect{}, value, false, value.Valid() && !value.Empty()
784+ case Viewport:
785+ hasPixels := value.Pixels.Valid() && !value.Pixels.Empty()
786+ hasCells := value.Cells.Valid() && !value.Cells.Empty()
787+ return value.Pixels, value.Cells, hasPixels, hasCells
788+ case *Viewport:
789+ if value == nil {
790+ return PixelRect{}, CellRect{}, false, false
791+ }
792+ return viewportParts(*value)
793+ default:
794+ return PixelRect{}, CellRect{}, false, false
795+ }
796+}
797+
798+func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect, hasPixels, hasCells bool) (VisibleFragment, bool) {
799+ destination := placement.destination
800+ if hasPixels {
801+ var ok bool
802+ destination, ok = destination.Intersect(pixels)
803+ if !ok {
804+ return VisibleFragment{}, false
805+ }
806+ }
807+ var visibleCells CellRect
808+ if placement.cells.Valid() && !placement.cells.Empty() {
809+ visibleCells = placement.cells
810+ if hasCells {
811+ var ok bool
812+ visibleCells, ok = visibleCells.Intersect(cells)
813+ if !ok {
814+ return VisibleFragment{}, false
815+ }
816+ cellPixels, ok := mapSubRectCellToPixel(placement.cells, placement.destination, visibleCells)
817+ if !ok {
818+ return VisibleFragment{}, false
819+ }
820+ var pixelOK bool
821+ destination, pixelOK = destination.Intersect(cellPixels)
822+ if !pixelOK {
823+ return VisibleFragment{}, false
824+ }
825+ }
826+ if hasPixels {
827+ var ok bool
828+ visibleCells, ok = mapSubRectPixelToCell(placement.destination, placement.cells, destination)
829+ if !ok {
830+ return VisibleFragment{}, false
831+ }
832+ if hasCells {
833+ visibleCells, ok = visibleCells.Intersect(cells)
834+ if !ok {
835+ return VisibleFragment{}, false
836+ }
837+ }
838+ }
839+ } else if hasCells {
840+ return VisibleFragment{}, false
841+ }
842+ source, ok := mapSubRect(placement.destination, placement.source, destination)
843+ if !ok {
844+ return VisibleFragment{}, false
845+ }
846+ return VisibleFragment{PlacementID: placement.id, AssetID: placement.asset, Source: source, Destination: destination, Cells: visibleCells}, true
847+}
848+
849+// mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate
850+// space into another rectangle's coordinate space. It uses exact 128+-bit
851+// arithmetic through math/big so products cannot wrap before division.
852+func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) {
853+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
854+ return PixelRect{}, false
855+ }
856+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
857+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
858+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
859+ return PixelRect{}, false
860+ }
861+ return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
862+}
863+
864+func mapSubRectCellToPixel(from CellRect, to PixelRect, sub CellRect) (PixelRect, bool) {
865+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsCellRect(from, sub) {
866+ return PixelRect{}, false
867+ }
868+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
869+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
870+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
871+ return PixelRect{}, false
872+ }
873+ return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
874+}
875+
876+func mapSubRectPixelToCell(from PixelRect, to CellRect, sub PixelRect) (CellRect, bool) {
877+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
878+ return CellRect{}, false
879+ }
880+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
881+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
882+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
883+ return CellRect{}, false
884+ }
885+ return CellRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
886+}
887+
888+func containsCellRect(outer, inner CellRect) bool {
889+ right, okRight := outer.Right()
890+ innerRight, okInnerRight := inner.Right()
891+ bottom, okBottom := outer.Bottom()
892+ innerBottom, okInnerBottom := inner.Bottom()
893+ return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
894+}
895+
896+func mustRight(start, width int64) int64 {
897+ right, _ := checkedEdge(start, width)
898+ return right
899+}
900+
901+func mapRange(fromStart, fromSize, toStart, toSize, clipStart, clipEnd int64) ([2]int64, bool) {
902+ if fromSize <= 0 || toSize <= 0 || clipStart < fromStart || clipEnd < clipStart {
903+ return [2]int64{}, false
904+ }
905+ fromEnd, ok := checkedEdge(fromStart, fromSize)
906+ if !ok || clipEnd > fromEnd {
907+ return [2]int64{}, false
908+ }
909+ startOffset := clipStart - fromStart
910+ endOffset := clipEnd - fromStart
911+ startDelta, ok := ratio(startOffset, toSize, fromSize, false)
912+ if !ok {
913+ return [2]int64{}, false
914+ }
915+ endDelta, ok := ratio(endOffset, toSize, fromSize, true)
916+ if !ok {
917+ return [2]int64{}, false
918+ }
919+ start, ok := checkedAdd(toStart, startDelta)
920+ if !ok {
921+ return [2]int64{}, false
922+ }
923+ end, ok := checkedAdd(toStart, endDelta)
924+ if !ok || end <= start {
925+ return [2]int64{}, false
926+ }
927+ return [2]int64{start, end}, true
928+}
929+
930+func ratio(offset, numerator, denominator int64, ceil bool) (int64, bool) {
931+ if offset < 0 || numerator <= 0 || denominator <= 0 {
932+ return 0, false
933+ }
934+ product := new(big.Int).Mul(big.NewInt(offset), big.NewInt(numerator))
935+ quotient, remainder := new(big.Int), new(big.Int)
936+ quotient.QuoRem(product, big.NewInt(denominator), remainder)
937+ if ceil && remainder.Sign() != 0 {
938+ quotient.Add(quotient, big.NewInt(1))
939+ }
940+ if !quotient.IsInt64() || quotient.Sign() < 0 {
941+ return 0, false
942+ }
943+ return quotient.Int64(), true
944+}
945+
946+func checkedAdd(a, b int64) (int64, bool) {
947+ if b > 0 && a > maxInt64-b {
948+ return 0, false
949+ }
950+ if b < 0 && a < minInt64-b {
951+ return 0, false
952+ }
953+ return a + b, true
954+}
@@ -0,0 +1,954 @@
1+package graphics
2+
3+import (
4+ "fmt"
5+ "math/big"
6+ "sort"
7+ "sync"
8+)
9+
10+type assetRecord struct {
11+ id AssetID
12+ encoded []byte
13+ width int64
14+ height int64
15+ pixels uint64
16+}
17+
18+type placementRecord struct {
19+ id PlacementID
20+ asset AssetID
21+ source PixelRect
22+ destination PixelRect
23+ cells CellRect
24+ layer int64
25+ order uint64
26+}
27+
28+type sceneState struct {
29+ generation uint64
30+ nextAsset uint64
31+ nextPlace uint64
32+ nextOrder uint64
33+ assets map[AssetID]assetRecord
34+ placements map[PlacementID]placementRecord
35+ usage Usage
36+}
37+
38+// Scene owns a bounded set of immutable assets and sparse placements. Scene
39+// methods are safe for concurrent callers; snapshots never share mutable maps
40+// with the scene. The scene itself contains no text or renderer state.
41+type Scene struct {
42+ mu sync.RWMutex
43+ limits Limits
44+ state *sceneState
45+}
46+
47+// NewScene creates an empty bounded graphics scene. Zero-valued limits use
48+// package defaults. Limits are copied and may be safely reused by the caller.
49+func NewScene(limits Limits) *Scene {
50+ return &Scene{limits: normalizeLimits(limits), state: emptyState()}
51+}
52+
53+// New is a concise constructor alias.
54+func New(limits Limits) *Scene { return NewScene(limits) }
55+
56+// NewSceneWithLimits is a descriptive constructor alias.
57+func NewSceneWithLimits(limits Limits) *Scene { return NewScene(limits) }
58+
59+func emptyState() *sceneState {
60+ return &sceneState{
61+ nextAsset: 1,
62+ nextPlace: 1,
63+ nextOrder: 1,
64+ assets: make(map[AssetID]assetRecord),
65+ placements: make(map[PlacementID]placementRecord),
66+ }
67+}
68+
69+func normalizeLimits(limits Limits) Limits {
70+ if limits.MaxAssets == 0 {
71+ limits.MaxAssets = DefaultMaxAssets
72+ }
73+ if limits.MaxPlacements == 0 {
74+ limits.MaxPlacements = DefaultMaxPlacements
75+ }
76+ if limits.MaxEncodedBytes == 0 {
77+ limits.MaxEncodedBytes = DefaultMaxEncodedBytes
78+ }
79+ if limits.MaxDecodedPixels == 0 {
80+ limits.MaxDecodedPixels = DefaultMaxDecodedPixels
81+ }
82+ if limits.MaxEncodedBytesPerAsset == 0 {
83+ limits.MaxEncodedBytesPerAsset = DefaultMaxEncodedBytesPerAsset
84+ }
85+ if limits.MaxDecodedPixelsPerAsset == 0 {
86+ limits.MaxDecodedPixelsPerAsset = DefaultMaxDecodedPixelsPerAsset
87+ }
88+ return limits
89+}
90+
91+// Limits returns the immutable limits configured for the scene.
92+func (s *Scene) Limits() Limits {
93+ if s == nil {
94+ return Limits{}
95+ }
96+ s.mu.RLock()
97+ defer s.mu.RUnlock()
98+ return s.limits
99+}
100+
101+// AddAsset copies and registers an encoded asset, returning an opaque ID.
102+func (s *Scene) AddAsset(blob AssetBlob) (AssetID, error) {
103+ if s == nil {
104+ return AssetID{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
105+ }
106+ s.mu.Lock()
107+ defer s.mu.Unlock()
108+ candidate := cloneState(s.state)
109+ record, err := s.prepareAsset(blob, candidate)
110+ if err != nil {
111+ return AssetID{}, err
112+ }
113+ id, ok := takeAssetID(candidate)
114+ if !ok {
115+ return AssetID{}, fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
116+ }
117+ record.id = id
118+ candidate.assets[id] = record
119+ candidate.usage.Assets++
120+ candidate.usage.EncodedBytes += uint64(len(record.encoded))
121+ candidate.usage.DecodedPixels += record.pixels
122+ if err := advanceGeneration(candidate); err != nil {
123+ return AssetID{}, err
124+ }
125+ s.state = candidate
126+ return id, nil
127+}
128+
129+// RegisterAsset is an alias for AddAsset.
130+func (s *Scene) RegisterAsset(blob AssetBlob) (AssetID, error) { return s.AddAsset(blob) }
131+
132+// AddEncodedAsset registers an asset from its encoded bytes and decoded pixel
133+// dimensions. The input bytes are copied before this method returns.
134+func (s *Scene) AddEncodedAsset(encoded []byte, width, height int64) (AssetID, error) {
135+ return s.AddAsset(AssetBlob{Encoded: encoded, Width: width, Height: height})
136+}
137+
138+// RemoveAsset removes an unreferenced asset. Use RemoveAssetCascade when the
139+// asset's placements should be removed as one transaction.
140+func (s *Scene) RemoveAsset(id AssetID) error {
141+ if s == nil {
142+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
143+ }
144+ s.mu.Lock()
145+ defer s.mu.Unlock()
146+ candidate := cloneState(s.state)
147+ if err := removeAsset(candidate, id, false); err != nil {
148+ return err
149+ }
150+ if err := advanceGeneration(candidate); err != nil {
151+ return fmt.Errorf("remove asset: %w", err)
152+ }
153+ s.state = candidate
154+ return nil
155+}
156+
157+// RemoveAssetCascade removes an asset and all placements that reference it in
158+// one generation.
159+func (s *Scene) RemoveAssetCascade(id AssetID) error {
160+ if s == nil {
161+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
162+ }
163+ s.mu.Lock()
164+ defer s.mu.Unlock()
165+ candidate := cloneState(s.state)
166+ if err := removeAsset(candidate, id, true); err != nil {
167+ return err
168+ }
169+ if err := advanceGeneration(candidate); err != nil {
170+ return fmt.Errorf("remove asset: %w", err)
171+ }
172+ s.state = candidate
173+ return nil
174+}
175+
176+// Place adds one sparse placement and returns its opaque ID.
177+func (s *Scene) Place(spec PlacementSpec) (PlacementID, error) {
178+ if s == nil {
179+ return PlacementID{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
180+ }
181+ s.mu.Lock()
182+ defer s.mu.Unlock()
183+ candidate := cloneState(s.state)
184+ record, err := s.preparePlacement(candidate, spec, nil)
185+ if err != nil {
186+ return PlacementID{}, err
187+ }
188+ if uint64(len(candidate.placements)) >= s.limits.MaxPlacements {
189+ return PlacementID{}, fmt.Errorf("place: %w", ErrTooManyPlacements)
190+ }
191+ id, ok := takePlacementID(candidate)
192+ if !ok {
193+ return PlacementID{}, fmt.Errorf("place: %w", ErrIdentifierOverflow)
194+ }
195+ record.id = id
196+ record.order = takeOrder(candidate)
197+ candidate.placements[id] = record
198+ candidate.usage.Placements++
199+ if err := advanceGeneration(candidate); err != nil {
200+ return PlacementID{}, fmt.Errorf("place: %w", err)
201+ }
202+ s.state = candidate
203+ return id, nil
204+}
205+
206+// AddPlacement is an alias for Place.
207+func (s *Scene) AddPlacement(spec PlacementSpec) (PlacementID, error) { return s.Place(spec) }
208+
209+// PlaceAsset creates a full-source placement of an asset.
210+func (s *Scene) PlaceAsset(asset AssetID, destination PixelRect) (PlacementID, error) {
211+ return s.Place(PlacementSpec{Asset: asset, Destination: destination})
212+}
213+
214+// UpdatePlacement replaces the geometry and optional metadata of a placement.
215+// If spec.Asset and spec.AssetID are both invalid, the existing asset is kept.
216+func (s *Scene) UpdatePlacement(id PlacementID, spec PlacementSpec) error {
217+ if s == nil {
218+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
219+ }
220+ s.mu.Lock()
221+ defer s.mu.Unlock()
222+ candidate := cloneState(s.state)
223+ existing, ok := candidate.placements[id]
224+ if !ok {
225+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
226+ }
227+ record, err := s.preparePlacement(candidate, spec, &existing)
228+ if err != nil {
229+ return err
230+ }
231+ record.id = id
232+ record.order = existing.order
233+ candidate.placements[id] = record
234+ if err := advanceGeneration(candidate); err != nil {
235+ return fmt.Errorf("update placement: %w", err)
236+ }
237+ s.state = candidate
238+ return nil
239+}
240+
241+// MovePlacement changes only the destination rectangle of a placement.
242+func (s *Scene) MovePlacement(id PlacementID, destination PixelRect) error {
243+ return s.UpdatePlacement(id, PlacementSpec{Destination: destination})
244+}
245+
246+// RemovePlacement removes one sparse placement.
247+func (s *Scene) RemovePlacement(id PlacementID) error {
248+ if s == nil {
249+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
250+ }
251+ s.mu.Lock()
252+ defer s.mu.Unlock()
253+ candidate := cloneState(s.state)
254+ if _, ok := candidate.placements[id]; !ok {
255+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
256+ }
257+ delete(candidate.placements, id)
258+ candidate.usage.Placements--
259+ if err := advanceGeneration(candidate); err != nil {
260+ return fmt.Errorf("remove placement: %w", err)
261+ }
262+ s.state = candidate
263+ return nil
264+}
265+
266+// Clear removes every asset and placement while preserving the monotonic ID
267+// counters. It publishes a new generation only when the scene is non-empty.
268+func (s *Scene) Clear() error {
269+ if s == nil {
270+ return nil
271+ }
272+ s.mu.Lock()
273+ defer s.mu.Unlock()
274+ if len(s.state.assets) == 0 && len(s.state.placements) == 0 {
275+ return nil
276+ }
277+ candidate := cloneState(s.state)
278+ candidate.assets = make(map[AssetID]assetRecord)
279+ candidate.placements = make(map[PlacementID]placementRecord)
280+ candidate.usage = Usage{}
281+ if err := advanceGeneration(candidate); err != nil {
282+ return fmt.Errorf("clear scene: %w", err)
283+ }
284+ s.state = candidate
285+ return nil
286+}
287+
288+// Apply commits sparse operations transactionally. If any operation fails,
289+// neither the state nor the generation changes.
290+func (s *Scene) Apply(operations ...Operation) error {
291+ if s == nil {
292+ return fmt.Errorf("apply operations: %w", ErrInvalidOperation)
293+ }
294+ if len(operations) == 0 {
295+ return nil
296+ }
297+ s.mu.Lock()
298+ defer s.mu.Unlock()
299+ candidate := cloneState(s.state)
300+ for i, operation := range operations {
301+ if err := s.applyOperation(candidate, operation); err != nil {
302+ return fmt.Errorf("apply operation %d: %w", i, err)
303+ }
304+ }
305+ if err := advanceGeneration(candidate); err != nil {
306+ return fmt.Errorf("apply operations: %w", err)
307+ }
308+ s.state = candidate
309+ return nil
310+}
311+
312+// ApplyOperations is the slice-taking form of Apply.
313+func (s *Scene) ApplyOperations(operations []Operation) error { return s.Apply(operations...) }
314+
315+// Snapshot returns an immutable reference to the current scene state. Future
316+// scene mutations use copy-on-write maps and cannot alter this snapshot.
317+func (s *Scene) Snapshot() *Snapshot {
318+ if s == nil {
319+ return &Snapshot{state: emptyState()}
320+ }
321+ s.mu.RLock()
322+ defer s.mu.RUnlock()
323+ return &Snapshot{state: s.state}
324+}
325+
326+// Generation returns the current scene generation.
327+func (s *Scene) Generation() Generation {
328+ if s == nil {
329+ return 0
330+ }
331+ s.mu.RLock()
332+ defer s.mu.RUnlock()
333+ return s.state.generation
334+}
335+
336+// Usage returns the current live resource usage.
337+func (s *Scene) Usage() Usage {
338+ if s == nil {
339+ return Usage{}
340+ }
341+ s.mu.RLock()
342+ defer s.mu.RUnlock()
343+ return s.state.usage
344+}
345+
346+func (s *Scene) prepareAsset(blob AssetBlob, state *sceneState) (assetRecord, error) {
347+ data := blob.Encoded
348+ if data == nil {
349+ data = blob.Data
350+ }
351+ if len(data) == 0 && blob.Encoded != nil && blob.Data != nil && len(blob.Data) != 0 {
352+ data = blob.Data
353+ }
354+ if blob.Width <= 0 || blob.Height <= 0 {
355+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
356+ }
357+ pixels, ok := decodedPixels(blob.Width, blob.Height, blob.DecodedPixels)
358+ if !ok {
359+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
360+ }
361+ encoded := uint64(len(data))
362+ if encoded > s.limits.MaxEncodedBytesPerAsset {
363+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
364+ }
365+ if pixels > s.limits.MaxDecodedPixelsPerAsset {
366+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
367+ }
368+ if uint64(len(state.assets)) >= s.limits.MaxAssets {
369+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrTooManyAssets)
370+ }
371+ if !within(state.usage.EncodedBytes, encoded, s.limits.MaxEncodedBytes) {
372+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
373+ }
374+ if !within(state.usage.DecodedPixels, pixels, s.limits.MaxDecodedPixels) {
375+ return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
376+ }
377+ return assetRecord{encoded: append([]byte(nil), data...), width: blob.Width, height: blob.Height, pixels: pixels}, nil
378+}
379+
380+func (s *Scene) preparePlacement(state *sceneState, spec PlacementSpec, existing *placementRecord) (placementRecord, error) {
381+ asset := spec.Asset
382+ if !asset.Valid() {
383+ asset = spec.AssetID
384+ }
385+ if !asset.Valid() && existing != nil {
386+ asset = existing.asset
387+ }
388+ recordAsset, ok := state.assets[asset]
389+ if !ok {
390+ return placementRecord{}, fmt.Errorf("place: %w", ErrAssetNotFound)
391+ }
392+ destination := spec.Destination
393+ if destination == (PixelRect{}) {
394+ destination = spec.Dest
395+ }
396+ if existing != nil && destination == (PixelRect{}) {
397+ destination = existing.destination
398+ }
399+ if !destination.Valid() || destination.Empty() {
400+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
401+ }
402+ source := spec.Source
403+ if source == (PixelRect{}) {
404+ if existing != nil && spec.Source == (PixelRect{}) {
405+ source = existing.source
406+ } else {
407+ source = PixelRect{Width: recordAsset.width, Height: recordAsset.height}
408+ }
409+ }
410+ if !source.Valid() || source.Empty() {
411+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
412+ }
413+ assetBounds := PixelRect{Width: recordAsset.width, Height: recordAsset.height}
414+ if _, ok := assetBounds.Intersect(source); !ok || source != assetBounds && !containsRect(assetBounds, source) {
415+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
416+ }
417+ cells := spec.Cells
418+ if cells == (CellRect{}) {
419+ cells = spec.CellBounds
420+ }
421+ if existing != nil && cells == (CellRect{}) {
422+ cells = existing.cells
423+ }
424+ if cells != (CellRect{}) && (!cells.Valid() || cells.Empty()) {
425+ return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
426+ }
427+ layer := spec.Layer
428+ if existing != nil && layer == 0 {
429+ layer = existing.layer
430+ }
431+ return placementRecord{asset: asset, source: source, destination: destination, cells: cells, layer: layer}, nil
432+}
433+
434+func containsRect(outer, inner PixelRect) bool {
435+ right, okRight := outer.Right()
436+ innerRight, okInnerRight := inner.Right()
437+ bottom, okBottom := outer.Bottom()
438+ innerBottom, okInnerBottom := inner.Bottom()
439+ return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
440+}
441+
442+func removeAsset(state *sceneState, id AssetID, cascade bool) error {
443+ record, ok := state.assets[id]
444+ if !ok {
445+ return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
446+ }
447+ for placementID, placement := range state.placements {
448+ if placement.asset != id {
449+ continue
450+ }
451+ if !cascade {
452+ return fmt.Errorf("remove asset %s: %w", id, ErrAssetInUse)
453+ }
454+ delete(state.placements, placementID)
455+ state.usage.Placements--
456+ }
457+ delete(state.assets, id)
458+ state.usage.Assets--
459+ state.usage.EncodedBytes -= uint64(len(record.encoded))
460+ state.usage.DecodedPixels -= record.pixels
461+ return nil
462+}
463+
464+func (s *Scene) applyOperation(state *sceneState, operation Operation) error {
465+ switch operation.Kind {
466+ case OperationAddAsset:
467+ record, err := s.prepareAsset(operation.Blob, state)
468+ if err != nil {
469+ return err
470+ }
471+ id, ok := takeAssetID(state)
472+ if !ok {
473+ return fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
474+ }
475+ record.id = id
476+ state.assets[id] = record
477+ state.usage.Assets++
478+ state.usage.EncodedBytes += uint64(len(record.encoded))
479+ state.usage.DecodedPixels += record.pixels
480+ case OperationRemoveAsset:
481+ id := operation.Asset
482+ if !id.Valid() {
483+ id = operation.AssetID
484+ }
485+ if err := removeAsset(state, id, false); err != nil {
486+ return err
487+ }
488+ case OperationPlace:
489+ record, err := s.preparePlacement(state, operation.Placement, nil)
490+ if err != nil {
491+ return err
492+ }
493+ if uint64(len(state.placements)) >= s.limits.MaxPlacements {
494+ return fmt.Errorf("place: %w", ErrTooManyPlacements)
495+ }
496+ id, ok := takePlacementID(state)
497+ if !ok {
498+ return fmt.Errorf("place: %w", ErrIdentifierOverflow)
499+ }
500+ record.id = id
501+ record.order = takeOrder(state)
502+ state.placements[id] = record
503+ state.usage.Placements++
504+ case OperationUpdatePlacement:
505+ existing, ok := state.placements[operation.PlacementID]
506+ if !ok {
507+ return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
508+ }
509+ record, err := s.preparePlacement(state, operation.Placement, &existing)
510+ if err != nil {
511+ return err
512+ }
513+ record.id = existing.id
514+ record.order = existing.order
515+ state.placements[existing.id] = record
516+ case OperationRemovePlacement:
517+ if _, ok := state.placements[operation.PlacementID]; !ok {
518+ return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
519+ }
520+ delete(state.placements, operation.PlacementID)
521+ state.usage.Placements--
522+ default:
523+ return fmt.Errorf("operation kind %d: %w", operation.Kind, ErrInvalidOperation)
524+ }
525+ return nil
526+}
527+
528+func cloneState(state *sceneState) *sceneState {
529+ if state == nil {
530+ return emptyState()
531+ }
532+ clone := *state
533+ clone.assets = make(map[AssetID]assetRecord, len(state.assets))
534+ for id, record := range state.assets {
535+ clone.assets[id] = record
536+ }
537+ clone.placements = make(map[PlacementID]placementRecord, len(state.placements))
538+ for id, record := range state.placements {
539+ clone.placements[id] = record
540+ }
541+ return &clone
542+}
543+
544+func advanceGeneration(state *sceneState) error {
545+ if state.generation == ^uint64(0) {
546+ return ErrGenerationOverflow
547+ }
548+ state.generation++
549+ return nil
550+}
551+
552+func takeAssetID(state *sceneState) (AssetID, bool) {
553+ if state.nextAsset == 0 {
554+ return AssetID{}, false
555+ }
556+ id := AssetID{value: state.nextAsset}
557+ if state.nextAsset == ^uint64(0) {
558+ state.nextAsset = 0
559+ } else {
560+ state.nextAsset++
561+ }
562+ return id, true
563+}
564+
565+func takePlacementID(state *sceneState) (PlacementID, bool) {
566+ if state.nextPlace == 0 {
567+ return PlacementID{}, false
568+ }
569+ id := PlacementID{value: state.nextPlace}
570+ if state.nextPlace == ^uint64(0) {
571+ state.nextPlace = 0
572+ } else {
573+ state.nextPlace++
574+ }
575+ return id, true
576+}
577+
578+func takeOrder(state *sceneState) uint64 {
579+ order := state.nextOrder
580+ if state.nextOrder != ^uint64(0) {
581+ state.nextOrder++
582+ } else {
583+ state.nextOrder = 0
584+ }
585+ return order
586+}
587+
588+func within(current, additional, limit uint64) bool {
589+ if current > limit {
590+ return false
591+ }
592+ return additional <= limit-current
593+}
594+
595+func decodedPixels(width, height int64, declared uint64) (uint64, bool) {
596+ if width <= 0 || height <= 0 {
597+ return 0, false
598+ }
599+ w, h := uint64(width), uint64(height)
600+ if h != 0 && w > ^uint64(0)/h {
601+ return 0, false
602+ }
603+ computed := w * h
604+ if declared != 0 {
605+ return declared, true
606+ }
607+ return computed, true
608+}
609+
610+// AssetView is an immutable view of a registered asset. Encoded returns a new
611+// byte slice on every call; no caller can mutate scene or snapshot storage.
612+type AssetView struct {
613+ id AssetID
614+ encoded []byte
615+ width int64
616+ height int64
617+ pixels uint64
618+}
619+
620+// Asset is an alias for the immutable snapshot view.
621+type Asset = AssetView
622+
623+func (a AssetView) ID() AssetID { return a.id }
624+func (a AssetView) Width() int64 { return a.width }
625+func (a AssetView) Height() int64 { return a.height }
626+func (a AssetView) DecodedPixels() uint64 { return a.pixels }
627+func (a AssetView) EncodedSize() uint64 { return uint64(len(a.encoded)) }
628+func (a AssetView) Encoded() []byte { return append([]byte(nil), a.encoded...) }
629+func (a AssetView) Bytes() []byte { return a.Encoded() }
630+func (a AssetView) Blob() AssetBlob {
631+ return AssetBlob{Encoded: a.Encoded(), Width: a.width, Height: a.height, DecodedPixels: a.pixels}
632+}
633+
634+// PlacementView is an immutable view of a sparse placement.
635+type PlacementView struct {
636+ id PlacementID
637+ asset AssetID
638+ source PixelRect
639+ destination PixelRect
640+ cells CellRect
641+ layer int64
642+}
643+
644+// Placement is an alias for the immutable snapshot view.
645+type Placement = PlacementView
646+
647+func (p PlacementView) ID() PlacementID { return p.id }
648+func (p PlacementView) AssetID() AssetID { return p.asset }
649+func (p PlacementView) Source() PixelRect { return p.source }
650+func (p PlacementView) Destination() PixelRect { return p.destination }
651+func (p PlacementView) Cells() CellRect { return p.cells }
652+func (p PlacementView) Layer() int64 { return p.layer }
653+func (p PlacementView) Spec() PlacementSpec {
654+ return PlacementSpec{Asset: p.asset, Source: p.source, Destination: p.destination, Cells: p.cells, Layer: p.layer}
655+}
656+
657+// Snapshot is an immutable reference to a scene state.
658+type Snapshot struct{ state *sceneState }
659+
660+// Generation is a monotonically increasing scene generation.
661+func (s *Snapshot) Generation() uint64 {
662+ if s == nil || s.state == nil {
663+ return 0
664+ }
665+ return s.state.generation
666+}
667+
668+// Usage reports the resources retained by this snapshot's state.
669+func (s *Snapshot) Usage() Usage {
670+ if s == nil || s.state == nil {
671+ return Usage{}
672+ }
673+ return s.state.usage
674+}
675+
676+// Asset returns an immutable asset view by ID.
677+func (s *Snapshot) Asset(id AssetID) (AssetView, bool) {
678+ if s == nil || s.state == nil {
679+ return AssetView{}, false
680+ }
681+ record, ok := s.state.assets[id]
682+ if !ok {
683+ return AssetView{}, false
684+ }
685+ return AssetView{id: record.id, encoded: record.encoded, width: record.width, height: record.height, pixels: record.pixels}, true
686+}
687+
688+// GetAsset is an alias for Asset.
689+func (s *Snapshot) GetAsset(id AssetID) (AssetView, bool) { return s.Asset(id) }
690+
691+// Assets returns immutable asset views in ID order.
692+func (s *Snapshot) Assets() []AssetView {
693+ if s == nil || s.state == nil || len(s.state.assets) == 0 {
694+ return nil
695+ }
696+ ids := make([]AssetID, 0, len(s.state.assets))
697+ for id := range s.state.assets {
698+ ids = append(ids, id)
699+ }
700+ sort.Slice(ids, func(i, j int) bool { return ids[i].value < ids[j].value })
701+ assets := make([]AssetView, 0, len(ids))
702+ for _, id := range ids {
703+ asset, _ := s.Asset(id)
704+ assets = append(assets, asset)
705+ }
706+ return assets
707+}
708+
709+// Placement returns an immutable placement view by ID.
710+func (s *Snapshot) Placement(id PlacementID) (PlacementView, bool) {
711+ if s == nil || s.state == nil {
712+ return PlacementView{}, false
713+ }
714+ record, ok := s.state.placements[id]
715+ if !ok {
716+ return PlacementView{}, false
717+ }
718+ return PlacementView{id: record.id, asset: record.asset, source: record.source, destination: record.destination, cells: record.cells, layer: record.layer}, true
719+}
720+
721+// GetPlacement is an alias for Placement.
722+func (s *Snapshot) GetPlacement(id PlacementID) (PlacementView, bool) { return s.Placement(id) }
723+
724+// Placements returns immutable placement views in layer and insertion order.
725+func (s *Snapshot) Placements() []PlacementView {
726+ if s == nil || s.state == nil || len(s.state.placements) == 0 {
727+ return nil
728+ }
729+ placements := make([]placementRecord, 0, len(s.state.placements))
730+ for _, placement := range s.state.placements {
731+ placements = append(placements, placement)
732+ }
733+ sort.SliceStable(placements, func(i, j int) bool {
734+ if placements[i].layer != placements[j].layer {
735+ return placements[i].layer < placements[j].layer
736+ }
737+ return placements[i].order < placements[j].order
738+ })
739+ views := make([]PlacementView, 0, len(placements))
740+ for _, placement := range placements {
741+ views = append(views, PlacementView{id: placement.id, asset: placement.asset, source: placement.source, destination: placement.destination, cells: placement.cells, layer: placement.layer})
742+ }
743+ return views
744+}
745+
746+// VisibleFragments returns clipped placement fragments. viewport may be a
747+// PixelRect, CellRect, or Viewport. Unsupported or invalid viewport values
748+// produce no fragments rather than wrapping coordinates.
749+func (s *Snapshot) VisibleFragments(viewport any) []VisibleFragment {
750+ if s == nil || s.state == nil {
751+ return nil
752+ }
753+ pixels, cells, hasPixels, hasCells := viewportParts(viewport)
754+ if !hasPixels && !hasCells {
755+ return nil
756+ }
757+ placements := s.Placements()
758+ fragments := make([]VisibleFragment, 0, len(placements))
759+ for _, placement := range placements {
760+ visible, ok := visiblePlacement(placement, pixels, cells, hasPixels, hasCells)
761+ if ok {
762+ fragments = append(fragments, visible)
763+ }
764+ }
765+ return fragments
766+}
767+
768+// VisiblePixelFragments is the typed pixel-space form of VisibleFragments.
769+func (s *Snapshot) VisiblePixelFragments(viewport PixelRect) []VisibleFragment {
770+ return s.VisibleFragments(viewport)
771+}
772+
773+// VisibleCellFragments is the typed cell-space form of VisibleFragments.
774+func (s *Snapshot) VisibleCellFragments(viewport CellRect) []VisibleFragment {
775+ return s.VisibleFragments(viewport)
776+}
777+
778+func viewportParts(viewport any) (PixelRect, CellRect, bool, bool) {
779+ switch value := viewport.(type) {
780+ case PixelRect:
781+ return value, CellRect{}, value.Valid() && !value.Empty(), false
782+ case CellRect:
783+ return PixelRect{}, value, false, value.Valid() && !value.Empty()
784+ case Viewport:
785+ hasPixels := value.Pixels.Valid() && !value.Pixels.Empty()
786+ hasCells := value.Cells.Valid() && !value.Cells.Empty()
787+ return value.Pixels, value.Cells, hasPixels, hasCells
788+ case *Viewport:
789+ if value == nil {
790+ return PixelRect{}, CellRect{}, false, false
791+ }
792+ return viewportParts(*value)
793+ default:
794+ return PixelRect{}, CellRect{}, false, false
795+ }
796+}
797+
798+func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect, hasPixels, hasCells bool) (VisibleFragment, bool) {
799+ destination := placement.destination
800+ if hasPixels {
801+ var ok bool
802+ destination, ok = destination.Intersect(pixels)
803+ if !ok {
804+ return VisibleFragment{}, false
805+ }
806+ }
807+ var visibleCells CellRect
808+ if placement.cells.Valid() && !placement.cells.Empty() {
809+ visibleCells = placement.cells
810+ if hasCells {
811+ var ok bool
812+ visibleCells, ok = visibleCells.Intersect(cells)
813+ if !ok {
814+ return VisibleFragment{}, false
815+ }
816+ cellPixels, ok := mapSubRectCellToPixel(placement.cells, placement.destination, visibleCells)
817+ if !ok {
818+ return VisibleFragment{}, false
819+ }
820+ var pixelOK bool
821+ destination, pixelOK = destination.Intersect(cellPixels)
822+ if !pixelOK {
823+ return VisibleFragment{}, false
824+ }
825+ }
826+ if hasPixels {
827+ var ok bool
828+ visibleCells, ok = mapSubRectPixelToCell(placement.destination, placement.cells, destination)
829+ if !ok {
830+ return VisibleFragment{}, false
831+ }
832+ if hasCells {
833+ visibleCells, ok = visibleCells.Intersect(cells)
834+ if !ok {
835+ return VisibleFragment{}, false
836+ }
837+ }
838+ }
839+ } else if hasCells {
840+ return VisibleFragment{}, false
841+ }
842+ source, ok := mapSubRect(placement.destination, placement.source, destination)
843+ if !ok {
844+ return VisibleFragment{}, false
845+ }
846+ return VisibleFragment{PlacementID: placement.id, AssetID: placement.asset, Source: source, Destination: destination, Cells: visibleCells}, true
847+}
848+
849+// mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate
850+// space into another rectangle's coordinate space. It uses exact 128+-bit
851+// arithmetic through math/big so products cannot wrap before division.
852+func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) {
853+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
854+ return PixelRect{}, false
855+ }
856+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
857+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
858+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
859+ return PixelRect{}, false
860+ }
861+ return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
862+}
863+
864+func mapSubRectCellToPixel(from CellRect, to PixelRect, sub CellRect) (PixelRect, bool) {
865+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsCellRect(from, sub) {
866+ return PixelRect{}, false
867+ }
868+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
869+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
870+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
871+ return PixelRect{}, false
872+ }
873+ return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
874+}
875+
876+func mapSubRectPixelToCell(from PixelRect, to CellRect, sub PixelRect) (CellRect, bool) {
877+ if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
878+ return CellRect{}, false
879+ }
880+ x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
881+ y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
882+ if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
883+ return CellRect{}, false
884+ }
885+ return CellRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
886+}
887+
888+func containsCellRect(outer, inner CellRect) bool {
889+ right, okRight := outer.Right()
890+ innerRight, okInnerRight := inner.Right()
891+ bottom, okBottom := outer.Bottom()
892+ innerBottom, okInnerBottom := inner.Bottom()
893+ return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
894+}
895+
896+func mustRight(start, width int64) int64 {
897+ right, _ := checkedEdge(start, width)
898+ return right
899+}
900+
901+func mapRange(fromStart, fromSize, toStart, toSize, clipStart, clipEnd int64) ([2]int64, bool) {
902+ if fromSize <= 0 || toSize <= 0 || clipStart < fromStart || clipEnd < clipStart {
903+ return [2]int64{}, false
904+ }
905+ fromEnd, ok := checkedEdge(fromStart, fromSize)
906+ if !ok || clipEnd > fromEnd {
907+ return [2]int64{}, false
908+ }
909+ startOffset := clipStart - fromStart
910+ endOffset := clipEnd - fromStart
911+ startDelta, ok := ratio(startOffset, toSize, fromSize, false)
912+ if !ok {
913+ return [2]int64{}, false
914+ }
915+ endDelta, ok := ratio(endOffset, toSize, fromSize, true)
916+ if !ok {
917+ return [2]int64{}, false
918+ }
919+ start, ok := checkedAdd(toStart, startDelta)
920+ if !ok {
921+ return [2]int64{}, false
922+ }
923+ end, ok := checkedAdd(toStart, endDelta)
924+ if !ok || end <= start {
925+ return [2]int64{}, false
926+ }
927+ return [2]int64{start, end}, true
928+}
929+
930+func ratio(offset, numerator, denominator int64, ceil bool) (int64, bool) {
931+ if offset < 0 || numerator <= 0 || denominator <= 0 {
932+ return 0, false
933+ }
934+ product := new(big.Int).Mul(big.NewInt(offset), big.NewInt(numerator))
935+ quotient, remainder := new(big.Int), new(big.Int)
936+ quotient.QuoRem(product, big.NewInt(denominator), remainder)
937+ if ceil && remainder.Sign() != 0 {
938+ quotient.Add(quotient, big.NewInt(1))
939+ }
940+ if !quotient.IsInt64() || quotient.Sign() < 0 {
941+ return 0, false
942+ }
943+ return quotient.Int64(), true
944+}
945+
946+func checkedAdd(a, b int64) (int64, bool) {
947+ if b > 0 && a > maxInt64-b {
948+ return 0, false
949+ }
950+ if b < 0 && a < minInt64-b {
951+ return 0, false
952+ }
953+ return a + b, true
954+}
A graphics/scene_test.go +166 -0
@@ -0,0 +1,166 @@
1+package graphics
2+
3+import (
4+ "errors"
5+ "math"
6+ "testing"
7+)
8+
9+func testAsset(t *testing.T, data []byte, width, height int64) AssetBlob {
10+ t.Helper()
11+ return AssetBlob{Encoded: data, Width: width, Height: height}
12+}
13+
14+func TestRectOperationsRejectOverflow(t *testing.T) {
15+ if _, err := NewPixelRect(math.MaxInt64, 0, 1, 1); !errors.Is(err, ErrInvalidRect) {
16+ t.Fatalf("overflowing pixel rectangle error = %v", err)
17+ }
18+ if _, err := NewCellRect(0, math.MaxInt64, 1, 1); !errors.Is(err, ErrInvalidRect) {
19+ t.Fatalf("overflowing cell rectangle error = %v", err)
20+ }
21+ valid := PixelRect{X: math.MinInt64, Y: math.MinInt64, Width: math.MaxInt64, Height: math.MaxInt64}
22+ if !valid.Valid() {
23+ t.Fatal("maximum representable negative-origin rectangle should be valid")
24+ }
25+ clipped, ok := valid.Intersect(PixelRect{X: -2, Y: -2, Width: 1, Height: 1})
26+ if !ok || clipped != (PixelRect{X: -2, Y: -2, Width: 1, Height: 1}) {
27+ t.Fatalf("intersection = %#v, %v", clipped, ok)
28+ }
29+}
30+
31+func TestAssetBlobOwnershipAndSnapshotLifecycle(t *testing.T) {
32+ scene := NewScene(Limits{MaxEncodedBytes: 32, MaxDecodedPixels: 64})
33+ encoded := []byte{1, 2, 3}
34+ assetID, err := scene.AddAsset(testAsset(t, encoded, 4, 4))
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ encoded[0] = 99
39+
40+ before := scene.Snapshot()
41+ asset, ok := before.Asset(assetID)
42+ if !ok {
43+ t.Fatal("asset missing from snapshot")
44+ }
45+ got := asset.Encoded()
46+ got[1] = 88
47+ if got := asset.Encoded(); got[0] != 1 || got[1] != 2 || asset.EncodedSize() != 3 {
48+ t.Fatalf("asset bytes were not immutable copies: %v", got)
49+ }
50+
51+ placementID, err := scene.Place(PlacementSpec{Asset: assetID, Destination: PixelRect{Width: 4, Height: 4}})
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if err := scene.RemoveAsset(assetID); !errors.Is(err, ErrAssetInUse) {
56+ t.Fatalf("remove in-use asset error = %v", err)
57+ }
58+ if err := scene.RemoveAssetCascade(assetID); err != nil {
59+ t.Fatal(err)
60+ }
61+ if _, ok := scene.Snapshot().Asset(assetID); ok {
62+ t.Fatal("removed asset remained in current snapshot")
63+ }
64+ if _, ok := scene.Snapshot().Placement(placementID); ok {
65+ t.Fatal("cascade did not remove placement")
66+ }
67+ if _, ok := before.Asset(assetID); !ok {
68+ t.Fatal("old snapshot lost its asset after scene mutation")
69+ }
70+}
71+
72+func TestAssetAndPlacementQuotasAreTransactional(t *testing.T) {
73+ scene := NewScene(Limits{
74+ MaxAssets: 1,
75+ MaxPlacements: 1,
76+ MaxEncodedBytes: 4,
77+ MaxDecodedPixels: 4,
78+ })
79+ id, err := scene.AddAsset(testAsset(t, []byte{1, 2, 3, 4}, 2, 2))
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ generation := scene.Snapshot().Generation()
84+ if _, err := scene.AddAsset(testAsset(t, []byte{5}, 1, 1)); !errors.Is(err, ErrTooManyAssets) {
85+ t.Fatalf("asset quota error = %v", err)
86+ }
87+ if scene.Snapshot().Generation() != generation {
88+ t.Fatal("failed asset addition changed generation")
89+ }
90+ if _, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}); err != nil {
91+ t.Fatal(err)
92+ }
93+ if _, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{X: 2, Width: 2, Height: 2}}); !errors.Is(err, ErrTooManyPlacements) {
94+ t.Fatalf("placement quota error = %v", err)
95+ }
96+ if err := scene.Apply(
97+ Operation{Kind: OperationRemovePlacement, PlacementID: PlacementID{value: 1}},
98+ Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
99+ ); err != nil {
100+ t.Fatal(err)
101+ }
102+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.EncodedBytes != 4 || got.DecodedPixels != 4 {
103+ t.Fatalf("usage = %#v", got)
104+ }
105+}
106+
107+func TestSnapshotGenerationAndTransactionalApply(t *testing.T) {
108+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
109+ initial := scene.Snapshot()
110+ id, err := scene.AddAsset(testAsset(t, []byte{1}, 2, 2))
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ if got := scene.Snapshot().Generation(); got <= initial.Generation() {
115+ t.Fatalf("generation did not advance: initial=%d current=%d", initial.Generation(), got)
116+ }
117+ before := scene.Snapshot()
118+ err = scene.Apply(
119+ Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
120+ Operation{Kind: OperationRemovePlacement, PlacementID: PlacementID{value: 999}},
121+ )
122+ if !errors.Is(err, ErrPlacementNotFound) {
123+ t.Fatalf("transaction error = %v", err)
124+ }
125+ if scene.Snapshot().Generation() != before.Generation() || len(scene.Snapshot().Placements()) != 0 {
126+ t.Fatal("failed transaction partially committed")
127+ }
128+}
129+
130+func TestVisibleFragmentsPreserveScaledSourceMapping(t *testing.T) {
131+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 10000})
132+ assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 100, 50))
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ placementID, err := scene.Place(PlacementSpec{
137+ Asset: assetID,
138+ Destination: PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
139+ Cells: CellRect{X: 2, Y: 3, Width: 20, Height: 10},
140+ })
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ fragments := scene.Snapshot().VisibleFragments(PixelRect{X: 110, Y: 20, Width: 100, Height: 100})
145+ if len(fragments) != 1 {
146+ t.Fatalf("fragments = %#v", fragments)
147+ }
148+ fragment := fragments[0]
149+ if fragment.PlacementID != placementID || fragment.AssetID != assetID {
150+ t.Fatalf("fragment IDs = %#v", fragment)
151+ }
152+ if fragment.Destination != (PixelRect{X: 110, Y: 20, Width: 100, Height: 100}) {
153+ t.Fatalf("destination = %#v", fragment.Destination)
154+ }
155+ if fragment.Source != (PixelRect{X: 50, Y: 0, Width: 50, Height: 50}) {
156+ t.Fatalf("source mapping = %#v", fragment.Source)
157+ }
158+ if fragment.Cells != (CellRect{X: 12, Y: 3, Width: 10, Height: 10}) {
159+ t.Fatalf("cell mapping = %#v", fragment.Cells)
160+ }
161+
162+ cellFragments := scene.Snapshot().VisibleFragments(CellRect{X: 7, Y: 5, Width: 5, Height: 5})
163+ if len(cellFragments) != 1 || cellFragments[0].Destination != (PixelRect{X: 60, Y: 40, Width: 50, Height: 50}) {
164+ t.Fatalf("cell clipping = %#v", cellFragments)
165+ }
166+}
@@ -0,0 +1,166 @@
1+package graphics
2+
3+import (
4+ "errors"
5+ "math"
6+ "testing"
7+)
8+
9+func testAsset(t *testing.T, data []byte, width, height int64) AssetBlob {
10+ t.Helper()
11+ return AssetBlob{Encoded: data, Width: width, Height: height}
12+}
13+
14+func TestRectOperationsRejectOverflow(t *testing.T) {
15+ if _, err := NewPixelRect(math.MaxInt64, 0, 1, 1); !errors.Is(err, ErrInvalidRect) {
16+ t.Fatalf("overflowing pixel rectangle error = %v", err)
17+ }
18+ if _, err := NewCellRect(0, math.MaxInt64, 1, 1); !errors.Is(err, ErrInvalidRect) {
19+ t.Fatalf("overflowing cell rectangle error = %v", err)
20+ }
21+ valid := PixelRect{X: math.MinInt64, Y: math.MinInt64, Width: math.MaxInt64, Height: math.MaxInt64}
22+ if !valid.Valid() {
23+ t.Fatal("maximum representable negative-origin rectangle should be valid")
24+ }
25+ clipped, ok := valid.Intersect(PixelRect{X: -2, Y: -2, Width: 1, Height: 1})
26+ if !ok || clipped != (PixelRect{X: -2, Y: -2, Width: 1, Height: 1}) {
27+ t.Fatalf("intersection = %#v, %v", clipped, ok)
28+ }
29+}
30+
31+func TestAssetBlobOwnershipAndSnapshotLifecycle(t *testing.T) {
32+ scene := NewScene(Limits{MaxEncodedBytes: 32, MaxDecodedPixels: 64})
33+ encoded := []byte{1, 2, 3}
34+ assetID, err := scene.AddAsset(testAsset(t, encoded, 4, 4))
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ encoded[0] = 99
39+
40+ before := scene.Snapshot()
41+ asset, ok := before.Asset(assetID)
42+ if !ok {
43+ t.Fatal("asset missing from snapshot")
44+ }
45+ got := asset.Encoded()
46+ got[1] = 88
47+ if got := asset.Encoded(); got[0] != 1 || got[1] != 2 || asset.EncodedSize() != 3 {
48+ t.Fatalf("asset bytes were not immutable copies: %v", got)
49+ }
50+
51+ placementID, err := scene.Place(PlacementSpec{Asset: assetID, Destination: PixelRect{Width: 4, Height: 4}})
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if err := scene.RemoveAsset(assetID); !errors.Is(err, ErrAssetInUse) {
56+ t.Fatalf("remove in-use asset error = %v", err)
57+ }
58+ if err := scene.RemoveAssetCascade(assetID); err != nil {
59+ t.Fatal(err)
60+ }
61+ if _, ok := scene.Snapshot().Asset(assetID); ok {
62+ t.Fatal("removed asset remained in current snapshot")
63+ }
64+ if _, ok := scene.Snapshot().Placement(placementID); ok {
65+ t.Fatal("cascade did not remove placement")
66+ }
67+ if _, ok := before.Asset(assetID); !ok {
68+ t.Fatal("old snapshot lost its asset after scene mutation")
69+ }
70+}
71+
72+func TestAssetAndPlacementQuotasAreTransactional(t *testing.T) {
73+ scene := NewScene(Limits{
74+ MaxAssets: 1,
75+ MaxPlacements: 1,
76+ MaxEncodedBytes: 4,
77+ MaxDecodedPixels: 4,
78+ })
79+ id, err := scene.AddAsset(testAsset(t, []byte{1, 2, 3, 4}, 2, 2))
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ generation := scene.Snapshot().Generation()
84+ if _, err := scene.AddAsset(testAsset(t, []byte{5}, 1, 1)); !errors.Is(err, ErrTooManyAssets) {
85+ t.Fatalf("asset quota error = %v", err)
86+ }
87+ if scene.Snapshot().Generation() != generation {
88+ t.Fatal("failed asset addition changed generation")
89+ }
90+ if _, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}); err != nil {
91+ t.Fatal(err)
92+ }
93+ if _, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{X: 2, Width: 2, Height: 2}}); !errors.Is(err, ErrTooManyPlacements) {
94+ t.Fatalf("placement quota error = %v", err)
95+ }
96+ if err := scene.Apply(
97+ Operation{Kind: OperationRemovePlacement, PlacementID: PlacementID{value: 1}},
98+ Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
99+ ); err != nil {
100+ t.Fatal(err)
101+ }
102+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.EncodedBytes != 4 || got.DecodedPixels != 4 {
103+ t.Fatalf("usage = %#v", got)
104+ }
105+}
106+
107+func TestSnapshotGenerationAndTransactionalApply(t *testing.T) {
108+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
109+ initial := scene.Snapshot()
110+ id, err := scene.AddAsset(testAsset(t, []byte{1}, 2, 2))
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ if got := scene.Snapshot().Generation(); got <= initial.Generation() {
115+ t.Fatalf("generation did not advance: initial=%d current=%d", initial.Generation(), got)
116+ }
117+ before := scene.Snapshot()
118+ err = scene.Apply(
119+ Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
120+ Operation{Kind: OperationRemovePlacement, PlacementID: PlacementID{value: 999}},
121+ )
122+ if !errors.Is(err, ErrPlacementNotFound) {
123+ t.Fatalf("transaction error = %v", err)
124+ }
125+ if scene.Snapshot().Generation() != before.Generation() || len(scene.Snapshot().Placements()) != 0 {
126+ t.Fatal("failed transaction partially committed")
127+ }
128+}
129+
130+func TestVisibleFragmentsPreserveScaledSourceMapping(t *testing.T) {
131+ scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 10000})
132+ assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 100, 50))
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ placementID, err := scene.Place(PlacementSpec{
137+ Asset: assetID,
138+ Destination: PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
139+ Cells: CellRect{X: 2, Y: 3, Width: 20, Height: 10},
140+ })
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ fragments := scene.Snapshot().VisibleFragments(PixelRect{X: 110, Y: 20, Width: 100, Height: 100})
145+ if len(fragments) != 1 {
146+ t.Fatalf("fragments = %#v", fragments)
147+ }
148+ fragment := fragments[0]
149+ if fragment.PlacementID != placementID || fragment.AssetID != assetID {
150+ t.Fatalf("fragment IDs = %#v", fragment)
151+ }
152+ if fragment.Destination != (PixelRect{X: 110, Y: 20, Width: 100, Height: 100}) {
153+ t.Fatalf("destination = %#v", fragment.Destination)
154+ }
155+ if fragment.Source != (PixelRect{X: 50, Y: 0, Width: 50, Height: 50}) {
156+ t.Fatalf("source mapping = %#v", fragment.Source)
157+ }
158+ if fragment.Cells != (CellRect{X: 12, Y: 3, Width: 10, Height: 10}) {
159+ t.Fatalf("cell mapping = %#v", fragment.Cells)
160+ }
161+
162+ cellFragments := scene.Snapshot().VisibleFragments(CellRect{X: 7, Y: 5, Width: 5, Height: 5})
163+ if len(cellFragments) != 1 || cellFragments[0].Destination != (PixelRect{X: 60, Y: 40, Width: 50, Height: 50}) {
164+ t.Fatalf("cell clipping = %#v", cellFragments)
165+ }
166+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close