1 Commits

Author SHA1 Message Date
Ian Gulliver
e1d8cefd22 WIP: iterative buildCells rewrite 2026-02-24 11:05:38 -08:00
8 changed files with 240 additions and 72 deletions

33
WIP.md Normal file
View File

@@ -0,0 +1,33 @@
# WIP: Iterative buildCells rewrite
## What's done
- Block has `weight uint64`, `triggers []*Trigger`, `topRow int` fields
- TriggerSource and TriggerTarget have `block *Block` pointer fields
- `linkTriggers()` resolves string IDs to block pointers, populates `block.triggers`
- `computeWeights()` sets weight = `(cue index) << 32` + 0 if cue-ended, 1 otherwise
- Higher weight = more likely to be moved
- Later cues = higher weight
- `buildCells()` is rewritten to be iterative:
1. Init: cues get sequential topRow, non-cues start at 0
2. Loop (trigger pass): move target blocks so trigger source row >= target hook row
3. Loop (overlap pass): sort same-track blocks by (topRow, weight), push overlaps to prev end + 1
4. After convergence: move each cue to max row of its triggered cells
5. `emitCells()` creates actual TimelineCell objects from computed topRows
- Test uses `BuildTimelineDebug` with testWriter for debug output
## What's broken
- Cue track comes out unordered after the cue-move step (cues placed at their highest triggered cell row, which scrambles scene order)
- Gap padding in emitCells skips cue track (`if block.Type != "cue"`) so cue cell rows don't match topRow
- The +1 gap between blocks may be wrong (was +2, changed to +1)
- assignRows still can't converge after buildCells — off-by-one misalignments between cue rows and target rows
## Key design decisions
- topRow coordinate space should include gap cells (emitCells just places cells at topRow)
- blockHeight: cue=0 (own track, can overlap), other=4
- Gap between same-track blocks = 1 row (chain or gap cell)
- Cross-track alignment still handled by assignRows solver after buildCells
## Next steps
- Fix cue track: need gap padding on cue track too so cell rows match topRow
- May need to rethink cue-move step — moving cues to highest triggered row scrambles cue order
- Verify topRow math matches actual emitted cell rows end-to-end

View File

@@ -98,7 +98,7 @@ func main() {
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
charW := font.MeasureString(stripFont, "0").Ceil()
pad := 8
for e := range count {
for e := 0; e < count; e++ {
ci := p.Start + e
ch := channels[ci]
cc := ch.Color
@@ -107,7 +107,10 @@ func main() {
valStr := fmt.Sprintf("%03d %02x", ch.Value, ch.Value)
nameW := font.MeasureString(stripFont, ch.Name).Ceil()
valW := font.MeasureString(stripFont, valStr).Ceil()
textW := max(valW, nameW)
textW := nameW
if valW > textW {
textW = valW
}
x0 := mid - textW/2 - pad
x1 := mid + textW/2 + pad
@@ -152,8 +155,8 @@ func main() {
if i == page {
img := image.NewRGBA(image.Rect(0, 0, sz, sz))
draw.Draw(img, img.Bounds(), txt, image.Point{}, draw.Src)
for y := range sz {
for x := range sz {
for y := 0; y < sz; y++ {
for x := 0; x < sz; x++ {
if x < b || x >= sz-b || y < b || y >= sz-b {
img.Set(x, y, color.RGBA{255, 255, 255, 255})
}

View File

@@ -1,7 +1,5 @@
package main
import "strings"
import "fmt"
type Show struct {
@@ -22,8 +20,9 @@ type Block struct {
Name string `json:"name"`
Loop bool `json:"loop,omitempty"`
weight int
weight uint64
triggers []*Trigger
topRow int
}
type Trigger struct {
@@ -32,12 +31,11 @@ type Trigger struct {
}
func (t *Trigger) String() string {
var s strings.Builder
s.WriteString(fmt.Sprintf("%s/%s ->", t.Source.Block, t.Source.Signal))
s := fmt.Sprintf("%s/%s ->", t.Source.Block, t.Source.Signal)
for _, target := range t.Targets {
s.WriteString(fmt.Sprintf(" %s/%s", target.Block, target.Hook))
s += fmt.Sprintf(" %s/%s", target.Block, target.Hook)
}
return s.String()
return s
}
type TriggerSource struct {

View File

@@ -49,7 +49,12 @@ func (t *TimelineTrack) cellTypeAt(index int, types ...CellType) bool {
if index < 0 || index >= len(t.Cells) {
return false
}
return slices.Contains(types, t.Cells[index].Type)
for _, typ := range types {
if t.Cells[index].Type == typ {
return true
}
}
return false
}
func (c *TimelineCell) String() string {
@@ -93,15 +98,14 @@ func (g exclusiveGroup) satisfied(tracks []*TimelineTrack) bool {
}
func (g exclusiveGroup) String() string {
var s strings.Builder
s.WriteString("exclusive(")
s := "exclusive("
for i, m := range g.members {
if i > 0 {
s.WriteString(", ")
s += ", "
}
s.WriteString(m.String())
s += m.String()
}
return s.String() + ")"
return s + ")"
}
func (tl *Timeline) debugf(format string, args ...any) {
@@ -212,44 +216,38 @@ func (tl *Timeline) linkTriggers() {
}
func (tl *Timeline) computeWeights() {
for _, block := range tl.show.Blocks {
block.weight = 0
}
cueEndedBlocks := map[string]bool{}
for _, trigger := range tl.show.Triggers {
if trigger.Source.block.Type != "cue" {
continue
}
for _, target := range trigger.Targets {
if target.Hook == "END" || target.Hook == "FADE_OUT" {
if target.block.weight < 1 {
target.block.weight = 1
}
cueEndedBlocks[target.Block] = true
}
}
}
cueIdx := uint64(0)
for _, block := range tl.show.Blocks {
if block.Type == "cue" {
tl.computeWeightDFS(block)
tl.setWeightDFS(block, cueIdx<<32, cueEndedBlocks)
cueIdx++
}
}
}
func (tl *Timeline) computeWeightDFS(b *Block) int {
maxChild := 0
func (tl *Timeline) setWeightDFS(b *Block, base uint64, cueEndedBlocks map[string]bool) {
if cueEndedBlocks[b.ID] {
b.weight = base
} else {
b.weight = base + 1
}
for _, trigger := range b.triggers {
for _, target := range trigger.Targets {
w := tl.computeWeightDFS(target.block)
if w > maxChild {
maxChild = w
}
tl.setWeightDFS(target.block, base, cueEndedBlocks)
}
}
if maxChild > b.weight {
b.weight = maxChild
}
return b.weight
}
func (tl *Timeline) findEndChains() map[string]bool {
@@ -303,34 +301,163 @@ func (tl *Timeline) findCell(blockID, event string) *TimelineCell {
panic("cell not found: " + blockID + " " + event)
}
func blockHeight(b *Block) int {
if b.Type == "cue" {
return 0
}
return 4
}
func eventOffset(event string) int {
switch event {
case "GO":
return 0
case "START":
return 0
case "FADE_OUT":
return 2
case "END":
return 3
default:
return 0
}
}
func (tl *Timeline) buildCells() {
endChains := tl.findEndChains()
lastOnTrack := map[string]*Block{}
cueIdx := 0
for _, block := range tl.show.Blocks {
lastOnTrack[block.Track] = block
if block.Type == "cue" {
block.topRow = cueIdx
cueIdx++
} else {
block.topRow = 0
}
}
for range 1000 {
changed := false
for _, trigger := range tl.show.Triggers {
srcRow := trigger.Source.block.topRow + eventOffset(trigger.Source.Signal)
for _, target := range trigger.Targets {
targetRow := srcRow - eventOffset(target.Hook)
if target.block.topRow < targetRow {
target.block.topRow = targetRow
changed = true
}
}
}
for _, track := range tl.Tracks {
trackBlocks := tl.blocksByTrack(track.ID)
slices.SortFunc(trackBlocks, func(a, b *Block) int {
if a.topRow != b.topRow {
return a.topRow - b.topRow
}
if a.weight != b.weight {
if a.weight < b.weight {
return -1
}
return 1
}
return 0
})
for i := 1; i < len(trackBlocks); i++ {
prev := trackBlocks[i-1]
cur := trackBlocks[i]
minRow := prev.topRow + blockHeight(prev) + 1
if cur.topRow < minRow {
cur.topRow = minRow
changed = true
}
}
}
if !changed {
break
}
}
for _, block := range tl.show.Blocks {
track := tl.trackIdx[block.Track]
var cells []*TimelineCell
switch block.Type {
case "cue":
cells = getCueCells(block)
default:
cells = getBlockCells(block)
if block.Type != "cue" {
continue
}
track.appendCells(cells...)
for _, c := range cells {
if c.Event == "" {
continue
maxRow := block.topRow
for _, trigger := range block.triggers {
for _, target := range trigger.Targets {
r := target.block.topRow + eventOffset(target.Hook)
if r > maxRow {
maxRow = r
}
}
tl.cellIdx[cellKey{blockID: c.BlockID, event: c.Event}] = c
}
if block.Type != "cue" && lastOnTrack[block.Track] != block {
if endChains[block.ID] {
track.appendCells(&TimelineCell{Type: CellChain})
} else {
track.appendCells(&TimelineCell{Type: CellGap})
block.topRow = maxRow
}
tl.emitCells()
tl.debugState()
}
func (tl *Timeline) blocksByTrack(trackID string) []*Block {
var blocks []*Block
for _, block := range tl.show.Blocks {
if block.Track == trackID {
blocks = append(blocks, block)
}
}
return blocks
}
func (tl *Timeline) emitCells() {
endChains := tl.findEndChains()
type trackEntry struct {
block *Block
}
trackBlocks := map[string][]*Block{}
for _, block := range tl.show.Blocks {
trackBlocks[block.Track] = append(trackBlocks[block.Track], block)
}
for trackID, blocks := range trackBlocks {
track := tl.trackIdx[trackID]
slices.SortFunc(blocks, func(a, b *Block) int {
return a.topRow - b.topRow
})
row := 0
for i, block := range blocks {
for row < block.topRow {
if block.Type != "cue" {
track.appendCells(&TimelineCell{Type: CellGap})
}
row++
}
var cells []*TimelineCell
switch block.Type {
case "cue":
cells = getCueCells(block)
default:
cells = getBlockCells(block)
}
track.appendCells(cells...)
for _, c := range cells {
if c.Event == "" {
continue
}
tl.cellIdx[cellKey{blockID: c.BlockID, event: c.Event}] = c
}
row += len(cells)
isLast := i == len(blocks)-1
if !isLast && block.Type != "cue" {
if endChains[block.ID] {
track.appendCells(&TimelineCell{Type: CellChain})
} else {
track.appendCells(&TimelineCell{Type: CellGap})
}
row++
}
}
}

View File

@@ -18,7 +18,7 @@ func TestBuildTimelineFromMockShow(t *testing.T) {
t.Logf("Validate: %v", time.Since(t1))
t2 := time.Now()
tl, err := BuildTimeline(show)
tl, err := BuildTimelineDebug(show, &testWriter{t})
t.Logf("BuildTimeline: %v", time.Since(t2))
if err != nil {
t.Fatalf("BuildTimeline failed: %v", err)
@@ -26,6 +26,13 @@ func TestBuildTimelineFromMockShow(t *testing.T) {
t.Logf("tracks=%d blocks=%d", len(tl.Tracks), len(tl.Blocks))
}
type testWriter struct{ t *testing.T }
func (w *testWriter) Write(p []byte) (int, error) {
w.t.Log(string(p))
return len(p), nil
}
func BenchmarkBuildTimeline(b *testing.B) {
show := GenerateMockShow(42, 5, 20, 4, 5)
if err := show.Validate(); err != nil {

View File

@@ -69,7 +69,7 @@ func main() {
os.Exit(1)
}
for i := range uint8(8) {
for i := uint8(0); i < 8; i++ {
updateLCD(out, i)
}

View File

@@ -38,13 +38,13 @@ var ModelXL = Model{
}
var ModelPlus = Model{
Name: "Plus",
Keys: 8,
KeyRows: 2,
KeyCols: 4,
KeySize: 120,
Encoders: 4,
LCDWidth: 800,
Name: "Plus",
Keys: 8,
KeyRows: 2,
KeyCols: 4,
KeySize: 120,
Encoders: 4,
LCDWidth: 800,
LCDHeight: 100,
}
@@ -101,10 +101,10 @@ func OpenModel(m *Model) (*Device, error) {
return &Device{dev: dev, model: m}, nil
}
func (d *Device) Model() *Model { return d.model }
func (d *Device) Close() error { return d.dev.Close() }
func (d *Device) Model() *Model { return d.model }
func (d *Device) Close() error { return d.dev.Close() }
func (d *Device) SerialNumber() string { return d.dev.SerialNumber() }
func (d *Device) Product() string { return d.dev.Product() }
func (d *Device) Product() string { return d.dev.Product() }
func (d *Device) FirmwareVersion() (string, error) {
buf, err := d.dev.GetFeatureReport(5)
@@ -150,8 +150,8 @@ func (d *Device) SetKeyImage(key int, img image.Image) error {
var src image.Image = scaled
if d.model.FlipKeys {
flipped := image.NewRGBA(scaled.Bounds())
for y := range sz {
for x := range sz {
for y := 0; y < sz; y++ {
for x := 0; x < sz; x++ {
flipped.Set(sz-1-x, sz-1-y, scaled.At(x, y))
}
}

View File

@@ -108,11 +108,11 @@ type TextSpan struct {
func DrawOutlinedSpans(img *image.RGBA, face font.Face, defaultOutline color.Color, thickness int, lines ...[]TextSpan) {
var full []string
for _, line := range lines {
var s strings.Builder
s := ""
for _, span := range line {
s.WriteString(span.Text)
s += span.Text
}
full = append(full, s.String())
full = append(full, s)
}
metrics := face.Metrics()