Initial Spatial Lab release with verified Blender bridge
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
"""Transactional project edits, with bounded persistent undo/redo."""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from .kernel import build, digest, dependencies
|
||||
|
||||
def read_json(path):
|
||||
p=Path(path)
|
||||
if p.stat().st_size>64*1024*1024:raise ValueError('JSON input exceeds 64 MiB')
|
||||
with p.open() as f:return json.load(f)
|
||||
|
||||
def write_json(path,data):
|
||||
p=Path(path);p.parent.mkdir(parents=True,exist_ok=True)
|
||||
fd,temp=tempfile.mkstemp(prefix=p.name+'.',suffix='.tmp',dir=p.parent)
|
||||
try:
|
||||
with os.fdopen(fd,'w') as f:
|
||||
json.dump(data,f,indent=2,allow_nan=False);f.write('\n');f.flush();os.fsync(f.fileno())
|
||||
os.replace(temp,p)
|
||||
finally:
|
||||
if os.path.exists(temp):os.unlink(temp)
|
||||
|
||||
class Project:
|
||||
def __init__(self,path):
|
||||
self.path=Path(path).resolve()
|
||||
self.history_path=self.path.parent/'.spatial_lab'/(self.path.name+'.history.json')
|
||||
|
||||
def read(self):return read_json(self.path)
|
||||
def build(self):return build(self.read())
|
||||
|
||||
@contextmanager
|
||||
def _lock(self):
|
||||
self.history_path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with self.history_path.with_suffix('.lock').open('a+b') as f:
|
||||
if os.name=='nt':
|
||||
import msvcrt
|
||||
f.seek(0,2)
|
||||
if f.tell()==0:f.write(b'0');f.flush()
|
||||
f.seek(0);msvcrt.locking(f.fileno(),msvcrt.LK_NBLCK,1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.flock(f.fileno(),fcntl.LOCK_EX|fcntl.LOCK_NB)
|
||||
try:yield
|
||||
finally:
|
||||
if os.name=='nt':f.seek(0);msvcrt.locking(f.fileno(),msvcrt.LK_UNLCK,1)
|
||||
else:fcntl.flock(f.fileno(),fcntl.LOCK_UN)
|
||||
|
||||
def _history(self,current):
|
||||
h=read_json(self.history_path) if self.history_path.exists() else {}
|
||||
# External JSON edits or an interrupted two-file write invalidate history,
|
||||
# never the current project. The next edit starts a new undo chain.
|
||||
if h.get('current_hash')!=digest(current):h={'past':[],'future':[],'current_hash':digest(current)}
|
||||
return h
|
||||
|
||||
def _save(self,project,history):
|
||||
history['current_hash']=digest(project)
|
||||
write_json(self.history_path,history);write_json(self.path,project)
|
||||
|
||||
def apply(self,changes):
|
||||
if not isinstance(changes,list) or not changes:raise ValueError('An edit transaction needs a nonempty list of changes')
|
||||
with self._lock():
|
||||
before=self.read();after=copy.deepcopy(before);h=self._history(before)
|
||||
for change in changes:
|
||||
action=change.get('action')
|
||||
if action=='set_parameter':after.setdefault('parameters',{})[change['name']]=change['value']
|
||||
elif action=='upsert_node':
|
||||
node=copy.deepcopy(change['node']);ids=[n['id'] for n in after['nodes']]
|
||||
if node['id'] in ids:after['nodes'][ids.index(node['id'])]=node
|
||||
else:after['nodes'].append(node)
|
||||
elif action=='patch_node':
|
||||
node=next((n for n in after['nodes'] if n['id']==change['id']),None)
|
||||
if node is None:raise ValueError('No such node: '+change['id'])
|
||||
for key,value in change['patch'].items():
|
||||
if key=='id':raise ValueError('Stable node IDs cannot be renamed with patch_node')
|
||||
if key in ['params','inputs']:node.setdefault(key,{}).update(value)
|
||||
else:node[key]=value
|
||||
elif action=='remove_node':
|
||||
target=change['id']
|
||||
if target not in [n['id'] for n in after['nodes']]:raise ValueError('No such node: '+target)
|
||||
removing={target}
|
||||
while True:
|
||||
dependents={n['id'] for n in after['nodes'] if any(d in removing for d in dependencies(n))}-removing
|
||||
if dependents and not change.get('cascade',False):raise ValueError('Node is used by: '+', '.join(sorted(dependents)))
|
||||
if not dependents:break
|
||||
removing.update(dependents)
|
||||
after['nodes']=[n for n in after['nodes'] if n['id'] not in removing]
|
||||
else:raise ValueError(f'Unknown edit action: {action}')
|
||||
result=build(after) # Failed evaluation leaves both project and history untouched.
|
||||
if digest(before)==digest(after):return result
|
||||
h['past']=(h['past']+[before])[-64:];h['future']=[];self._save(after,h)
|
||||
return result
|
||||
|
||||
def travel(self,direction):
|
||||
if direction not in ['undo','redo']:raise ValueError('Expected undo or redo')
|
||||
with self._lock():
|
||||
current=self.read();h=self._history(current);src,dst=('past','future') if direction=='undo' else ('future','past')
|
||||
if not h[src]:raise ValueError('Nothing to '+direction+' (external edits start a new history chain)')
|
||||
target=h[src][-1];result=build(target);h[src].pop();h[dst]=(h[dst]+[current])[-64:];self._save(target,h)
|
||||
return result
|
||||
Reference in New Issue
Block a user