From 2a11a995ccc36ea31ee35ad986ac0e8b28bd7f7a Mon Sep 17 00:00:00 2001 From: nabeya11 Date: Sun, 16 Aug 2026 15:18:40 +0900 Subject: [PATCH 1/2] Record deletions as delta patches with in-place compaction Deletion now compacts the cloud in place, keeping the spare capacity of Data, and records only the removed points. Undo re-expands with a backward walk, without allocation when the capacity is still available. This replaces the last snapshot-fallback edit; the whole-cloud replacePatch remains only for re-import and full voxel_grid. Co-Authored-By: Claude Fable 5 --- editor.go | 55 +++++++++++++++++++++++++++++---------- patch.go | 65 ++++++++++++++++++++++++++++++++++++++++++++++ patch_test.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 13 deletions(-) diff --git a/editor.go b/editor.go index ea53b0d..e79ad3d 100644 --- a/editor.go +++ b/editor.go @@ -191,29 +191,25 @@ func (e *editor) mutateLabels(fn func(i int, l uint32) (uint32, bool)) error { } func (e *editor) passThrough(fn func(int, mat.Vec3) bool) error { - pp, err := passThrough(e.pp, fn) + it, err := e.pp.Vec3Iterator() if err != nil { return err } - e.push(&replacePatch{ - header: e.pp.PointCloudHeader.Clone(), - data: e.pp.Data, + pcNew, p := compactInPlace(e.pp, func(i int) bool { + return fn(i, it.Vec3At(i)) }) - e.pp = pp + e.pp = pcNew + e.push(p) runtime.GC() return nil } func (e *editor) passThroughByMask(sel []uint32, mask, val uint32) error { - pp, err := passThroughByMask(e.pp, sel, mask, val) - if err != nil { - return err - } - e.push(&replacePatch{ - header: e.pp.PointCloudHeader.Clone(), - data: e.pp.Data, + pcNew, p := compactInPlace(e.pp, func(i int) bool { + return sel[i]&mask == val }) - e.pp = pp + e.pp = pcNew + e.push(p) runtime.GC() return nil } @@ -238,6 +234,39 @@ func (e *editor) unlabelPoints(labelsToKeep []uint32) error { }) } +// Spare capacity of pp.Data is kept so that undo can re-expand without allocation +func compactInPlace(pp *pc.PointCloud, keep func(i int) bool) (*pc.PointCloud, *deletePatch) { + stride := pp.Stride() + p := &deletePatch{oldWidth: pp.Width, oldHeight: pp.Height} + n := pp.Points + j := 0 + runStart := -1 + flush := func(end int) { + if runStart < 0 { + return + } + cnt := end - runStart + if runStart != j { + copy(pp.Data[j*stride:(j+cnt)*stride], pp.Data[runStart*stride:end*stride]) + } + j += cnt + runStart = -1 + } + for i := 0; i < n; i++ { + if keep(i) { + if runStart < 0 { + runStart = i + } + continue + } + flush(i) + p.indices = append(p.indices, uint32(i)) + p.points = append(p.points, pp.Data[i*stride:(i+1)*stride]...) + } + flush(n) + return newCloudView(pp, j, j, 1, pp.Data[:j*stride]), p +} + func passThrough(pp *pc.PointCloud, fn func(int, mat.Vec3) bool) (*pc.PointCloud, error) { it, err := pp.Vec3Iterator() if err != nil { diff --git a/patch.go b/patch.go index c0bc258..d19d18e 100644 --- a/patch.go +++ b/patch.go @@ -80,6 +80,60 @@ func (p *labelPatch) encode(buf *bytes.Buffer) { writeUint32s(buf, p.oldLabels) } +type deletePatch struct { + oldWidth, oldHeight int + indices []uint32 // ascending original positions of the removed points + points []byte +} + +func (p *deletePatch) revert(pp *pc.PointCloud) (*pc.PointCloud, error) { + stride := pp.Stride() + if len(p.points) != len(p.indices)*stride { + return nil, errBrokenPatch + } + oldN := pp.Points + len(p.indices) + need := oldN * stride + var data []byte + if cap(pp.Data) >= need { + data = pp.Data[:need] + } else { + data = make([]byte, need) + copy(data, pp.Data) + } + + // Walk backwards so that every move reads a not-yet-overwritten position + di := len(p.indices) - 1 + src := pp.Points - 1 + for dst := oldN - 1; dst >= 0; dst-- { + if di >= 0 && int(p.indices[di]) == dst { + copy(data[dst*stride:(dst+1)*stride], p.points[di*stride:(di+1)*stride]) + di-- + } else { + if src < 0 { + return nil, errBrokenPatch + } + if dst != src { + copy(data[dst*stride:(dst+1)*stride], data[src*stride:(src+1)*stride]) + } + src-- + } + } + if di >= 0 { + return nil, errBrokenPatch + } + return newCloudView(pp, oldN, p.oldWidth, p.oldHeight, data), nil +} + +func (p *deletePatch) encode(buf *bytes.Buffer) { + buf.WriteByte(patchTypeDelete) + writeUint32(buf, uint32(p.oldWidth)) + writeUint32(buf, uint32(p.oldHeight)) + writeUint32(buf, uint32(len(p.indices))) + writeUint32s(buf, p.indices) + writeUint32(buf, uint32(len(p.points))) + buf.Write(p.points) +} + type appendPatch struct { oldPoints, oldWidth, oldHeight int } @@ -169,6 +223,17 @@ func decodePatch(b []byte) (patch, []byte, error) { return nil, nil, r.err } return p, r.b, nil + case patchTypeDelete: + p := &deletePatch{ + oldWidth: int(r.uint32()), + oldHeight: int(r.uint32()), + } + p.indices = r.uint32s(int(r.uint32())) + p.points = r.bytes(int(r.uint32())) + if r.err != nil { + return nil, nil, r.err + } + return p, r.b, nil case patchTypeAppend: p := &appendPatch{ oldPoints: int(r.uint32()), diff --git a/patch_test.go b/patch_test.go index 3b96125..b07ff16 100644 --- a/patch_test.go +++ b/patch_test.go @@ -73,6 +73,73 @@ func TestLabelPatchRevert(t *testing.T) { assertCloudEqual(t, orig, out) } +func deleteForTest(pp *pc.PointCloud, removed map[int]bool) *deletePatch { + stride := pp.Stride() + p := &deletePatch{ + oldWidth: pp.Width, + oldHeight: pp.Height, + } + j := 0 + for i := 0; i < pp.Points; i++ { + if removed[i] { + p.indices = append(p.indices, uint32(i)) + p.points = append(p.points, pp.Data[i*stride:(i+1)*stride]...) + continue + } + if i != j { + copy(pp.Data[j*stride:(j+1)*stride], pp.Data[i*stride:(i+1)*stride]) + } + j++ + } + pp.Points = j + pp.Width = j + pp.Height = 1 + pp.Data = pp.Data[:j*stride] + return p +} + +func TestDeletePatchRevert(t *testing.T) { + for name, removed := range map[string]map[int]bool{ + "Scattered": {1: true, 5: true, 6: true, 99: true}, + "Head": {0: true, 1: true, 2: true}, + "Tail": {97: true, 98: true, 99: true}, + "All": allIndices(100), + "None": {}, + } { + t.Run(name, func(t *testing.T) { + orig := makeTestCloud(t, 100, 10, 10) + t.Run("KeptCapacity", func(t *testing.T) { + pp := cloneCloud(orig) + p := deleteForTest(pp, removed) + out, err := p.revert(pp) + if err != nil { + t.Fatal(err) + } + assertCloudEqual(t, orig, out) + }) + t.Run("Realloc", func(t *testing.T) { + pp := cloneCloud(orig) + p := deleteForTest(pp, removed) + // Force the reallocation path by dropping spare capacity. + pp.Data = append([]byte{}, pp.Data...) + out, err := p.revert(pp) + if err != nil { + t.Fatal(err) + } + assertCloudEqual(t, orig, out) + }) + }) + } +} + +func allIndices(n int) map[int]bool { + m := map[int]bool{} + for i := 0; i < n; i++ { + m[i] = true + } + return m +} + func TestAppendPatchRevert(t *testing.T) { orig := makeTestCloud(t, 100, 10, 10) pp := cloneCloud(orig) @@ -115,6 +182,11 @@ func TestPatchEncodeDecodeRoundTrip(t *testing.T) { orig.Viewpoint = []float32{1, 2, 3, 1, 0, 0, 0} patches := []patch{ &labelPatch{indices: []uint32{1, 2, 42}, oldLabels: []uint32{7, 8, 9}}, + &deletePatch{ + oldWidth: 10, oldHeight: 10, + indices: []uint32{0, 50, 99}, + points: bytes.Repeat([]byte{1, 2, 3, 4}, 3*4), + }, &appendPatch{oldPoints: 90, oldWidth: 9, oldHeight: 10}, &replacePatch{header: orig.PointCloudHeader.Clone(), data: orig.Data}, } From fbc75bff550308995d5e773b9730347f013bbe98 Mon Sep 17 00:00:00 2001 From: nabeya11 Date: Sat, 22 Aug 2026 21:09:50 +0900 Subject: [PATCH 2/2] Clarify the no-spare-capacity delete revert subtest The subtest exercises revert without spare capacity; the reallocation branch is only reached when points were actually removed, so name it after the setup rather than the branch. Co-Authored-By: Claude Fable 5 --- patch_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/patch_test.go b/patch_test.go index b07ff16..f35c443 100644 --- a/patch_test.go +++ b/patch_test.go @@ -117,10 +117,11 @@ func TestDeletePatchRevert(t *testing.T) { } assertCloudEqual(t, orig, out) }) - t.Run("Realloc", func(t *testing.T) { + t.Run("NoSpareCapacity", func(t *testing.T) { pp := cloneCloud(orig) p := deleteForTest(pp, removed) - // Force the reallocation path by dropping spare capacity. + // Drop the spare capacity to exercise the reallocation + // path (a no-op for the None pattern). pp.Data = append([]byte{}, pp.Data...) out, err := p.revert(pp) if err != nil {