diff --git a/command.go b/command.go index a47b62a..725ff6f 100644 --- a/command.go +++ b/command.go @@ -631,8 +631,8 @@ func (c *commandContext) VoxelFilter(resolution float32) error { if selected { c.editor.passThrough(c.baseFilter(false)) - c.editor.pop() c.editor.merge(pcFiltered) + c.editor.squashLatest() } else { if err := c.editor.SetPointCloud(pcFiltered, cloudMain); err != nil { return err diff --git a/editor.go b/editor.go index be56099..78bc24a 100644 --- a/editor.go +++ b/editor.go @@ -36,14 +36,14 @@ func newEditor() *editor { type history interface { MaxHistory() int SetMaxHistory(m int) - push(pp *pc.PointCloud) *pc.PointCloud - pop() *pc.PointCloud - undo() (*pc.PointCloud, bool) + push(p patch) + squashLatest() + undo(pp *pc.PointCloud) (*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 +114,13 @@ func (e *editor) SetPointCloud(pp *pc.PointCloud, id cloudID) error { } switch id { case cloudMain: - e.pp = e.push(pcNew) + if e.pp != nil { + e.push(&replacePatch{ + header: e.pp.PointCloudHeader.Clone(), + data: e.pp.Data, + }) + } + e.pp = pcNew case cloudSub: e.ppSub = pcNew it, err := pcNew.Vec3Iterator() @@ -161,7 +167,11 @@ func (e *editor) label(fn func(int, mat.Vec3) (uint32, bool)) error { itL.Incr() i++ } - e.pp = e.push(pcNew) + e.push(&replacePatch{ + header: e.pp.PointCloudHeader.Clone(), + data: e.pp.Data, + }) + e.pp = pcNew runtime.GC() return nil } @@ -171,7 +181,11 @@ func (e *editor) passThrough(fn func(int, mat.Vec3) bool) error { if err != nil { return err } - e.pp = e.push(pp) + e.push(&replacePatch{ + header: e.pp.PointCloudHeader.Clone(), + data: e.pp.Data, + }) + e.pp = pp runtime.GC() return nil } @@ -181,7 +195,11 @@ func (e *editor) passThroughByMask(sel []uint32, mask, val uint32) error { if err != nil { return err } - e.pp = e.push(pp) + e.push(&replacePatch{ + header: e.pp.PointCloudHeader.Clone(), + data: e.pp.Data, + }) + e.pp = pp runtime.GC() return nil } @@ -214,7 +232,11 @@ func (e *editor) relabelPointsInLabelRange(minLabel, maxLabel, newLabel uint32) lt.SetUint32(newLabel) } - e.pp = e.push(pcNew) + e.push(&replacePatch{ + header: e.pp.PointCloudHeader.Clone(), + data: e.pp.Data, + }) + e.pp = pcNew runtime.GC() return nil } @@ -255,7 +277,11 @@ func (e *editor) unlabelPoints(labelsToKeep []uint32) error { lt.SetUint32(0) } - e.pp = e.push(pcNew) + e.push(&replacePatch{ + header: e.pp.PointCloudHeader.Clone(), + data: e.pp.Data, + }) + e.pp = pcNew runtime.GC() return nil } @@ -356,6 +382,10 @@ func (e *editor) merge(pp *pc.PointCloud) { pcNew.Width = pcNew.Points pcNew.Height = 1 - e.pp = e.push(pcNew) + e.push(&replacePatch{ + header: e.pp.PointCloudHeader.Clone(), + data: e.pp.Data, + }) + e.pp = pcNew runtime.GC() } diff --git a/history_test.go b/history_test.go new file mode 100644 index 0000000..8a4bae7 --- /dev/null +++ b/history_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "math/rand" + "reflect" + "testing" + + "github.com/seqsense/pcgol/mat" + "github.com/seqsense/pcgol/pc" +) + +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) + e.merge(makeTestCloud(t, n, n, 1)) + 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) + } + } +} + +func TestHistoryMaxDepth(t *testing.T) { + rnd := rand.New(rand.NewSource(1)) + + e := newEditor() // maxHistoryDefault = 4 + if err := e.SetPointCloud(makeTestCloud(t, 100, 10, 10), cloudMain); err != nil { + t.Fatal(err) + } + + for k := 0; k < 6; k++ { + applyRandomEdit(t, e, rnd) + } + for k := 0; k < maxHistoryDefault; k++ { + if !e.Undo() { + t.Fatalf("undo %d must succeed", k) + } + } + if e.Undo() { + t.Fatal("undo deeper than max_history must fail") + } + + e.SetMaxHistory(0) + applyRandomEdit(t, e, rnd) + if e.Undo() { + t.Fatal("undo with max_history=0 must fail") + } +} + +func TestHistorySquashLatest(t *testing.T) { + e := newEditor() + if err := e.SetPointCloud(makeTestCloud(t, 100, 10, 10), cloudMain); err != nil { + t.Fatal(err) + } + orig := snapshotCloud(e) + + if err := e.passThrough(func(i int, _ mat.Vec3) bool { return i%2 == 0 }); err != nil { + t.Fatal(err) + } + e.merge(makeTestCloud(t, 10, 10, 1)) + e.squashLatest() + + if !e.Undo() { + t.Fatal("undo failed") + } + assertCloudEqual(t, orig, e.pp) +} diff --git a/patch.go b/patch.go new file mode 100644 index 0000000..72895de --- /dev/null +++ b/patch.go @@ -0,0 +1,202 @@ +package main + +import ( + "bytes" + "encoding/binary" + "errors" + "math" + + "github.com/seqsense/pcgol/pc" +) + +type patch interface { + // pp may be mutated; use the returned cloud + revert(pp *pc.PointCloud) (*pc.PointCloud, error) + // The wire form is the head followed by the raw payload + encodeHead(buf *bytes.Buffer) + payload() []byte +} + +const ( + patchTypeLabel = iota + 1 + patchTypeDelete + patchTypeAppend + patchTypeReplace +) + +var ( + errBrokenPatch = errors.New("broken patch data") + errUnknownPatchType = errors.New("unknown patch type") +) + +type replacePatch struct { + header pc.PointCloudHeader + data []byte +} + +func (p *replacePatch) revert(_ *pc.PointCloud) (*pc.PointCloud, error) { + return &pc.PointCloud{ + PointCloudHeader: p.header, + Points: p.header.Width * p.header.Height, + Data: p.data, + }, nil +} + +func (p *replacePatch) encodeHead(buf *bytes.Buffer) { + buf.WriteByte(patchTypeReplace) + writeUint32(buf, math.Float32bits(p.header.Version)) + writeUint32(buf, uint32(len(p.header.Fields))) + for i := range p.header.Fields { + writeString(buf, p.header.Fields[i]) + writeUint32(buf, uint32(p.header.Size[i])) + writeString(buf, p.header.Type[i]) + writeUint32(buf, uint32(p.header.Count[i])) + } + writeUint32(buf, uint32(p.header.Width)) + writeUint32(buf, uint32(p.header.Height)) + writeUint32(buf, uint32(len(p.header.Viewpoint))) + for _, v := range p.header.Viewpoint { + writeUint32(buf, math.Float32bits(v)) + } + writeUint32(buf, uint32(len(p.data))) +} + +func (p *replacePatch) payload() []byte { + return p.data +} + +func encodePatches(buf *bytes.Buffer, ps []patch) { + for _, p := range ps { + p.encodeHead(buf) + buf.Write(p.payload()) + } +} + +// Decoded patches may reference b; do not reuse it afterwards +func decodePatches(b []byte) ([]patch, error) { + var ps []patch + for len(b) > 0 { + p, rest, err := decodePatch(b) + if err != nil { + return nil, err + } + ps = append(ps, p) + b = rest + } + return ps, nil +} + +func decodePatch(b []byte) (patch, []byte, error) { + if len(b) < 1 { + return nil, nil, errBrokenPatch + } + typ := b[0] + r := reader{b: b[1:]} + switch typ { + case patchTypeReplace: + p := &replacePatch{} + p.header.Version = math.Float32frombits(r.uint32()) + nFields := int(r.uint32()) + // A field encodes to at least 16 bytes + if r.err != nil || nFields < 0 || nFields > len(r.b)/16 { + return nil, nil, errBrokenPatch + } + p.header.Fields = make([]string, nFields) + p.header.Size = make([]int, nFields) + p.header.Type = make([]string, nFields) + p.header.Count = make([]int, nFields) + for i := 0; i < nFields; i++ { + p.header.Fields[i] = r.string() + p.header.Size[i] = int(r.uint32()) + p.header.Type[i] = r.string() + p.header.Count[i] = int(r.uint32()) + } + p.header.Width = int(r.uint32()) + p.header.Height = int(r.uint32()) + nvp := int(r.uint32()) + if r.err != nil || nvp < 0 || nvp > len(r.b)/4 { + return nil, nil, errBrokenPatch + } + p.header.Viewpoint = make([]float32, nvp) + for i := range p.header.Viewpoint { + p.header.Viewpoint[i] = math.Float32frombits(r.uint32()) + } + p.data = r.bytes(int(r.uint32())) + if r.err != nil { + return nil, nil, r.err + } + return p, r.b, nil + } + return nil, nil, errUnknownPatchType +} + +func revertChunks(pp *pc.PointCloud, chunks [][]byte) (*pc.PointCloud, error) { + for i := len(chunks) - 1; i >= 0; i-- { + ps, err := decodePatches(chunks[i]) + if err != nil { + return nil, err + } + for j := len(ps) - 1; j >= 0; j-- { + if pp, err = ps[j].revert(pp); err != nil { + return nil, err + } + } + } + return pp, nil +} + +func packPatch(p patch) []byte { + var buf bytes.Buffer + p.encodeHead(&buf) + data := p.payload() + packed := make([]byte, buf.Len()+len(data)) + copy(packed, buf.Bytes()) + copy(packed[buf.Len():], data) + return packed +} + +func writeUint32(buf *bytes.Buffer, v uint32) { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], v) + buf.Write(b[:]) +} + +func writeString(buf *bytes.Buffer, s string) { + writeUint32(buf, uint32(len(s))) + buf.WriteString(s) +} + +type reader struct { + b []byte + err error +} + +func (r *reader) uint32() uint32 { + if r.err != nil { + return 0 + } + if len(r.b) < 4 { + r.err = errBrokenPatch + return 0 + } + v := binary.LittleEndian.Uint32(r.b) + r.b = r.b[4:] + return v +} + +func (r *reader) bytes(n int) []byte { + if r.err != nil { + return nil + } + if n < 0 || len(r.b) < n { + r.err = errBrokenPatch + return nil + } + b := r.b[:n] + r.b = r.b[n:] + return b +} + +func (r *reader) string() string { + return string(r.bytes(int(r.uint32()))) +} diff --git a/patch_test.go b/patch_test.go new file mode 100644 index 0000000..1d95637 --- /dev/null +++ b/patch_test.go @@ -0,0 +1,95 @@ +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, + }, + 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 revert") + } +} + +func TestReplacePatchRevert(t *testing.T) { + orig := makeTestCloud(t, 100, 10, 10) + orig.Viewpoint = []float32{0, 0, 0, 1, 0, 0, 0} + pp := makeTestCloud(t, 5, 5, 1) + + p := &replacePatch{ + header: orig.PointCloudHeader.Clone(), + data: append([]byte{}, orig.Data...), + } + out, err := p.revert(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 TestPatchEncodeDecodeRoundTrip(t *testing.T) { + orig := makeTestCloud(t, 100, 10, 10) + orig.Viewpoint = []float32{1, 2, 3, 1, 0, 0, 0} + patches := []patch{ + &replacePatch{header: orig.PointCloudHeader.Clone(), data: orig.Data}, + } + + var buf bytes.Buffer + encodePatches(&buf, patches) + decoded, err := decodePatches(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if len(decoded) != len(patches) { + t.Fatalf("Expected %d patches, got %d", len(patches), len(decoded)) + } + for i := range patches { + if !reflect.DeepEqual(patches[i], decoded[i]) { + t.Errorf("Patch %d: expected %+v, got %+v", i, patches[i], decoded[i]) + } + } +} diff --git a/undo.go b/undo.go index 4db1bc4..8b931ef 100644 --- a/undo.go +++ b/undo.go @@ -1,3 +1,4 @@ +//go:build !js // +build !js package main @@ -6,35 +7,57 @@ import ( "github.com/seqsense/pcgol/pc" ) -// historyDummy is a dummy history implementation for testing. -type historyDummy struct { - latest *pc.PointCloud +type historyMem struct { + // entries[i] is a list of packed patch chunks forming one undo step + entries [][][]byte + maxHistory int } -func newHistory(_ int) history { - return &historyDummy{} +func newHistory(n int) history { + return &historyMem{maxHistory: n} } -func (historyDummy) MaxHistory() int { - return 0 +func (h *historyMem) MaxHistory() int { + return h.maxHistory } -func (historyDummy) SetMaxHistory(_ int) { +func (h *historyMem) SetMaxHistory(m int) { + if m < 0 { + m = 0 + } + h.maxHistory = m } -func (h *historyDummy) push(pp *pc.PointCloud) *pc.PointCloud { - h.latest = pp - return pp +func (h *historyMem) push(p patch) { + h.entries = append(h.entries, [][]byte{packPatch(p)}) + for len(h.entries) > h.maxHistory { + h.entries[0] = nil + h.entries = h.entries[1:] + } } -func (h *historyDummy) pop() *pc.PointCloud { - return h.latest +func (h *historyMem) squashLatest() { + if n := len(h.entries); n >= 2 { + h.entries[n-2] = append(h.entries[n-2], h.entries[n-1]...) + h.entries[n-1] = nil + h.entries = h.entries[:n-1] + } } -func (historyDummy) undo() (*pc.PointCloud, bool) { - return nil, false +func (h *historyMem) undo(pp *pc.PointCloud) (*pc.PointCloud, bool) { + n := len(h.entries) + if n == 0 { + return nil, false + } + out, err := revertChunks(pp, h.entries[n-1]) + if err != nil { + return nil, false + } + h.entries[n-1] = nil + h.entries = h.entries[:n-1] + return out, true } -func (h *historyDummy) clear() { - h.latest = nil +func (h *historyMem) clear() { + h.entries = nil } diff --git a/undo_js.go b/undo_js.go index 482ea3c..fae3da9 100644 --- a/undo_js.go +++ b/undo_js.go @@ -1,15 +1,18 @@ package main import ( + "bytes" "syscall/js" "github.com/seqsense/pcgol/pc" ) +// historyJS stores entries as JS Uint8Arrays to keep them out of the WASM +// linear memory, which never shrinks. type historyJS struct { - history []js.Value - historyHeader []pc.PointCloudHeader - maxHistory int + // entries[i] is a list of packed patch chunks forming one undo step + entries [][]js.Value + maxHistory int } func newHistory(n int) history { @@ -27,53 +30,49 @@ func (h *historyJS) SetMaxHistory(m int) { h.maxHistory = m } -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 (h *historyJS) push(p patch) { + var head bytes.Buffer + p.encodeHead(&head) + data := p.payload() + chunk := js.Global().Get("Uint8Array").New(head.Len() + len(data)) + js.CopyBytesToJS(chunk, head.Bytes()) + js.CopyBytesToJS(chunk.Call("subarray", head.Len()), data) + h.entries = append(h.entries, []js.Value{chunk}) + for len(h.entries) > h.maxHistory { + h.entries[0] = nil + h.entries = h.entries[1:] } - 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 +func (h *historyJS) squashLatest() { + if n := len(h.entries); n >= 2 { + h.entries[n-2] = append(h.entries[n-2], h.entries[n-1]...) + h.entries[n-1] = nil + h.entries = h.entries[:n-1] } - return nil, false } -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()), +func (h *historyJS) undo(pp *pc.PointCloud) (*pc.PointCloud, bool) { + n := len(h.entries) + if n == 0 { + return nil, false + } + entry := h.entries[n-1] + chunks := make([][]byte, len(entry)) + for i, c := range entry { + b := make([]byte, c.Get("byteLength").Int()) + js.CopyBytesToGo(b, c) + chunks[i] = b + } + out, err := revertChunks(pp, chunks) + if err != nil { + return nil, false } - js.CopyBytesToGo(pp.Data, dataJS) - return pp + h.entries[n-1] = nil + h.entries = h.entries[:n-1] + return out, true } func (h *historyJS) clear() { - h.history = nil - h.historyHeader = nil + h.entries = nil } diff --git a/undo_test.go b/undo_test.go new file mode 100644 index 0000000..e9a05f3 --- /dev/null +++ b/undo_test.go @@ -0,0 +1,21 @@ +//go:build !js +// +build !js + +package main + +import ( + "testing" +) + +func TestHistoryUndoKeepsEntryOnError(t *testing.T) { + h := &historyMem{ + maxHistory: 4, + entries: [][][]byte{{{0xFF}}}, // broken patch data + } + if _, ok := h.undo(nil); ok { + t.Fatal("undo of a broken entry must fail") + } + if len(h.entries) != 1 { + t.Fatal("a broken entry must not be dropped; a later undo would apply an older patch to a mismatched state") + } +}