blendiff 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blendiff/__init__.py +40 -0
- blendiff/cli/__init__.py +6 -0
- blendiff/cli/__main__.py +214 -0
- blendiff/cli/api.py +281 -0
- blendiff/data_model/__init__.py +13 -0
- blendiff/data_model/conflict.py +196 -0
- blendiff/data_model/diff.py +109 -0
- blendiff/data_model/material.py +139 -0
- blendiff/data_model/scene.py +131 -0
- blendiff/diff_engine/__init__.py +2 -0
- blendiff/diff_engine/diff_engine.py +249 -0
- blendiff/diff_engine/material_diff.py +256 -0
- blendiff/diff_engine/render_diff.py +84 -0
- blendiff/export/__init__.py +1 -0
- blendiff/export/html_exporter.py +547 -0
- blendiff/merge_engine/__init__.py +2 -0
- blendiff/merge_engine/applier.py +252 -0
- blendiff/merge_engine/merge_engine.py +305 -0
- blendiff/serializer/__init__.py +2 -0
- blendiff/serializer/scene_serializer.py +150 -0
- blendiff/storage/__init__.py +1 -0
- blendiff/storage/sidecar.py +243 -0
- blendiff-0.2.0.dist-info/METADATA +147 -0
- blendiff-0.2.0.dist-info/RECORD +27 -0
- blendiff-0.2.0.dist-info/WHEEL +4 -0
- blendiff-0.2.0.dist-info/entry_points.txt +2 -0
- blendiff-0.2.0.dist-info/licenses/LICENSE +21 -0
blendiff/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BlenDiff — semantic diff, snapshot history, and assisted merge for .blend files.
|
|
3
|
+
|
|
4
|
+
When imported outside Blender (e.g. via pip), only the pure-Python core is
|
|
5
|
+
available: data_model, diff_engine, serializer, storage, export, cli.
|
|
6
|
+
|
|
7
|
+
When imported inside Blender, the full addon registers its UI panels and
|
|
8
|
+
operators on top of the core.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
bl_info = {
|
|
14
|
+
"name": "BlenDiff",
|
|
15
|
+
"author": "Vishrut",
|
|
16
|
+
"version": (0, 2, 0),
|
|
17
|
+
"blender": (3, 6, 0),
|
|
18
|
+
"location": "3D Viewport > Sidebar > BlenDiff",
|
|
19
|
+
"description": "Semantic scene diff, snapshot history, and assisted merge for .blend files",
|
|
20
|
+
"category": "Scene",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import bpy
|
|
25
|
+
_IN_BLENDER = True
|
|
26
|
+
except ModuleNotFoundError:
|
|
27
|
+
_IN_BLENDER = False
|
|
28
|
+
|
|
29
|
+
if _IN_BLENDER:
|
|
30
|
+
from .ui import panels, operators, merge_panel
|
|
31
|
+
|
|
32
|
+
def register() -> None:
|
|
33
|
+
operators.register()
|
|
34
|
+
panels.register()
|
|
35
|
+
merge_panel.register()
|
|
36
|
+
|
|
37
|
+
def unregister() -> None:
|
|
38
|
+
merge_panel.unregister()
|
|
39
|
+
panels.unregister()
|
|
40
|
+
operators.unregister()
|
blendiff/cli/__init__.py
ADDED
blendiff/cli/__main__.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blendiff.cli.__main__
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Entry point for: python -m blendiff.cli <command> [args]
|
|
5
|
+
|
|
6
|
+
Commands
|
|
7
|
+
--------
|
|
8
|
+
list <sidecar> List all snapshots in a sidecar
|
|
9
|
+
compare <sidecar> <label_a> <label_b> Diff two snapshots by label
|
|
10
|
+
latest <sidecar> Diff the two most recent snapshots
|
|
11
|
+
|
|
12
|
+
Global flags
|
|
13
|
+
------------
|
|
14
|
+
--output <path> Write HTML report to this path (default: print JSON)
|
|
15
|
+
--json Print result as JSON to stdout
|
|
16
|
+
--fail-on-changes Exit with code 1 if any changes detected (for CI gates)
|
|
17
|
+
--quiet Suppress all output except errors
|
|
18
|
+
|
|
19
|
+
Exit codes
|
|
20
|
+
----------
|
|
21
|
+
0 — success, no changes (or changes detected but --fail-on-changes not set)
|
|
22
|
+
1 — changes detected and --fail-on-changes is set
|
|
23
|
+
2 — error (file not found, snapshot not found, parse failure, etc.)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="python -m blendiff.cli",
|
|
37
|
+
description="BlenDiff headless CLI — diff Blender scene snapshots without opening Blender.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
41
|
+
sub.required = True
|
|
42
|
+
|
|
43
|
+
# ── list ──────────────────────────────────────────────────────────────
|
|
44
|
+
p_list = sub.add_parser("list", help="List all snapshots in a .blendiff sidecar")
|
|
45
|
+
p_list.add_argument("sidecar", help="Path to the .blendiff file")
|
|
46
|
+
p_list.add_argument("--json", dest="as_json", action="store_true",
|
|
47
|
+
help="Output as JSON")
|
|
48
|
+
|
|
49
|
+
# ── compare ───────────────────────────────────────────────────────────
|
|
50
|
+
p_compare = sub.add_parser(
|
|
51
|
+
"compare",
|
|
52
|
+
help="Diff two snapshots by label (most recent match used if duplicates exist)",
|
|
53
|
+
)
|
|
54
|
+
p_compare.add_argument("sidecar", help="Path to the .blendiff file")
|
|
55
|
+
p_compare.add_argument("label_a", help="Label of the base snapshot (before)")
|
|
56
|
+
p_compare.add_argument("label_b", help="Label of the target snapshot (after)")
|
|
57
|
+
p_compare.add_argument("--output", metavar="PATH",
|
|
58
|
+
help="Write HTML report to this path")
|
|
59
|
+
p_compare.add_argument("--json", dest="as_json", action="store_true",
|
|
60
|
+
help="Print result as JSON to stdout")
|
|
61
|
+
p_compare.add_argument("--fail-on-changes", action="store_true",
|
|
62
|
+
help="Exit with code 1 if changes are detected")
|
|
63
|
+
p_compare.add_argument("--quiet", action="store_true",
|
|
64
|
+
help="Suppress output except errors")
|
|
65
|
+
|
|
66
|
+
# ── latest ────────────────────────────────────────────────────────────
|
|
67
|
+
p_latest = sub.add_parser(
|
|
68
|
+
"latest",
|
|
69
|
+
help="Diff the two most recent snapshots",
|
|
70
|
+
)
|
|
71
|
+
p_latest.add_argument("sidecar", help="Path to the .blendiff file")
|
|
72
|
+
p_latest.add_argument("--output", metavar="PATH",
|
|
73
|
+
help="Write HTML report to this path")
|
|
74
|
+
p_latest.add_argument("--json", dest="as_json", action="store_true",
|
|
75
|
+
help="Print result as JSON to stdout")
|
|
76
|
+
p_latest.add_argument("--fail-on-changes", action="store_true",
|
|
77
|
+
help="Exit with code 1 if changes are detected")
|
|
78
|
+
p_latest.add_argument("--quiet", action="store_true",
|
|
79
|
+
help="Suppress output except errors")
|
|
80
|
+
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_list(args) -> int:
|
|
85
|
+
from .api import list_snapshots
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
snapshots = list_snapshots(args.sidecar)
|
|
89
|
+
except FileNotFoundError as e:
|
|
90
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
91
|
+
return 2
|
|
92
|
+
|
|
93
|
+
if args.as_json:
|
|
94
|
+
print(json.dumps(snapshots, indent=2))
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
if not snapshots:
|
|
98
|
+
print("No snapshots found.")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
print(f"{'#':<4} {'Label':<30} {'Timestamp':<22} {'ID'}")
|
|
102
|
+
print("-" * 80)
|
|
103
|
+
for i, s in enumerate(snapshots, 1):
|
|
104
|
+
print(f"{i:<4} {s['label']:<30} {s['timestamp_display']:<22} {s['id'][:8]}")
|
|
105
|
+
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _cmd_compare(args, use_latest: bool = False) -> int:
|
|
110
|
+
from .api import compare_snapshots_by_label, compare_latest_two
|
|
111
|
+
from ..export.html_exporter import export_to_file, build_output_path
|
|
112
|
+
|
|
113
|
+
quiet = getattr(args, "quiet", False)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
if use_latest:
|
|
117
|
+
result = compare_latest_two(args.sidecar)
|
|
118
|
+
else:
|
|
119
|
+
result = compare_snapshots_by_label(
|
|
120
|
+
args.sidecar, args.label_a, args.label_b
|
|
121
|
+
)
|
|
122
|
+
except FileNotFoundError as e:
|
|
123
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
124
|
+
return 2
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
|
|
129
|
+
# JSON output
|
|
130
|
+
if getattr(args, "as_json", False):
|
|
131
|
+
print(json.dumps(result, indent=2, default=str))
|
|
132
|
+
|
|
133
|
+
# HTML output
|
|
134
|
+
elif getattr(args, "output", None):
|
|
135
|
+
output_path = args.output
|
|
136
|
+
snapshot_label = (
|
|
137
|
+
f"{result['snapshot_a']['label']} → {result['snapshot_b']['label']}"
|
|
138
|
+
)
|
|
139
|
+
export_to_file(
|
|
140
|
+
result=result,
|
|
141
|
+
snapshot_label=snapshot_label,
|
|
142
|
+
blend_filepath=args.sidecar,
|
|
143
|
+
output_path=output_path,
|
|
144
|
+
)
|
|
145
|
+
if not quiet:
|
|
146
|
+
print(f"HTML report written to: {output_path}")
|
|
147
|
+
|
|
148
|
+
# Default: human-readable summary
|
|
149
|
+
else:
|
|
150
|
+
if not quiet:
|
|
151
|
+
snap_a = result["snapshot_a"]
|
|
152
|
+
snap_b = result["snapshot_b"]
|
|
153
|
+
print(f"\nBlenDiff — comparing snapshots")
|
|
154
|
+
print(f" Before : '{snap_a['label']}' ({snap_a['timestamp']})")
|
|
155
|
+
print(f" After : '{snap_b['label']}' ({snap_b['timestamp']})")
|
|
156
|
+
print(f"\n {result['summary']}\n")
|
|
157
|
+
|
|
158
|
+
added = result["added_objects"]
|
|
159
|
+
removed = result["removed_objects"]
|
|
160
|
+
modified = result["modified_objects"]
|
|
161
|
+
col_diffs = result["collection_diffs"]
|
|
162
|
+
|
|
163
|
+
if added:
|
|
164
|
+
print(f" Added objects ({len(added)}):")
|
|
165
|
+
for name in added:
|
|
166
|
+
print(f" + {name}")
|
|
167
|
+
|
|
168
|
+
if removed:
|
|
169
|
+
print(f" Removed objects ({len(removed)}):")
|
|
170
|
+
for name in removed:
|
|
171
|
+
print(f" - {name}")
|
|
172
|
+
|
|
173
|
+
if modified:
|
|
174
|
+
print(f" Modified objects ({len(modified)}):")
|
|
175
|
+
for obj in modified:
|
|
176
|
+
print(f" ~ {obj['name']}")
|
|
177
|
+
for c in obj["changes"]:
|
|
178
|
+
print(f" {c['property_path']}")
|
|
179
|
+
print(f" {c['old_value']} → {c['new_value']}")
|
|
180
|
+
|
|
181
|
+
if col_diffs:
|
|
182
|
+
print(f" Collection changes ({len(col_diffs)}):")
|
|
183
|
+
for cd in col_diffs:
|
|
184
|
+
print(f" {cd['kind'].capitalize()}: {cd['path']}")
|
|
185
|
+
|
|
186
|
+
if not result["has_changes"]:
|
|
187
|
+
print(" No changes detected.")
|
|
188
|
+
|
|
189
|
+
print()
|
|
190
|
+
|
|
191
|
+
# Exit code
|
|
192
|
+
if getattr(args, "fail_on_changes", False) and result["has_changes"]:
|
|
193
|
+
return 1
|
|
194
|
+
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main(argv=None) -> int:
|
|
199
|
+
parser = _build_parser()
|
|
200
|
+
args = parser.parse_args(argv)
|
|
201
|
+
|
|
202
|
+
if args.command == "list":
|
|
203
|
+
return _cmd_list(args)
|
|
204
|
+
elif args.command == "compare":
|
|
205
|
+
return _cmd_compare(args, use_latest=False)
|
|
206
|
+
elif args.command == "latest":
|
|
207
|
+
return _cmd_compare(args, use_latest=True)
|
|
208
|
+
|
|
209
|
+
parser.print_help()
|
|
210
|
+
return 2
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
sys.exit(main())
|
blendiff/cli/api.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blendiff.cli.api
|
|
3
|
+
~~~~~~~~~~~~~~~~~
|
|
4
|
+
Pure Python API for comparing snapshots outside Blender.
|
|
5
|
+
|
|
6
|
+
This module is the core of the headless/CI feature. It has:
|
|
7
|
+
- Zero bpy imports
|
|
8
|
+
- No argparse / CLI concerns
|
|
9
|
+
- No side effects — all functions return data, callers decide what to do
|
|
10
|
+
|
|
11
|
+
Intended usage:
|
|
12
|
+
|
|
13
|
+
# In a script or CI pipeline
|
|
14
|
+
from blendiff.cli.api import compare_snapshots, list_snapshots
|
|
15
|
+
|
|
16
|
+
snapshots = list_snapshots("my_scene.blendiff")
|
|
17
|
+
result = compare_snapshots("my_scene.blendiff", "snap_id_a", "snap_id_b")
|
|
18
|
+
print(result["summary"])
|
|
19
|
+
|
|
20
|
+
# Or by label instead of ID
|
|
21
|
+
result = compare_snapshots_by_label(
|
|
22
|
+
"my_scene.blendiff", "Last approved", "Current"
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
from ..storage.sidecar import SidecarManager, Snapshot
|
|
32
|
+
from ..diff_engine.diff_engine import DiffEngine
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Public API
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def list_snapshots(sidecar_path: str) -> list[dict]:
|
|
40
|
+
"""
|
|
41
|
+
List all snapshots in a .blendiff sidecar file.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
sidecar_path:
|
|
46
|
+
Path to the .blendiff file.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
list of dicts with keys: id, label, timestamp, scene_name
|
|
51
|
+
Data field is excluded for brevity.
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
FileNotFoundError if sidecar_path does not exist.
|
|
56
|
+
"""
|
|
57
|
+
_require_sidecar(sidecar_path)
|
|
58
|
+
mgr = _manager_for_sidecar(sidecar_path)
|
|
59
|
+
snapshots = mgr.list_snapshots()
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
"id": s.id,
|
|
63
|
+
"label": s.label,
|
|
64
|
+
"timestamp": s.timestamp,
|
|
65
|
+
"timestamp_display": s.timestamp_display(),
|
|
66
|
+
"scene_name": s.scene_name,
|
|
67
|
+
}
|
|
68
|
+
for s in snapshots
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compare_snapshots(
|
|
73
|
+
sidecar_path: str,
|
|
74
|
+
snapshot_id_a: str,
|
|
75
|
+
snapshot_id_b: str,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""
|
|
78
|
+
Diff two snapshots by UUID.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
sidecar_path:
|
|
83
|
+
Path to the .blendiff file.
|
|
84
|
+
snapshot_id_a:
|
|
85
|
+
UUID of the base snapshot (the "before").
|
|
86
|
+
snapshot_id_b:
|
|
87
|
+
UUID of the target snapshot (the "after").
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
dict with keys:
|
|
92
|
+
summary — human-readable summary string
|
|
93
|
+
added_objects — list of names
|
|
94
|
+
removed_objects — list of names
|
|
95
|
+
modified_objects — list of {name, changes: [{property_path, old_value, new_value}]}
|
|
96
|
+
collection_diffs — list of {path, kind, changes}
|
|
97
|
+
has_changes — bool
|
|
98
|
+
snapshot_a — {id, label, timestamp}
|
|
99
|
+
snapshot_b — {id, label, timestamp}
|
|
100
|
+
|
|
101
|
+
Raises
|
|
102
|
+
------
|
|
103
|
+
FileNotFoundError if sidecar_path does not exist.
|
|
104
|
+
ValueError if either snapshot ID is not found.
|
|
105
|
+
"""
|
|
106
|
+
_require_sidecar(sidecar_path)
|
|
107
|
+
mgr = _manager_for_sidecar(sidecar_path)
|
|
108
|
+
|
|
109
|
+
snap_a = mgr.get_snapshot(snapshot_id_a)
|
|
110
|
+
snap_b = mgr.get_snapshot(snapshot_id_b)
|
|
111
|
+
|
|
112
|
+
if snap_a is None:
|
|
113
|
+
raise ValueError(f"Snapshot not found: {snapshot_id_a}")
|
|
114
|
+
if snap_b is None:
|
|
115
|
+
raise ValueError(f"Snapshot not found: {snapshot_id_b}")
|
|
116
|
+
|
|
117
|
+
return _run_diff(snap_a, snap_b)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def compare_snapshots_by_label(
|
|
121
|
+
sidecar_path: str,
|
|
122
|
+
label_a: str,
|
|
123
|
+
label_b: str,
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""
|
|
126
|
+
Diff two snapshots by label.
|
|
127
|
+
|
|
128
|
+
If multiple snapshots share a label, the most recent one is used.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
sidecar_path:
|
|
133
|
+
Path to the .blendiff file.
|
|
134
|
+
label_a:
|
|
135
|
+
Label of the base snapshot.
|
|
136
|
+
label_b:
|
|
137
|
+
Label of the target snapshot.
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
Same structure as compare_snapshots().
|
|
142
|
+
|
|
143
|
+
Raises
|
|
144
|
+
------
|
|
145
|
+
FileNotFoundError if sidecar_path does not exist.
|
|
146
|
+
ValueError if either label is not found.
|
|
147
|
+
"""
|
|
148
|
+
_require_sidecar(sidecar_path)
|
|
149
|
+
mgr = _manager_for_sidecar(sidecar_path)
|
|
150
|
+
|
|
151
|
+
# list_snapshots() returns newest-first, so first match = most recent
|
|
152
|
+
all_snapshots = mgr.list_snapshots()
|
|
153
|
+
|
|
154
|
+
snap_a = _find_by_label(all_snapshots, label_a)
|
|
155
|
+
snap_b = _find_by_label(all_snapshots, label_b)
|
|
156
|
+
|
|
157
|
+
if snap_a is None:
|
|
158
|
+
raise ValueError(f"No snapshot found with label: '{label_a}'")
|
|
159
|
+
if snap_b is None:
|
|
160
|
+
raise ValueError(f"No snapshot found with label: '{label_b}'")
|
|
161
|
+
|
|
162
|
+
return _run_diff(snap_a, snap_b)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def compare_latest_two(sidecar_path: str) -> dict:
|
|
166
|
+
"""
|
|
167
|
+
Diff the two most recent snapshots.
|
|
168
|
+
|
|
169
|
+
Useful for CI: "what changed since the last snapshot?"
|
|
170
|
+
|
|
171
|
+
Raises
|
|
172
|
+
------
|
|
173
|
+
FileNotFoundError if sidecar_path does not exist.
|
|
174
|
+
ValueError if fewer than 2 snapshots exist.
|
|
175
|
+
"""
|
|
176
|
+
_require_sidecar(sidecar_path)
|
|
177
|
+
mgr = _manager_for_sidecar(sidecar_path)
|
|
178
|
+
snapshots = mgr.list_snapshots() # newest first
|
|
179
|
+
|
|
180
|
+
if len(snapshots) < 2:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"Need at least 2 snapshots to diff, found {len(snapshots)}."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# newest = snapshots[0], second-newest = snapshots[1]
|
|
186
|
+
# We diff second-newest → newest (chronological order)
|
|
187
|
+
return _run_diff(snapshots[1], snapshots[0])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Internal helpers
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _manager_for_sidecar(sidecar_path: str) -> SidecarManager:
|
|
195
|
+
"""
|
|
196
|
+
Build a SidecarManager from a direct sidecar path.
|
|
197
|
+
|
|
198
|
+
SidecarManager normally derives the sidecar path from a .blend path.
|
|
199
|
+
Here we already have the sidecar path, so we reconstruct a fake blend
|
|
200
|
+
path just for the manager's internal use — the actual file it reads
|
|
201
|
+
is set directly.
|
|
202
|
+
"""
|
|
203
|
+
# Derive a synthetic blend path so SidecarManager is satisfied
|
|
204
|
+
base = sidecar_path
|
|
205
|
+
if base.endswith(".blendiff"):
|
|
206
|
+
base = base[: -len(".blendiff")]
|
|
207
|
+
blend_path = base + ".blend"
|
|
208
|
+
|
|
209
|
+
mgr = SidecarManager(blend_path)
|
|
210
|
+
# Override the sidecar path to use the actual file given
|
|
211
|
+
mgr._sidecar_path = sidecar_path
|
|
212
|
+
return mgr
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _require_sidecar(sidecar_path: str) -> None:
|
|
216
|
+
if not os.path.exists(sidecar_path):
|
|
217
|
+
raise FileNotFoundError(f"Sidecar file not found: {sidecar_path}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _find_by_label(snapshots: list[Snapshot], label: str) -> Optional[Snapshot]:
|
|
221
|
+
"""Return the first (most recent) snapshot matching label."""
|
|
222
|
+
for s in snapshots:
|
|
223
|
+
if s.label == label:
|
|
224
|
+
return s
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _run_diff(snap_a: Snapshot, snap_b: Snapshot) -> dict:
|
|
229
|
+
"""Run DiffEngine on two Snapshot objects and return a result dict."""
|
|
230
|
+
engine = DiffEngine()
|
|
231
|
+
diff = engine.compare(snap_a.data, snap_b.data)
|
|
232
|
+
s = diff.summary()
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"summary": (
|
|
236
|
+
f"Added: {s['added']} Removed: {s['removed']} "
|
|
237
|
+
f"Modified: {s['modified']} Collections: {s['collection_changes']}"
|
|
238
|
+
),
|
|
239
|
+
"has_changes": diff.has_changes,
|
|
240
|
+
"added_objects": [o.name for o in diff.added_objects],
|
|
241
|
+
"removed_objects": [o.name for o in diff.removed_objects],
|
|
242
|
+
"modified_objects": [
|
|
243
|
+
{
|
|
244
|
+
"name": o.name,
|
|
245
|
+
"changes": [
|
|
246
|
+
{
|
|
247
|
+
"property_path": c.property_path,
|
|
248
|
+
"old_value": c.old_value,
|
|
249
|
+
"new_value": c.new_value,
|
|
250
|
+
}
|
|
251
|
+
for c in o.changes
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
for o in diff.modified_objects
|
|
255
|
+
],
|
|
256
|
+
"collection_diffs": [
|
|
257
|
+
{
|
|
258
|
+
"path": cd.path,
|
|
259
|
+
"kind": cd.kind.value,
|
|
260
|
+
"changes": [
|
|
261
|
+
{
|
|
262
|
+
"property_path": c.property_path,
|
|
263
|
+
"old_value": c.old_value,
|
|
264
|
+
"new_value": c.new_value,
|
|
265
|
+
}
|
|
266
|
+
for c in cd.changes
|
|
267
|
+
],
|
|
268
|
+
}
|
|
269
|
+
for cd in diff.collection_diffs
|
|
270
|
+
],
|
|
271
|
+
"snapshot_a": {
|
|
272
|
+
"id": snap_a.id,
|
|
273
|
+
"label": snap_a.label,
|
|
274
|
+
"timestamp": snap_a.timestamp_display(),
|
|
275
|
+
},
|
|
276
|
+
"snapshot_b": {
|
|
277
|
+
"id": snap_b.id,
|
|
278
|
+
"label": snap_b.label,
|
|
279
|
+
"timestamp": snap_b.timestamp_display(),
|
|
280
|
+
},
|
|
281
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .scene import (
|
|
2
|
+
Transform, MaterialSlot, SceneObject, CollectionNode, SerializedScene,
|
|
3
|
+
)
|
|
4
|
+
from .diff import (
|
|
5
|
+
ChangeKind, PropertyChange, ObjectDiff, CollectionDiff, SceneDiff,
|
|
6
|
+
)
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Transform", "MaterialSlot", "SceneObject", "CollectionNode",
|
|
9
|
+
"SerializedScene",
|
|
10
|
+
"RenderDiff",
|
|
11
|
+
"ChangeKind", "PropertyChange", "ObjectDiff", "CollectionDiff",
|
|
12
|
+
"SceneDiff",
|
|
13
|
+
]
|