diff --git a/command.go b/command.go index a47b62a..fc48573 100644 --- a/command.go +++ b/command.go @@ -583,7 +583,9 @@ func (c *commandContext) AddSurface(resolution float32) bool { it.Incr() } } - c.editor.merge(pcNew) + if err := c.editor.merge(pcNew); err != nil { + return false + } c.setPointCloudUpdated() return true } @@ -631,8 +633,10 @@ func (c *commandContext) VoxelFilter(resolution float32) error { if selected { c.editor.passThrough(c.baseFilter(false)) - c.editor.pop() - c.editor.merge(pcFiltered) + if err := c.editor.merge(pcFiltered); err != nil { + return err + } + c.editor.squashLatest() } else { if err := c.editor.SetPointCloud(pcFiltered, cloudMain); err != nil { return err @@ -731,7 +735,9 @@ func (c *commandContext) FinalizeCurrentMode() error { for ; it.IsValid(); it.Incr() { it.SetVec3(trans.Transform(it.Vec3())) } - c.editor.merge(c.editor.ppSub) + if err := c.editor.merge(c.editor.ppSub); err != nil { + return err + } c.setPointCloudUpdated() c.UnsetCursors() } diff --git a/editor.go b/editor.go index be56099..590c9a0 100644 --- a/editor.go +++ b/editor.go @@ -12,7 +12,7 @@ const ( ) type editor struct { - history + *history pp *pc.PointCloud ppSub *pc.PointCloud ppSubRect rect @@ -33,17 +33,8 @@ func newEditor() *editor { } } -type history interface { - MaxHistory() int - SetMaxHistory(m int) - push(pp *pc.PointCloud) *pc.PointCloud - pop() *pc.PointCloud - undo() (*pc.PointCloud, bool) - clear() -} - func (e *editor) Undo() bool { - pp, ok := e.history.undo() + pp, ok := e.history.undo(e.pp) if ok { e.pp = pp } @@ -114,7 +105,12 @@ func (e *editor) SetPointCloud(pp *pc.PointCloud, id cloudID) error { } switch id { case cloudMain: - e.pp = e.push(pcNew) + if e.pp != nil { + if err := e.push(newUndoDataEntireCloud(e.pp)); err != nil { + return err + } + } + e.pp = pcNew case cloudSub: e.ppSub = pcNew it, err := pcNew.Vec3Iterator() @@ -161,7 +157,10 @@ func (e *editor) label(fn func(int, mat.Vec3) (uint32, bool)) error { itL.Incr() i++ } - e.pp = e.push(pcNew) + if err := e.push(newUndoDataEntireCloud(e.pp)); err != nil { + return err + } + e.pp = pcNew runtime.GC() return nil } @@ -171,7 +170,10 @@ func (e *editor) passThrough(fn func(int, mat.Vec3) bool) error { if err != nil { return err } - e.pp = e.push(pp) + if err := e.push(newUndoDataEntireCloud(e.pp)); err != nil { + return err + } + e.pp = pp runtime.GC() return nil } @@ -181,7 +183,10 @@ func (e *editor) passThroughByMask(sel []uint32, mask, val uint32) error { if err != nil { return err } - e.pp = e.push(pp) + if err := e.push(newUndoDataEntireCloud(e.pp)); err != nil { + return err + } + e.pp = pp runtime.GC() return nil } @@ -214,7 +219,10 @@ func (e *editor) relabelPointsInLabelRange(minLabel, maxLabel, newLabel uint32) lt.SetUint32(newLabel) } - e.pp = e.push(pcNew) + if err := e.push(newUndoDataEntireCloud(e.pp)); err != nil { + return err + } + e.pp = pcNew runtime.GC() return nil } @@ -255,7 +263,10 @@ func (e *editor) unlabelPoints(labelsToKeep []uint32) error { lt.SetUint32(0) } - e.pp = e.push(pcNew) + if err := e.push(newUndoDataEntireCloud(e.pp)); err != nil { + return err + } + e.pp = pcNew runtime.GC() return nil } @@ -347,7 +358,7 @@ func passThroughImpl(pp *pc.PointCloud, core func(_, _ *pc.PointCloud) int) (*pc return pcNew, nil } -func (e *editor) merge(pp *pc.PointCloud) { +func (e *editor) merge(pp *pc.PointCloud) error { pcNew := &pc.PointCloud{ PointCloudHeader: e.pp.PointCloudHeader.Clone(), Points: e.pp.Points + pp.Points, @@ -356,6 +367,10 @@ func (e *editor) merge(pp *pc.PointCloud) { pcNew.Width = pcNew.Points pcNew.Height = 1 - e.pp = e.push(pcNew) + if err := e.push(newUndoDataEntireCloud(e.pp)); err != nil { + return err + } + e.pp = pcNew runtime.GC() + return nil } diff --git a/history.go b/history.go new file mode 100644 index 0000000..a9cc3a7 --- /dev/null +++ b/history.go @@ -0,0 +1,79 @@ +package main + +import ( + "bytes" + + "github.com/seqsense/pcgol/pc" +) + +type recordStore interface { + store(parts ...[]byte) record +} + +type record interface { + load() []byte +} + +type undoStep []record + +type history struct { + store recordStore + steps []undoStep + maxHistory int +} + +func (h *history) MaxHistory() int { + return h.maxHistory +} + +func (h *history) SetMaxHistory(m int) { + if m < 0 { + m = 0 + } + h.maxHistory = m +} + +func (h *history) push(d undoData) error { + var head bytes.Buffer + if err := encodeUndoData(&head, d); err != nil { + return err + } + h.steps = append(h.steps, undoStep{h.store.store(head.Bytes(), d.payload())}) + for len(h.steps) > h.maxHistory { + h.steps[0] = nil + h.steps = h.steps[1:] + } + return nil +} + +func (h *history) squashLatest() { + if n := len(h.steps); n >= 2 { + h.steps[n-2] = append(h.steps[n-2], h.steps[n-1]...) + h.steps[n-1] = nil + h.steps = h.steps[:n-1] + } +} + +func (h *history) undo(pp *pc.PointCloud) (*pc.PointCloud, bool) { + n := len(h.steps) + if n == 0 { + return nil, false + } + step := h.steps[n-1] + for i := len(step) - 1; i >= 0; i-- { + d, err := decodeRecord(step[i].load()) + if err != nil { + return nil, false + } + if pp, err = d.restore(pp); err != nil { + return nil, false + } + } + h.steps[n-1] = nil + h.steps = h.steps[:n-1] + return pp, true +} + +func (h *history) clear() { + h.steps = nil +} diff --git a/history_test.go b/history_test.go new file mode 100644 index 0000000..1bd1556 --- /dev/null +++ b/history_test.go @@ -0,0 +1,315 @@ +package main + +import ( + "math/rand" + "reflect" + "testing" + + "github.com/seqsense/pcgol/mat" + "github.com/seqsense/pcgol/pc" +) + +func makeCloud(t *testing.T, points []mat.Vec3, labels []uint32) *pc.PointCloud { + t.Helper() + pp := &pc.PointCloud{ + PointCloudHeader: pc.PointCloudHeader{ + Version: 0.7, + Fields: []string{"x", "y", "z", "label"}, + Size: []int{4, 4, 4, 4}, + Type: []string{"F", "F", "F", "U"}, + Count: []int{1, 1, 1, 1}, + Width: len(points), + Height: 1, + Viewpoint: []float32{0, 0, 0, 1, 0, 0, 0}, + }, + Points: len(points), + } + pp.Data = make([]byte, len(points)*pp.Stride()) + it, err := pp.Vec3Iterator() + if err != nil { + t.Fatal(err) + } + lt, err := pp.Uint32Iterator("label") + if err != nil { + t.Fatal(err) + } + for i := range points { + it.SetVec3(points[i]) + lt.SetUint32(labels[i]) + it.Incr() + lt.Incr() + } + return pp +} + +func assertCloud(t *testing.T, pp *pc.PointCloud, points []mat.Vec3, labels []uint32) { + t.Helper() + if pp.Points != len(points) { + t.Fatalf("Expected %d points, got %d", len(points), pp.Points) + } + it, err := pp.Vec3Iterator() + if err != nil { + t.Fatal(err) + } + lt, err := pp.Uint32Iterator("label") + if err != nil { + t.Fatal(err) + } + for i := range points { + if !points[i].Equal(it.Vec3()) { + t.Fatalf("Point %d: expected %v, got %v", i, points[i], it.Vec3()) + } + if labels[i] != lt.Uint32() { + t.Fatalf("Label %d: expected %d, got %d", i, labels[i], lt.Uint32()) + } + it.Incr() + lt.Incr() + } +} + +func TestEditorUndo(t *testing.T) { + pts := []mat.Vec3{{1, 0, 0}, {2, 0, 0}, {3, 0, 0}} + + newTestEditor := func(t *testing.T, labels []uint32) *editor { + t.Helper() + e := newEditor() + if err := e.SetPointCloud(makeCloud(t, pts, labels), cloudMain); err != nil { + t.Fatal(err) + } + return e + } + undo := func(t *testing.T, e *editor) { + t.Helper() + if !e.Undo() { + t.Fatal("undo failed") + } + } + + t.Run("Label", func(t *testing.T) { + e := newTestEditor(t, []uint32{0, 0, 0}) + if err := e.label(func(i int, _ mat.Vec3) (uint32, bool) { + return 5, i != 1 + }); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, pts, []uint32{5, 0, 5}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{0, 0, 0}) + }) + t.Run("Delete", func(t *testing.T) { + e := newTestEditor(t, []uint32{1, 2, 3}) + if err := e.passThrough(func(i int, _ mat.Vec3) bool { + return i != 1 + }); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, []mat.Vec3{pts[0], pts[2]}, []uint32{1, 3}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{1, 2, 3}) + }) + t.Run("DeleteByMask", func(t *testing.T) { + e := newTestEditor(t, []uint32{1, 2, 3}) + sel := []uint32{0, selectBitmaskSegmentSelected, 0} + if err := e.passThroughByMask(sel, selectBitmaskSegmentSelected, 0); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, []mat.Vec3{pts[0], pts[2]}, []uint32{1, 3}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{1, 2, 3}) + }) + t.Run("Merge", func(t *testing.T) { + e := newTestEditor(t, []uint32{1, 2, 3}) + if err := e.merge(makeCloud(t, []mat.Vec3{{4, 0, 0}}, []uint32{7})); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, append(pts, mat.Vec3{4, 0, 0}), []uint32{1, 2, 3, 7}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{1, 2, 3}) + }) + t.Run("Relabel", func(t *testing.T) { + e := newTestEditor(t, []uint32{1, 2, 3}) + if err := e.relabelPointsInLabelRange(1, 2, 9); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, pts, []uint32{9, 9, 3}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{1, 2, 3}) + }) + t.Run("Unlabel", func(t *testing.T) { + e := newTestEditor(t, []uint32{1, 2, 3}) + if err := e.unlabelPoints([]uint32{2}); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, pts, []uint32{0, 2, 0}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{1, 2, 3}) + }) + t.Run("Replace", func(t *testing.T) { + e := newTestEditor(t, []uint32{1, 2, 3}) + pts2 := []mat.Vec3{{9, 9, 9}} + if err := e.SetPointCloud(makeCloud(t, pts2, []uint32{4}), cloudMain); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, pts2, []uint32{4}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{1, 2, 3}) + }) + t.Run("SequentialEdits", func(t *testing.T) { + e := newTestEditor(t, []uint32{0, 0, 0}) + if err := e.relabelPointsInLabelRange(0, 0, 1); err != nil { + t.Fatal(err) + } + if err := e.passThrough(func(i int, _ mat.Vec3) bool { + return i == 0 + }); err != nil { + t.Fatal(err) + } + assertCloud(t, e.pp, pts[:1], []uint32{1}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{1, 1, 1}) + undo(t, e) + assertCloud(t, e.pp, pts, []uint32{0, 0, 0}) + }) + t.Run("NothingToUndo", func(t *testing.T) { + e := newTestEditor(t, []uint32{0, 0, 0}) + if e.Undo() { + t.Fatal("undo without edits must fail") + } + }) +} + +func TestHistoryMaxDepth(t *testing.T) { + pts := []mat.Vec3{{1, 0, 0}, {2, 0, 0}} + labelAll := func(t *testing.T, e *editor, l uint32) { + t.Helper() + if err := e.label(func(int, mat.Vec3) (uint32, bool) { + return l, true + }); err != nil { + t.Fatal(err) + } + } + + e := newEditor() + e.SetMaxHistory(2) + if err := e.SetPointCloud(makeCloud(t, pts, []uint32{0, 0}), cloudMain); err != nil { + t.Fatal(err) + } + labelAll(t, e, 1) + labelAll(t, e, 2) + labelAll(t, e, 3) + + if !e.Undo() { + t.Fatal("first undo must succeed") + } + assertCloud(t, e.pp, pts, []uint32{2, 2}) + if !e.Undo() { + t.Fatal("second undo must succeed") + } + assertCloud(t, e.pp, pts, []uint32{1, 1}) + if e.Undo() { + t.Fatal("undo deeper than max_history must fail") + } + assertCloud(t, e.pp, pts, []uint32{1, 1}) + + e.SetMaxHistory(0) + labelAll(t, e, 9) + if e.Undo() { + t.Fatal("undo with max_history=0 must fail") + } +} + +func TestHistorySquashLatest(t *testing.T) { + pts := []mat.Vec3{{1, 0, 0}, {2, 0, 0}} + + e := newEditor() + if err := e.SetPointCloud(makeCloud(t, pts, []uint32{0, 0}), cloudMain); err != nil { + t.Fatal(err) + } + if err := e.relabelPointsInLabelRange(0, 0, 1); err != nil { + t.Fatal(err) + } + if err := e.merge(makeCloud(t, []mat.Vec3{{3, 0, 0}}, []uint32{2})); err != nil { + t.Fatal(err) + } + e.squashLatest() + + if !e.Undo() { + t.Fatal("undo failed") + } + assertCloud(t, e.pp, pts, []uint32{0, 0}) + if e.Undo() { + t.Fatal("squashed edits must be undone as a single step") + } +} + +// The tests below fuzz the same behavior with randomized edit sequences. + +func snapshotCloud(e *editor) *pc.PointCloud { + return cloneCloud(e.pp) +} + +func applyRandomEdit(t *testing.T, e *editor, rnd *rand.Rand) { + t.Helper() + switch rnd.Intn(5) { + case 0: // label by position + if err := e.label(func(i int, _ mat.Vec3) (uint32, bool) { + return uint32(rnd.Intn(4)), rnd.Intn(2) == 0 + }); err != nil { + t.Fatal(err) + } + case 1: // relabel range + if err := e.relabelPointsInLabelRange(0, uint32(rnd.Intn(3)), uint32(rnd.Intn(4))); err != nil { + t.Fatal(err) + } + case 2: // delete random points + if err := e.passThrough(func(i int, _ mat.Vec3) bool { + return rnd.Intn(4) != 0 + }); err != nil { + t.Fatal(err) + } + case 3: // paste + n := 1 + rnd.Intn(20) + if err := e.merge(makeTestCloud(t, n, n, 1)); err != nil { + t.Fatal(err) + } + case 4: // whole-cloud replacement + n := 50 + rnd.Intn(100) + if err := e.SetPointCloud(makeTestCloud(t, n, n, 1), cloudMain); err != nil { + t.Fatal(err) + } + } +} + +func TestEditorUndoRoundTrip(t *testing.T) { + for trial := int64(0); trial < 10; trial++ { + rnd := rand.New(rand.NewSource(trial)) + + e := newEditor() + e.SetMaxHistory(100) + if err := e.SetPointCloud(makeTestCloud(t, 200, 20, 10), cloudMain); err != nil { + t.Fatal(err) + } + + const nOps = 8 + snapshots := []*pc.PointCloud{snapshotCloud(e)} + for k := 0; k < nOps; k++ { + applyRandomEdit(t, e, rnd) + snapshots = append(snapshots, snapshotCloud(e)) + } + + for k := nOps; k > 0; k-- { + assertCloudEqual(t, snapshots[k], e.pp) + if !e.Undo() { + t.Fatalf("trial %d: undo %d failed", trial, nOps-k) + } + } + assertCloudEqual(t, snapshots[0], e.pp) + if !reflect.DeepEqual(snapshots[0].PointCloudHeader, e.pp.PointCloudHeader) { + t.Fatalf("trial %d: header mismatch after undoing all edits", trial) + } + + if e.Undo() { + t.Fatalf("trial %d: undo over the initial state must fail", trial) + } + } +} diff --git a/record.go b/record.go new file mode 100644 index 0000000..a5303a9 --- /dev/null +++ b/record.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "encoding/gob" + "io" + + "github.com/seqsense/pcgol/pc" +) + +type undoData interface { + // pp may be mutated; use the returned cloud. + restore(pp *pc.PointCloud) (*pc.PointCloud, error) + // payload returns the bulk data kept out of the gob encoding to avoid copying it. + payload() []byte + setPayload(data []byte) +} + +func init() { + gob.Register(&undoDataEntireCloud{}) +} + +type undoDataEntireCloud struct { + Header pc.PointCloudHeader + data []byte +} + +func newUndoDataEntireCloud(pp *pc.PointCloud) *undoDataEntireCloud { + return &undoDataEntireCloud{ + Header: pp.PointCloudHeader.Clone(), + data: pp.Data, + } +} + +func (p *undoDataEntireCloud) restore(_ *pc.PointCloud) (*pc.PointCloud, error) { + return &pc.PointCloud{ + PointCloudHeader: p.Header, + Points: p.Header.Width * p.Header.Height, + Data: p.data, + }, nil +} + +func (p *undoDataEntireCloud) payload() []byte { + return p.data +} + +func (p *undoDataEntireCloud) setPayload(data []byte) { + p.data = data +} + +// A record is this encoding followed by the raw payload. +func encodeUndoData(w io.Writer, d undoData) error { + return gob.NewEncoder(w).Encode(&d) +} + +// The returned undoData references b; do not reuse b afterwards. +func decodeRecord(b []byte) (undoData, error) { + r := bytes.NewReader(b) + var d undoData + if err := gob.NewDecoder(r).Decode(&d); err != nil { + return nil, err + } + // gob reads exactly one value from a ByteReader; the rest is the payload + d.setPayload(b[len(b)-r.Len():]) + return d, nil +} diff --git a/record_test.go b/record_test.go new file mode 100644 index 0000000..656bd68 --- /dev/null +++ b/record_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "bytes" + "math/rand" + "reflect" + "testing" + + "github.com/seqsense/pcgol/pc" +) + +func makeTestCloud(t *testing.T, n, width, height int) *pc.PointCloud { + t.Helper() + pp := &pc.PointCloud{ + PointCloudHeader: pc.PointCloudHeader{ + Version: 0.7, + Fields: []string{"x", "y", "z", "label"}, + Size: []int{4, 4, 4, 4}, + Type: []string{"F", "F", "F", "U"}, + Count: []int{1, 1, 1, 1}, + Width: width, + Height: height, + Viewpoint: []float32{0, 0, 0, 1, 0, 0, 0}, + }, + Points: n, + } + pp.Data = make([]byte, n*pp.Stride()) + rnd := rand.New(rand.NewSource(int64(n))) + rnd.Read(pp.Data) + return pp +} + +func cloneCloud(pp *pc.PointCloud) *pc.PointCloud { + out := &pc.PointCloud{ + PointCloudHeader: pp.PointCloudHeader.Clone(), + Points: pp.Points, + Data: append([]byte{}, pp.Data...), + } + return out +} + +func assertCloudEqual(t *testing.T, expected, got *pc.PointCloud) { + t.Helper() + if expected.Points != got.Points { + t.Fatalf("Points: expected %d, got %d", expected.Points, got.Points) + } + if expected.Width != got.Width || expected.Height != got.Height { + t.Fatalf("Size: expected %dx%d, got %dx%d", + expected.Width, expected.Height, got.Width, got.Height) + } + if !bytes.Equal(expected.Data, got.Data) { + t.Fatal("Data mismatch after restore") + } +} + +func TestUndoDataEntireCloudRestore(t *testing.T) { + orig := makeTestCloud(t, 100, 10, 10) + pp := makeTestCloud(t, 5, 5, 1) + + p := newUndoDataEntireCloud(orig) + out, err := p.restore(pp) + if err != nil { + t.Fatal(err) + } + assertCloudEqual(t, orig, out) + if !reflect.DeepEqual(orig.PointCloudHeader, out.PointCloudHeader) { + t.Fatalf("Header: expected %+v, got %+v", orig.PointCloudHeader, out.PointCloudHeader) + } +} + +func TestRecordEncodeDecodeRoundTrip(t *testing.T) { + orig := makeTestCloud(t, 100, 10, 10) + orig.Viewpoint = []float32{1, 2, 3, 1, 0, 0, 0} + p := newUndoDataEntireCloud(orig) + + var buf bytes.Buffer + if err := encodeUndoData(&buf, p); err != nil { + t.Fatal(err) + } + buf.Write(p.payload()) + decoded, err := decodeRecord(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(undoData(p), decoded) { + t.Errorf("Expected %+v, got %+v", p, decoded) + } +} diff --git a/undo.go b/undo.go index 4db1bc4..92a2d35 100644 --- a/undo.go +++ b/undo.go @@ -1,40 +1,29 @@ +//go:build !js // +build !js package main -import ( - "github.com/seqsense/pcgol/pc" -) - -// historyDummy is a dummy history implementation for testing. -type historyDummy struct { - latest *pc.PointCloud +func newHistory(n int) *history { + return &history{store: memStore{}, maxHistory: n} } -func newHistory(_ int) history { - return &historyDummy{} -} +// memStore keeps records on the Go heap. +type memStore struct{} -func (historyDummy) MaxHistory() int { - return 0 +func (memStore) store(parts ...[]byte) record { + var total int + for _, d := range parts { + total += len(d) + } + b := make([]byte, 0, total) + for _, d := range parts { + b = append(b, d...) + } + return memRecord(b) } -func (historyDummy) SetMaxHistory(_ int) { -} - -func (h *historyDummy) push(pp *pc.PointCloud) *pc.PointCloud { - h.latest = pp - return pp -} - -func (h *historyDummy) pop() *pc.PointCloud { - return h.latest -} - -func (historyDummy) undo() (*pc.PointCloud, bool) { - return nil, false -} +type memRecord []byte -func (h *historyDummy) clear() { - h.latest = nil +func (r memRecord) load() []byte { + return r } diff --git a/undo_js.go b/undo_js.go index 482ea3c..621296e 100644 --- a/undo_js.go +++ b/undo_js.go @@ -2,78 +2,35 @@ package main import ( "syscall/js" - - "github.com/seqsense/pcgol/pc" ) -type historyJS struct { - history []js.Value - historyHeader []pc.PointCloudHeader - maxHistory int -} - -func newHistory(n int) history { - return &historyJS{maxHistory: n} +func newHistory(n int) *history { + return &history{store: jsStore{}, maxHistory: n} } -func (h *historyJS) MaxHistory() int { - return h.maxHistory -} - -func (h *historyJS) SetMaxHistory(m int) { - if m < 0 { - m = 0 - } - h.maxHistory = m -} +// jsStore keeps records on the JS heap to avoid growing the WASM linear memory. +type jsStore struct{} -func (h *historyJS) push(pp *pc.PointCloud) *pc.PointCloud { - header := pp.PointCloudHeader.Clone() - dataJS := js.Global().Get("Uint8Array").New(len(pp.Data)) - js.CopyBytesToJS(dataJS, pp.Data) - h.history = append(h.history, dataJS) - h.historyHeader = append(h.historyHeader, header) - if len(h.history) > h.MaxHistory()+1 { - h.history[0] = js.Null() - h.history = h.history[1:] - h.historyHeader = h.historyHeader[1:] +func (jsStore) store(parts ...[]byte) record { + var total int + for _, d := range parts { + total += len(d) } - return pp -} - -func (h *historyJS) pop() *pc.PointCloud { - n := len(h.history) - back := h.history[n-1] - backHeader := h.historyHeader[n-1] - h.history[n-1] = js.Null() - h.history = h.history[:n-1] - h.historyHeader = h.historyHeader[:n-1] - - return h.reconstructPointCloud(backHeader, back) -} - -func (h *historyJS) undo() (*pc.PointCloud, bool) { - if n := len(h.history); n > 1 { - h.history[n-1] = js.Null() - h.history = h.history[:n-1] - h.historyHeader = h.historyHeader[:n-1] - - return h.reconstructPointCloud(h.historyHeader[n-2], h.history[n-2]), true + v := js.Global().Get("Uint8Array").New(total) + var off int + for _, d := range parts { + js.CopyBytesToJS(v.Call("subarray", off), d) + off += len(d) } - return nil, false + return jsRecord{v} } -func (h *historyJS) reconstructPointCloud(header pc.PointCloudHeader, dataJS js.Value) *pc.PointCloud { - pp := &pc.PointCloud{ - PointCloudHeader: header, - Points: header.Width * header.Height, - Data: make([]byte, dataJS.Get("byteLength").Int()), - } - js.CopyBytesToGo(pp.Data, dataJS) - return pp +type jsRecord struct { + v js.Value } -func (h *historyJS) clear() { - h.history = nil - h.historyHeader = nil +func (r jsRecord) load() []byte { + b := make([]byte, r.v.Get("byteLength").Int()) + js.CopyBytesToGo(b, r.v) + return b } diff --git a/undo_test.go b/undo_test.go new file mode 100644 index 0000000..830de18 --- /dev/null +++ b/undo_test.go @@ -0,0 +1,19 @@ +//go:build !js +// +build !js + +package main + +import ( + "testing" +) + +func TestHistoryUndoKeepsStepOnError(t *testing.T) { + h := newHistory(4) + h.steps = []undoStep{{memRecord{0xFF}}} // broken record data + if _, ok := h.undo(nil); ok { + t.Fatal("undo of a broken step must fail") + } + if len(h.steps) != 1 { + t.Fatal("a broken step must not be dropped; a later undo would apply an older record to a mismatched state") + } +}