Initial Spatial Lab release with verified Blender bridge

This commit is contained in:
emil28092005
2026-09-17 16:59:32 +03:00
commit 60d0018fa9
30 changed files with 58238 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.blend filter=lfs diff=lfs merge=lfs -text
+18
View File
@@ -0,0 +1,18 @@
.spatial_lab/
out/
*.egg-info/
build/
dist/
__pycache__/
*.py[cod]
.venv/
*.blend[0-9]
*.blend[0-9][0-9]
*.blend@
*.log
*.tmp
.DS_Store
.env
.env.*
!.env.example
+138
View File
@@ -0,0 +1,138 @@
# Spatial Lab
An agent-operated mathematical geometry workbench for designing intricate architectural structures before building the final environment in Blender. The editable source is a construction graph of curves, transported frames, sections, surfaces and transforms. The browser observes the current design; authoring happens through Python or CLI transactions.
**Working loop:** inspect → edit parameters or graph → observe → validate → export → update Blender → inspect again. A project remains editable throughout this loop, with persistent undo/redo. Nothing depends on reconstructing shapes from an image.
## Run
Clone this repository with Git LFS to retrieve the Blender example:
```sh
git clone https://github.com/emil28092005/spatial-lab.git
cd spatial-lab
git lfs pull
```
Python 3.11+ and a browser with WebGL2 are required. There are no runtime Python dependencies, CDN assets or accounts to configure. From this directory:
```sh
./spatial-lab serve examples/braided_atrium.spatial.json --port 8767
```
Open <http://127.0.0.1:8767>. The launcher also works by absolute path from another working directory. `python3 -m spatial_lab` is equivalent when run from this directory. Optional installation: `python3 -m pip install -e .` provides the `spatial-lab` command.
The viewer automatically rebuilds on source changes. Drag to orbit, Shift-drag to pan, scroll to zoom. Use Top/Front/Side, Fit, Surface/Wire, node selection, Isolate and Section Z to inspect the structure. Viewer controls never edit geometry. Invalid source displays an error and labels the previous valid view as stale. The HTTP server binds only to localhost and exposes no write API.
## Iterative authoring
```sh
./spatial-lab init out/my-study.json --example simple
./spatial-lab inspect out/my-study.json
./spatial-lab param out/my-study.json height 12
./spatial-lab undo out/my-study.json
./spatial-lab redo out/my-study.json
./spatial-lab build out/my-study.json --out out/my-study.bundle.json
./spatial-lab check out/my-study.bundle.json
./spatial-lab ops
```
Use `init --example atrium` for the larger demo. Existing files are never overwritten by `init`. Command output is JSON; validation failures return exit code 2.
For several related changes, write a JSON array and apply it with `./spatial-lab patch PROJECT changes.json`:
```json
[
{"action":"set_parameter","name":"width","value":1.8},
{"action":"patch_node","id":"braided-galleries","patch":{"params":{"count":2}}}
]
```
The entire graph must build successfully before any edit is saved. `upsert_node` adds/replaces an operation; `patch_node` merges its `params` and `inputs`; `remove_node` rejects a node with dependents unless `cascade:true`. `node PROJECT node.json` and `remove PROJECT ID --cascade` are CLI shortcuts. Persistent history retains 64 revisions beside the project in `.spatial_lab/`. Direct external JSON edits start a new undo chain. Keep source projects in Git for long-term history.
Python uses the same transaction layer:
```python
from spatial_lab import Project
project = Project("examples/braided_atrium.spatial.json")
bundle = project.apply([
{"action": "set_parameter", "name": "width", "value": 1.8},
])
project.travel("undo")
```
## Construction vocabulary
All built-in operators currently use version 1. Node IDs remain stable during edits. Inputs reference other node IDs; graph order is resolved automatically. Set `visible:false` on intermediate nodes to avoid exporting duplicate construction geometry. Hidden nodes still evaluate and feed downstream operations.
| Operator | Inputs | Main parameters |
| --- | --- | --- |
| `curve` | — | `xyz`: three expressions in `t`; `domain` (default `[0,"tau"]`); `segments` (128); `closed` (false); optional `max_chord_error` in metres |
| `polyline` | — | `points`: 3D vectors; `closed` (false); do not repeat the first point |
| `frames` | `path` | `up` (`[0,0,1]`); `twist_degrees` (0); parallel transport with closed-loop seam correction |
| `sweep` | `frames` | `profile`: a simple closed 2D polygon in local X/Z; `cap` (true) |
| `surface` | — | `xyz` in `u,v`; `u_domain`,`v_domain` (`[0,1]`); `u_segments` (32), `v_segments` (16); `wrap_u`,`wrap_v` (false); `thickness` (0) |
| `loft` | `sections`: list of curves | Corresponding closed contours with equal point counts; `cap` (true), requiring planar ends |
| `transform` | `source` | `translate`, `rotate` (XYZ Euler degrees), `scale` (positive scalar or 3D vector); curve or mesh input |
| `repeat` | `source`: mesh | `count` (2); `translate`,`rotate`,`scale` define a cumulative step; instances share geometry |
| `merge` | `sources`: list of meshes | Groups parts without performing a boolean union |
Numeric fields accept arithmetic expressions using project parameters, `pi`, `tau`, `e`, trigonometry and basic math functions. Global parameters are constants: they cannot refer to one another. Formula evaluation uses a bounded arithmetic AST, not Python `eval`. Local frames use X=right, Y=tangent, Z=up. A closed frame loop requires twist to be a multiple of 360°. Positive-scale transforms preserve orientation. Cumulative repetition applies the step matrix repeatedly, so translation can rotate or scale between instances.
Curves have explicit sampling resolution. `estimated_chord_error_m` measures deviation at interval midpoints; it is an estimate, not a certified maximum or adaptive subdivision. Inspect it and increase `segments` when needed. Thin shells offset sampled normals; large offsets can intersect themselves.
## Exact Blender handoff
```sh
./spatial-lab build examples/braided_atrium.spatial.json --out out/braided_atrium.bundle.json
blender -b --factory-startup --python-exit-code 1 --python blender/bridge.py -- \
--bundle out/braided_atrium.bundle.json \
--save out/Braided_Atrium.blend --report out/blender_verification.json
```
For a live Blender session (including through Blender MCP), load the bridge once, select the intended dedicated scene, then repeat `import_bundle` after rebuilding:
```python
import importlib.util
from pathlib import Path
lab = Path("/absolute/path/to/spatial-lab")
spec = importlib.util.spec_from_file_location("spatial_bridge", lab / "blender/bridge.py")
bridge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bridge)
scene = bridge.create_scene("Spatial study") # Reuse this scene on subsequent imports.
bridge.import_bundle(lab / "out/braided_atrium.bundle.json", scene)
report = bridge.verify_bundle(lab / "out/braided_atrium.bundle.json", scene)
assert report["passed"], report
```
The importer updates objects by stable ID and prunes only obsolete objects tagged with this project ID in the supplied scene. Existing object identity, modifiers and object-level material assignments survive updates. It replaces the owned base mesh and transforms: do final base-mesh editing on a separate copy or after the mathematical design is finished. Per-face authored material assignments are not preserved. Other scene objects are untouched. Imported guides are wire meshes and frame empties. Scene unit scale must be 1.
The JSON contract is documented in [FORMAT.md](docs/FORMAT.md). Vertices, triangle indices and transformation matrices are transferred directly. Verification compares base-mesh coordinates in local and world space, exact connectivity/winding, IDs and matrices; artist modifiers are excluded. The supplied demo was tested in Blender 5.2.2 LTS with maximum world-coordinate error below 0.000004 m. No manual reconstruction or random regeneration occurs during import.
Ready-made artifacts: [Blender scene](examples/verified/Braided_Atrium.blend), [technical bundle](examples/verified/braided_atrium.bundle.json), [verification report](examples/verified/blender_verification.json). Retrieve the `.blend` with Git LFS when cloning the repository.
## Extend
`examples/wave_extension.py` demonstrates a composable curve-deformation operator:
```sh
./spatial-lab --plugin examples/wave_extension.py ops
```
Register a function with `@register_operator(name, version=1, inputs=(...))`. It receives `(context, params, resolved_inputs)` and returns a `curve`, `frames` or `mesh` entity. Use `context.number`, `vec`, `count` and `expression` to evaluate bounded numeric parameters. See built-ins in `spatial_lab/operators.py` for entity shapes. Load the same plugin for every authoring/build/viewer command using that operator. Blender can import the already evaluated bundle without the plugin. Plugins are explicitly trusted local Python code; project JSON cannot auto-load them.
## Validation and current limits
```sh
python3 -m unittest discover -s tests -v
node --check spatial_lab/web/app.js
blender -b --factory-startup --python-exit-code 1 --python tests/blender_roundtrip.py
```
The Blender integration test changes geometry, removes an instance, checks artist-work preservation, undoes the transaction and verifies the restored bundle. It writes a report, bundle and `.blend` into `out/`. Kernel tests include analytical volumes, frame orthogonality, closed seams, concave caps, graph dependencies, safe expressions, deterministic hashes and transactional history. HTTP tests cover live updates and error handling.
Version 0.1 checks triangle degeneracy, edge incidence/orientation, expected boundaries, finite coordinates and transfer parity. It does **not** solve constraints, detect 3D self-intersections/collisions, prove walkability, perform solid booleans or implement portals/non-Euclidean runtime space. The braided atrium is a geometric study, not a finished playable level or cinematic scene. Those remain subsequent design and Blender/engine stages. Engine-specific adapters, UVs, textures and production detailing are deliberately outside the current mathematical handoff.
Runtime limits include 256 nodes, 4,096 exported objects, 200,000 vertices per mesh and 500,000 unique vertices per bundle. Large compositions should be split into projects. [Design notes](docs/DESIGN.md) describe the architecture and viewer choices.
+151
View File
@@ -0,0 +1,151 @@
"""Import and verify exact Spatial Lab base geometry in Blender.
From Blender Python: import_bundle(path, scene=dedicated_scene).
CLI: blender -b --factory-startup --python bridge.py -- --bundle X --save Y --report Z
Only tagged objects of the same project in the specified scene are owned by this adapter.
"""
import argparse
import json
import math
import sys
from pathlib import Path
LAB=Path(__file__).resolve().parents[1]
if str(LAB) not in sys.path:sys.path.insert(0,str(LAB))
from spatial_lab.kernel import validate_bundle
from spatial_lab.project import read_json,write_json
from spatial_lab.math3d import point
def enum_set(owner,property_name,value):
valid=[item.identifier for item in owner.bl_rna.properties[property_name].enum_items]
if value not in valid:raise ValueError(f'{value} is not a supported {property_name}: {valid}')
setattr(owner,property_name,value)
def create_scene(name='Spatial Lab'):
import bpy
scene=bpy.data.scenes.new(name);scene.unit_settings.scale_length=1;enum_set(scene.unit_settings,'system','METRIC')
return scene
def _owned(scene,project_id):
result={}
for o in scene.objects:
if o.get('spatial_lab_project')==project_id:
key=o.get('spatial_lab_id')
if key in result:raise ValueError('Duplicate imported object ID: '+str(key))
result[key]=o
return result
def import_bundle(source,scene=None,prune=True):
import bpy
from mathutils import Matrix
bundle=read_json(source) if isinstance(source,(str,Path)) else source
validate_bundle(bundle)
scene=scene or bpy.context.scene
if abs(scene.unit_settings.scale_length-1)>1e-9:raise ValueError('Import into a scene with unit scale 1 (metres)')
pid=bundle['project_id'];old=_owned(scene,pid)
for spec in bundle['objects']:
if spec['id'] in old and old[spec['id']].get('spatial_lab_kind')!=spec['kind']:raise ValueError('An existing stable ID changed object kind: '+spec['id'])
collection=next((c for c in scene.collection.children if c.get('spatial_lab_project')==pid),None)
if collection is None:
collection=bpy.data.collections.new('Spatial Lab / '+bundle['name']);collection['spatial_lab_project']=pid;scene.collection.children.link(collection)
cache={};created=0;updated=0
for gid,g in bundle['geometries'].items():
me=bpy.data.meshes.new('SL geometry '+gid[:12]);me.from_pydata(g['vertices'],[],g['faces']);me.update()
me['spatial_lab_project']=pid;me['spatial_lab_geometry']=gid;cache[gid]=me
def material(spec):
key=spec['node_id'];mat=next((m for m in bpy.data.materials if m.get('spatial_lab_project')==pid and m.get('spatial_lab_node')==key),None)
if mat is None:
mat=bpy.data.materials.new('SL / '+key);mat['spatial_lab_project']=pid;mat['spatial_lab_node']=key;mat.use_nodes=True
shader=next(n for n in mat.node_tree.nodes if n.type=='BSDF_PRINCIPLED');shader.inputs['Base Color'].default_value=(*spec['color'],1);shader.inputs['Roughness'].default_value=.76
mat.diffuse_color=(*spec['color'],1)
return mat
seen=set()
for spec in bundle['objects']:
oid=spec['id'];seen.add(oid);kind=spec['kind']
data=cache.get(spec.get('geometry'))
if kind=='curve':
pts=spec['points'];edges=[(i,i+1) for i in range(len(pts)-1)]
if spec.get('closed'):edges.append((len(pts)-1,0))
data=bpy.data.meshes.new('SL guide '+oid);data.from_pydata(pts,edges,[]);data.update();data['spatial_lab_project']=pid
obj=old.get(oid);old_materials=[]
if obj is None:
obj=bpy.data.objects.new('SL / '+oid,data);collection.objects.link(obj);created+=1
else:
old_materials=[s.material for s in obj.material_slots];obj.data=data;updated+=1
if obj.name not in collection.objects:collection.objects.link(obj)
obj['spatial_lab_project']=pid;obj['spatial_lab_id']=oid;obj['spatial_lab_kind']=kind;obj['spatial_lab_node']=spec['node_id'];obj['spatial_lab_role']=spec.get('role','geometry')
obj['spatial_lab_source_hash']=bundle['bundle_hash'];obj.matrix_world=Matrix(spec['matrix']);obj.color=(*spec['color'],1)
if kind=='mesh':
assigned=old_materials or [material(spec)]
while len(data.materials)<len(assigned):data.materials.append(material(spec))
for i,mat in enumerate(assigned):obj.material_slots[i].link='OBJECT';obj.material_slots[i].material=mat
elif kind=='curve':enum_set(obj,'display_type','WIRE');obj.hide_render=True
else:enum_set(obj,'empty_display_type','ARROWS');obj.empty_display_size=.65
removed=[]
if prune:
for oid,obj in old.items():
if oid not in seen:removed.append(oid);bpy.data.objects.remove(obj,do_unlink=True)
for me in list(bpy.data.meshes):
if me.get('spatial_lab_project')==pid and me.users==0:bpy.data.meshes.remove(me)
scene['spatial_lab_bundle_hash']=bundle['bundle_hash'];scene['spatial_lab_project_id']=pid
# Depsgraph updates are needed before world-space parity checks of fresh objects.
for layer in scene.view_layers:layer.update()
return {'created':created,'updated':updated,'removed':removed,'collection':collection.name,'bundle_hash':bundle['bundle_hash']}
def verify_bundle(source,scene=None,tolerance=1e-5):
import bpy
from mathutils import Vector
bundle=read_json(source) if isinstance(source,(str,Path)) else source;validate_bundle(bundle)
scene=scene or bpy.context.scene
for layer in scene.view_layers:layer.update()
owned=_owned(scene,bundle['project_id']);errors=[];local_error=0.;world_error=0.;matrix_error=0.;vertices=0;faces=0
expected={s['id'] for s in bundle['objects']}
for spec in bundle['objects']:
obj=owned.get(spec['id'])
if obj is None:errors.append('Missing object: '+spec['id']);continue
matrix_error=max(matrix_error,max(abs(obj.matrix_world[i][j]-spec['matrix'][i][j]) for i in range(4) for j in range(4)))
if spec['kind']=='frame':continue
pts=bundle['geometries'][spec['geometry']]['vertices'] if spec['kind']=='mesh' else spec['points']
triangles=bundle['geometries'][spec['geometry']]['faces'] if spec['kind']=='mesh' else []
if len(obj.data.vertices)!=len(pts):errors.append('Vertex count: '+spec['id']);continue
if [list(p.vertices) for p in obj.data.polygons]!=triangles:errors.append('Face connectivity/winding: '+spec['id'])
if spec['kind']=='curve':
expected_edges={tuple(sorted((i,i+1))) for i in range(len(pts)-1)}
if spec.get('closed'):expected_edges.add((0,len(pts)-1))
if {tuple(sorted(e.vertices)) for e in obj.data.edges}!=expected_edges:errors.append('Curve connectivity: '+spec['id'])
for v,p in zip(obj.data.vertices,pts):
local_error=max(local_error,math.dist(v.co,p));world_error=max(world_error,math.dist(obj.matrix_world@v.co,point(spec['matrix'],p)))
vertices+=len(pts);faces+=len(triangles)
extra=sorted(set(owned)-expected)
if extra:errors.append('Unexpected owned objects: '+', '.join(extra))
if local_error>tolerance:errors.append('Local-coordinate error exceeds tolerance')
if world_error>tolerance:errors.append('World-coordinate error exceeds tolerance')
if matrix_error>tolerance:errors.append('Transform error exceeds tolerance')
return {'passed':not errors,'project_id':bundle['project_id'],'bundle_hash':bundle['bundle_hash'],'objects_checked':len(expected),'vertices_checked_including_instances':vertices,'triangles_checked_including_instances':faces,'tolerance_m':tolerance,'max_local_vertex_error_m':local_error,'max_world_vertex_error_m':world_error,'max_matrix_component_error':matrix_error,'errors':errors,'scope':'Base mesh coordinates, connectivity, orientation and transforms. Artist modifiers are deliberately excluded.'}
def main():
import bpy
parser=argparse.ArgumentParser();parser.add_argument('--bundle',required=True);parser.add_argument('--save');parser.add_argument('--report');parser.add_argument('--exercise-update',action='store_true')
args=parser.parse_args(sys.argv[sys.argv.index('--')+1:] if '--' in sys.argv else [])
bundle=read_json(args.bundle);scene=create_scene('Spatial Lab / '+bundle['name']);bpy.context.window.scene=scene
imported=import_bundle(bundle,scene);verified=verify_bundle(bundle,scene)
if args.exercise_update:
marker=bpy.data.objects.new('Unrelated object must survive',None);scene.collection.objects.link(marker)
mesh_obj=next(o for o in scene.objects if o.get('spatial_lab_kind')=='mesh');pointer=mesh_obj.as_pointer()
modifier=mesh_obj.modifiers.new('Artist bevel survives reimport','BEVEL');modifier.width=.015;modifier.segments=2
mat=bpy.data.materials.new('Artist material survives reimport');mesh_obj.material_slots[0].link='OBJECT';mesh_obj.material_slots[0].material=mat
second=import_bundle(bundle,scene)
assert mesh_obj.as_pointer()==pointer and mesh_obj.modifiers.get(modifier.name) is not None and mesh_obj.material_slots[0].material==mat
assert marker.name in scene.objects and second['created']==0
# Restore the original base appearance for the delivered demo, after testing preservation.
mesh_obj.modifiers.remove(modifier);mesh_obj.material_slots[0].material=next(m for m in bpy.data.materials if m.get('spatial_lab_project')==bundle['project_id'] and m.get('spatial_lab_node')==mesh_obj['spatial_lab_node'])
bpy.data.objects.remove(marker,do_unlink=True)
verified=verify_bundle(bundle,scene);verified['reimport_preserves_object_identity_modifiers_materials_and_unrelated_objects']=True
if args.save:
Path(args.save).parent.mkdir(parents=True,exist_ok=True);bpy.ops.wm.save_as_mainfile(filepath=str(Path(args.save).resolve()))
result={'import':imported,'verification':verified}
if args.report:write_json(args.report,result)
print(json.dumps(result,indent=2))
if not verified['passed']:raise RuntimeError('Blender transfer verification failed')
if __name__=='__main__':main()
+13
View File
@@ -0,0 +1,13 @@
# Spatial Lab design
The author is an agent operating through Python and a CLI. The browser is an observation surface, not a manual modeller. The primary artifact is an editable construction graph; the derived bundle is the exact geometric handoff to Blender.
The viewer uses a cool drafting surface (#e9eef3), ink (#213348), quiet guides (#bccbd7), geometry blue (#668aa6), and a selection amber (#d18b42). System sans-serif carries navigation; fixed-width numerals and formulas use monospace. A narrow construction outline sits beside a large geometry viewport. A compact inspector shows the selected operation and its parameters. Controls only change observation: orbit, projections, visibility, section height, and wire/surface mode. Geometry never mutates through the viewer.
The visual emphasis is the construction itself. There is no marketing header, ornamental dashboard, animation, or material editor. Graph relationships, axes, and units are functional markings. Node colour is semantic and stable, not random decoration.
The kernel contains a versioned operator registry, bounded arithmetic evaluator, dependency-ordered graph execution, rotation-minimizing frames, triangulated geometry, structural validation, and deterministic content hashes. The project layer adds transactional edits and persistent undo/redo. No network service or third-party Python dependency is required to build geometry.
The JSON bundle uses metres, a right-handed Z-up basis, row-major matrix arrays, and column-vector multiplication. Objects reference shared geometric data by content hash. Stable object IDs derive from node IDs and instance keys. The Blender importer owns only tagged objects, updates them in place, preserves existing modifiers and material assignments, and verifies the imported base mesh against the bundle.
Version 0.1 does not claim collision-free architecture, walkability, a general constraint solver, solid booleans, or non-Euclidean runtime behaviour. Structural mesh checks and transfer parity are implemented; spatial design remains an iterative agent task.
+49
View File
@@ -0,0 +1,49 @@
# Spatial Lab JSON contract, version 1
## Editable project: `spatial-lab.project/1`
```json
{
"schema": "spatial-lab.project/1",
"id": "corridor-study",
"name": "Corridor study",
"parameters": {"length": 10},
"nodes": [
{"id":"axis","op":"polyline","version":1,"visible":false,
"params":{"points":[[0,0,0],[0,"length",0]]}},
{"id":"basis","op":"frames","visible":false,"inputs":{"path":"axis"}},
{"id":"deck","op":"sweep","inputs":{"frames":"basis"},
"params":{"profile":[[-1,-0.15],[1,-0.15],[1,0.15],[-1,0.15]]},
"color":[0.4,0.56,0.68],"role":"gallery"}
]
}
```
`id` identifies a project for ownership in the Blender adapter. Use a different project ID for independently managed studies in the same scene. Node IDs identify graph operations, never rename them during ordinary parameter edits. Version defaults to 1; visibility defaults to true. `name`, `color` and `role` are optional semantic presentation metadata. Parameters may be numbers or bounded arithmetic expressions. Dependencies are explicitly listed in `inputs`, as a node ID or list of node IDs. Cycles and missing references fail validation.
## Evaluated bundle: `spatial-lab.bundle/1`
| Field | Meaning |
| --- | --- |
| `project_id`, `name` | Source project identity |
| `units`, `handedness`, `up_axis` | Always `meters`, `right`, `+Z` |
| `matrix_convention` | Row arrays, column vectors, local-to-world matrices |
| `recipe` | Complete original project for further editing |
| `project_hash` | SHA-256 of canonical recipe JSON |
| `nodes` | Evaluated node summaries, input links and parameters |
| `geometries` | Dictionary keyed by geometry hash; each value has `vertices` and `faces` |
| `objects` | Stable ID, node ID, kind, transform, semantic metadata and geometry reference/data |
| `bounds` | World-space axis-aligned `min`/`max` |
| `validation` | Mesh reports, warnings, and explicit flags for checks not performed |
| `stats` | Node/object counts, unique vertex/mesh counts, triangles including instances |
| `bundle_hash` | SHA-256 of canonical bundle JSON excluding this field |
Every vertex is `[x,y,z]` in metres. Every face is exactly three zero-based vertex indices with outward winding for solid meshes. Geometry hash covers the complete `{"vertices":...,"faces":...}` object. Mesh instances reference it through `geometry`; they do not duplicate vertex arrays.
Every object has `id`, `node_id`, `name`, `kind`, `role`, `color` and a 4×4 `matrix`. Its bottom row is `[0,0,0,1]`; matrices are invertible and orientation-preserving. Translation is in the last column. A local position becomes `matrix * [x,y,z,1]`. Colour has three normalized RGB components.
Mesh object IDs combine node ID and part key, e.g. `braided-galleries/0000/main`. A curve object instead embeds `points` and `closed` and uses ID `NODE/path`. Its points are joined in order, with the final-to-first edge added only for closed curves. Frames are individual objects of kind `frame`, with local axes in matrix columns and their origin in its last column; ID `NODE/frame-0000`.
Canonical JSON is the Python kernel's `json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)` encoded as UTF-8. These integrity hashes are for reproducibility and accidental-corruption detection, not authentication. `validate_bundle` checks the supported convention, hashes, references, finite coordinates, transforms and triangle/edge structure before import. Bundles should be generated by `build` rather than assembled manually. A consumer only needs the evaluated geometry and matrices to reproduce the shape; it does not need the expression evaluator or custom operators.
Blender natively uses the same axis convention. Unity/Godot/custom-engine adapters must explicitly convert basis/units and triangle winding if their chosen basis changes handedness. No such engine adapter is included yet.
+284
View File
@@ -0,0 +1,284 @@
{
"schema": "spatial-lab.project/1",
"id": "braided-atrium",
"name": "Braided atrium",
"parameters": {
"radius": 11,
"lobe": 3.2,
"rise": 4.8,
"width": 1.25,
"thickness": 0.24,
"twist": 360,
"samples": 240,
"levels": 3
},
"nodes": [
{
"id": "braid-path",
"op": "curve",
"params": {
"xyz": [
"(radius+lobe*cos(3*t))*cos(2*t)",
"(radius+lobe*cos(3*t))*sin(2*t)",
"rise*sin(3*t)"
],
"domain": [
0,
"tau"
],
"segments": "samples",
"closed": true
},
"visible": false,
"role": "route"
},
{
"id": "braid-frames",
"op": "frames",
"inputs": {
"path": "braid-path"
},
"params": {
"twist_degrees": "twist"
},
"visible": false
},
{
"id": "braid-section",
"op": "sweep",
"inputs": {
"frames": "braid-frames"
},
"params": {
"profile": [
[
"-width/2",
"-thickness/2"
],
[
"width/2",
"-thickness/2"
],
[
"width/2",
"thickness/2"
],
[
"-width/2",
"thickness/2"
]
]
},
"visible": false
},
{
"id": "braided-galleries",
"name": "Braided galleries",
"op": "repeat",
"inputs": {
"source": "braid-section"
},
"params": {
"count": "levels",
"translate": [
0,
0,
6.5
],
"rotate": [
0,
0,
37
],
"scale": 0.87
},
"color": [
0.37,
0.56,
0.68
],
"role": "gallery"
},
{
"id": "ring-path",
"op": "curve",
"params": {
"xyz": [
"(radius+lobe+2.6)*cos(t)",
"(radius+lobe+2.6)*sin(t)",
"-6+0.65*sin(4*t)"
],
"segments": 144,
"closed": true
},
"visible": false
},
{
"id": "ring-frames",
"op": "frames",
"inputs": {
"path": "ring-path"
},
"visible": false
},
{
"id": "ring-section",
"op": "sweep",
"inputs": {
"frames": "ring-frames"
},
"params": {
"profile": [
[
-0.13,
-0.11
],
[
0.13,
-0.11
],
[
0.13,
0.11
],
[
-0.13,
0.11
]
]
},
"visible": false
},
{
"id": "outer-rings",
"name": "Contour rings",
"op": "repeat",
"inputs": {
"source": "ring-section"
},
"params": {
"count": 9,
"translate": [
0,
0,
2.8
],
"rotate": [
0,
0,
11
]
},
"color": [
0.63,
0.68,
0.73
],
"role": "structure"
},
{
"id": "spine-path",
"op": "curve",
"params": {
"xyz": [
"(radius+lobe+2.6)*cos(t)",
"(radius+lobe+2.6)*sin(t)",
"-6+18*t/pi"
],
"domain": [
0,
"1.35*pi"
],
"segments": 112
},
"visible": false
},
{
"id": "spine-frames",
"op": "frames",
"inputs": {
"path": "spine-path"
},
"visible": false
},
{
"id": "spine-section",
"op": "sweep",
"inputs": {
"frames": "spine-frames"
},
"params": {
"profile": [
[
-0.18,
-0.18
],
[
0.18,
-0.18
],
[
0.18,
0.18
],
[
-0.18,
0.18
]
]
},
"visible": false
},
{
"id": "spiral-spines",
"name": "Spiral spines",
"op": "repeat",
"inputs": {
"source": "spine-section"
},
"params": {
"count": 4,
"rotate": [
0,
0,
90
]
},
"color": [
0.69,
0.47,
0.29
],
"role": "structure"
},
{
"id": "saddle-shell",
"name": "Lower saddle",
"op": "surface",
"params": {
"xyz": [
"u",
"v",
"0.025*(u*u-v*v)-9"
],
"u_domain": [
-7,
7
],
"v_domain": [
-7,
7
],
"u_segments": 28,
"v_segments": 28,
"thickness": 0.22
},
"color": [
0.57,
0.66,
0.58
],
"role": "shell"
}
]
}
Binary file not shown.
@@ -0,0 +1,56 @@
{
"blender_version": "5.2.2 LTS",
"initial": {
"passed": true,
"project_id": "braided-atrium",
"bundle_hash": "c7fbfd0528618a64c9868a908dbc19de3a40cec5e6a6247ef52937b0ace462eb",
"objects_checked": 17,
"vertices_checked_including_instances": 11554,
"triangles_checked_including_instances": 23088,
"tolerance_m": 1e-05,
"max_local_vertex_error_m": 1.0060402715329934e-06,
"max_world_vertex_error_m": 3.1225857623483306e-06,
"max_matrix_component_error": 7.629394538355427e-07,
"errors": [],
"scope": "Base mesh coordinates, connectivity, orientation and transforms. Artist modifiers are deliberately excluded."
},
"edited": {
"passed": true,
"project_id": "braided-atrium",
"bundle_hash": "48d124fa042aa478054370e11d7a063edae36f2b5541a578429227ca1fd46b13",
"objects_checked": 16,
"vertices_checked_including_instances": 10594,
"triangles_checked_including_instances": 21168,
"tolerance_m": 1e-05,
"max_local_vertex_error_m": 1.0060402715329934e-06,
"max_world_vertex_error_m": 3.1225857623483306e-06,
"max_matrix_component_error": 7.629394538355427e-07,
"errors": [],
"scope": "Base mesh coordinates, connectivity, orientation and transforms. Artist modifiers are deliberately excluded."
},
"undo": {
"passed": true,
"project_id": "braided-atrium",
"bundle_hash": "c7fbfd0528618a64c9868a908dbc19de3a40cec5e6a6247ef52937b0ace462eb",
"objects_checked": 17,
"vertices_checked_including_instances": 11554,
"triangles_checked_including_instances": 23088,
"tolerance_m": 1e-05,
"max_local_vertex_error_m": 1.0060402715329934e-06,
"max_world_vertex_error_m": 3.1225857623483306e-06,
"max_matrix_component_error": 7.629394538355427e-07,
"errors": [],
"scope": "Base mesh coordinates, connectivity, orientation and transforms. Artist modifiers are deliberately excluded."
},
"changed_import": {
"created": 0,
"updated": 16,
"removed": [
"braided-galleries/0002/main"
],
"collection": "Spatial Lab / Braided atrium",
"bundle_hash": "48d124fa042aa478054370e11d7a063edae36f2b5541a578429227ca1fd46b13"
},
"preserved_object_identity_modifiers_materials_and_unrelated_objects": true,
"undo_restored_identical_bundle": true
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
"""Example of a composable curve deformation loaded with --plugin."""
import math
from spatial_lab.kernel import register_operator
@register_operator('wave-z', version=1, inputs=('path',),
description='Displace a curve along world Z by a sinusoid of world X.')
def wave_z(context, params, inputs):
path = inputs['path']
if path['kind'] != 'curve':
raise ValueError('wave-z requires a curve')
amplitude = context.number(params.get('amplitude', 1))
wavelength = context.number(params.get('wavelength', 6))
if wavelength <= 0:
raise ValueError('wavelength must be positive')
points = [[x, y, z + amplitude * math.sin(math.tau * x / wavelength)]
for x, y, z in path['points']]
return {'kind': 'curve', 'points': points, 'closed': path['closed']}
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "spatial-lab"
version = "0.1.0"
description = "Agent-operated mathematical geometry workbench with a verified Blender bridge"
requires-python = ">=3.11"
dependencies = []
[project.scripts]
spatial-lab = "spatial_lab.cli:main"
[tool.setuptools]
packages = ["spatial_lab"]
[tool.setuptools.package-data]
spatial_lab = ["web/*"]
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env python3
"""Run from any working directory without installation."""
from spatial_lab.cli import main
raise SystemExit(main())
+7
View File
@@ -0,0 +1,7 @@
"""Spatial Lab: editable mathematical construction graphs and portable geometry."""
__version__ = "0.1.0"
from .kernel import build, register_operator
from .project import Project
__all__ = ["build", "register_operator", "Project"]
+2
View File
@@ -0,0 +1,2 @@
from .cli import main
raise SystemExit(main())
+61
View File
@@ -0,0 +1,61 @@
import argparse
import json
import sys
from pathlib import Path
from . import __version__
from .kernel import build, operator_catalog, load_plugin, validate_bundle
from .project import Project, read_json, write_json
from .examples import demo_project, small_project
def main(argv=None):
p=argparse.ArgumentParser(prog='spatial-lab',description='Mathematical geometry workbench; author through commands, inspect in a browser, transfer exact meshes to Blender.')
p.add_argument('--version',action='version',version=__version__)
p.add_argument('--plugin',action='append',default=[],help='Load a trusted local Python operator module (repeatable)')
s=p.add_subparsers(dest='command',required=True)
q=s.add_parser('init',help='Create an editable project');q.add_argument('project');q.add_argument('--example',choices=['atrium','simple'],default='atrium')
q=s.add_parser('build',help='Evaluate and export the technical bundle');q.add_argument('project');q.add_argument('--out',required=True)
q=s.add_parser('inspect',help='Show evaluated nodes and mesh checks');q.add_argument('project');q.add_argument('--node')
q=s.add_parser('param',help='Change a global parameter transactionally');q.add_argument('project');q.add_argument('name');q.add_argument('value',help='JSON value, e.g. 12 or "2*pi"')
q=s.add_parser('patch',help='Apply an atomic list of graph changes');q.add_argument('project');q.add_argument('changes',help='JSON file containing an array of changes')
q=s.add_parser('node',help='Add or replace a node by stable ID');q.add_argument('project');q.add_argument('spec',help='JSON file containing one node')
q=s.add_parser('remove',help='Remove a node');q.add_argument('project');q.add_argument('node_id');q.add_argument('--cascade',action='store_true')
for name in ['undo','redo']:
q=s.add_parser(name);q.add_argument('project')
s.add_parser('ops',help='List installed operators and their versions')
q=s.add_parser('check',help='Check bundle integrity');q.add_argument('bundle')
q=s.add_parser('serve',help='Run the read-only, live geometry viewer');q.add_argument('project');q.add_argument('--port',type=int,default=8767)
a=p.parse_args(argv)
try:
for plugin in a.plugin:load_plugin(plugin)
if a.command=='ops':result=operator_catalog()
elif a.command=='init':
if Path(a.project).exists():raise ValueError('Project already exists; choose another path')
project=demo_project() if a.example=='atrium' else small_project();b=build(project);write_json(a.project,project);result={'project':str(Path(a.project).resolve()),**b['stats']}
elif a.command=='build':
b=Project(a.project).build();write_json(a.out,b);result={'bundle':str(Path(a.out).resolve()),'hash':b['bundle_hash'],**b['stats'],'warnings':b['validation']['warnings']}
elif a.command=='inspect':
b=Project(a.project).build()
if a.node:
result=next((n for n in b['nodes'] if n['id']==a.node),None)
if result is None:raise ValueError('No such node: '+a.node)
else:result={'name':b['name'],'parameters':b['recipe'].get('parameters',{}),'nodes':b['nodes'],'stats':b['stats'],'bounds':b['bounds'],'validation':b['validation']}
elif a.command=='check':
b=read_json(a.bundle);validate_bundle(b);result={'valid':True,'hash':b['bundle_hash'],'stats':b['stats']}
elif a.command=='serve':
from .server import serve
serve(a.project,a.port);return 0
else:
project=Project(a.project)
if a.command in ['undo','redo']:b=project.travel(a.command)
else:
if a.command=='patch':changes=read_json(a.changes)
elif a.command=='node':changes=[{'action':'upsert_node','node':read_json(a.spec)}]
elif a.command=='remove':changes=[{'action':'remove_node','id':a.node_id,'cascade':a.cascade}]
elif a.command=='param':changes=[{'action':'set_parameter','name':a.name,'value':json.loads(a.value)}]
b=project.apply(changes)
result={'action':a.command,'project_hash':b['project_hash'],**b['stats'],'warnings':b['validation']['warnings']}
print(json.dumps(result,indent=2,allow_nan=False));return 0
except (ValueError,OSError,KeyError,TypeError) as e:
print(json.dumps({'error':str(e)}),file=sys.stderr);return 2
if __name__=='__main__':raise SystemExit(main())
+28
View File
@@ -0,0 +1,28 @@
"""Editable examples built exclusively from expressions, frames, sections and transforms."""
from .kernel import PROJECT_SCHEMA
def demo_project():
return {
'schema':PROJECT_SCHEMA,'id':'braided-atrium','name':'Braided atrium',
'parameters':{'radius':11,'lobe':3.2,'rise':4.8,'width':1.25,'thickness':.24,'twist':360,'samples':240,'levels':3},
'nodes':[
{'id':'braid-path','op':'curve','params':{'xyz':['(radius+lobe*cos(3*t))*cos(2*t)','(radius+lobe*cos(3*t))*sin(2*t)','rise*sin(3*t)'],'domain':[0,'tau'],'segments':'samples','closed':True},'visible':False,'role':'route'},
{'id':'braid-frames','op':'frames','inputs':{'path':'braid-path'},'params':{'twist_degrees':'twist'},'visible':False},
{'id':'braid-section','op':'sweep','inputs':{'frames':'braid-frames'},'params':{'profile':[['-width/2','-thickness/2'],['width/2','-thickness/2'],['width/2','thickness/2'],['-width/2','thickness/2']]},'visible':False},
{'id':'braided-galleries','name':'Braided galleries','op':'repeat','inputs':{'source':'braid-section'},'params':{'count':'levels','translate':[0,0,6.5],'rotate':[0,0,37],'scale':.87},'color':[.37,.56,.68],'role':'gallery'},
{'id':'ring-path','op':'curve','params':{'xyz':['(radius+lobe+2.6)*cos(t)','(radius+lobe+2.6)*sin(t)','-6+0.65*sin(4*t)'],'segments':144,'closed':True},'visible':False},
{'id':'ring-frames','op':'frames','inputs':{'path':'ring-path'},'visible':False},
{'id':'ring-section','op':'sweep','inputs':{'frames':'ring-frames'},'params':{'profile':[[-.13,-.11],[.13,-.11],[.13,.11],[-.13,.11]]},'visible':False},
{'id':'outer-rings','name':'Contour rings','op':'repeat','inputs':{'source':'ring-section'},'params':{'count':9,'translate':[0,0,2.8],'rotate':[0,0,11]},'color':[.63,.68,.73],'role':'structure'},
{'id':'spine-path','op':'curve','params':{'xyz':['(radius+lobe+2.6)*cos(t)','(radius+lobe+2.6)*sin(t)','-6+18*t/pi'],'domain':[0,'1.35*pi'],'segments':112},'visible':False},
{'id':'spine-frames','op':'frames','inputs':{'path':'spine-path'},'visible':False},
{'id':'spine-section','op':'sweep','inputs':{'frames':'spine-frames'},'params':{'profile':[[-.18,-.18],[.18,-.18],[.18,.18],[-.18,.18]]},'visible':False},
{'id':'spiral-spines','name':'Spiral spines','op':'repeat','inputs':{'source':'spine-section'},'params':{'count':4,'rotate':[0,0,90]},'color':[.69,.47,.29],'role':'structure'},
{'id':'saddle-shell','name':'Lower saddle','op':'surface','params':{'xyz':['u','v','0.025*(u*u-v*v)-9'],'u_domain':[-7,7],'v_domain':[-7,7],'u_segments':28,'v_segments':28,'thickness':.22},'color':[.57,.66,.58],'role':'shell'}
]}
def small_project():
return {'schema':PROJECT_SCHEMA,'id':'first-study','name':'First study','parameters':{'height':4},'nodes':[
{'id':'path','op':'curve','params':{'xyz':['8*cos(t)','8*sin(t)','height*t/tau'],'domain':[0,'tau'],'segments':96},'visible':False},
{'id':'local-frames','op':'frames','inputs':{'path':'path'},'visible':False},
{'id':'gallery','op':'sweep','inputs':{'frames':'local-frames'},'params':{'profile':[[-1,-.15],[1,-.15],[1,.15],[-1,.15]]},'role':'gallery'}]}
+56
View File
@@ -0,0 +1,56 @@
"""Bounded arithmetic expressions, interpreted without eval or Python execution."""
import ast
import math
from functools import lru_cache
FUNCTIONS={k:getattr(math,k) for k in ['sin','cos','tan','asin','acos','atan','atan2','sqrt','exp','log','floor','ceil','radians','degrees']}
FUNCTIONS.update(abs=abs,min=min,max=max)
CONSTANTS={'pi':math.pi,'tau':math.tau,'e':math.e}
@lru_cache(maxsize=512)
def parse(text):
if len(text)>2048:raise ValueError('Expression exceeds 2048 characters')
tree=ast.parse(text,mode='eval')
if sum(1 for _ in ast.walk(tree))>128:raise ValueError('Expression is too complex')
permitted=(ast.Expression,ast.Constant,ast.Name,ast.Load,ast.BinOp,ast.UnaryOp,ast.Call,ast.Add,ast.Sub,ast.Mult,ast.Div,ast.Pow,ast.Mod,ast.USub,ast.UAdd)
for n in ast.walk(tree):
if not isinstance(n,permitted):raise ValueError(f'Unsupported expression syntax: {type(n).__name__}')
if isinstance(n,ast.Constant) and (isinstance(n.value,bool) or not isinstance(n.value,(int,float))):raise ValueError('Only numeric literals are allowed')
if isinstance(n,ast.Call) and (not isinstance(n.func,ast.Name) or n.func.id not in FUNCTIONS or n.keywords):raise ValueError('Only documented math functions are allowed')
return tree.body
def scalar(value, variables=None):
env={**CONSTANTS,**(variables or {})}
def walk(n):
if isinstance(n,ast.Constant):return float(n.value)
if isinstance(n,ast.Name):
if n.id not in env:raise ValueError(f'Unknown symbol: {n.id}')
return float(env[n.id])
if isinstance(n,ast.UnaryOp):return -walk(n.operand) if isinstance(n.op,ast.USub) else walk(n.operand)
if isinstance(n,ast.Call):return FUNCTIONS[n.func.id](*[walk(a) for a in n.args])
a,b=walk(n.left),walk(n.right)
if isinstance(n.op,ast.Pow):
if abs(b)>32 or abs(a)>1e9:raise ValueError('Power operands exceed limits')
return a**b
if isinstance(n.op,ast.Add):return a+b
if isinstance(n.op,ast.Sub):return a-b
if isinstance(n.op,ast.Mult):return a*b
if isinstance(n.op,ast.Div):return a/b
if isinstance(n.op,ast.Mod):return a%b
raise ValueError('Unsupported operator')
try:
if isinstance(value,bool):raise ValueError('A boolean is not a numeric expression')
result=walk(parse(value)) if isinstance(value,str) else float(value)
if isinstance(result,complex) or not math.isfinite(result) or abs(result)>1e9:raise ValueError('Expression result must be finite and within ±1e9')
return result
except (ZeroDivisionError,OverflowError,TypeError,ValueError,SyntaxError,RecursionError) as e:
raise ValueError(f'Invalid expression {str(value)[:100]!r}: {e}') from e
def vector(value,variables=None,size=3):
if not isinstance(value,(list,tuple)) or len(value)!=size:raise ValueError(f'Expected a {size}-component vector')
return [scalar(v,variables) for v in value]
def integer(value,variables=None,low=1,high=2048):
n=scalar(value,variables)
if n!=int(n) or not low<=n<=high:raise ValueError(f'Expected an integer in [{low}, {high}]')
return int(n)
+170
View File
@@ -0,0 +1,170 @@
"""Construction graph evaluation and the neutral, deterministic geometry contract."""
import copy
import hashlib
import importlib.util
import json
import math
import re
from dataclasses import dataclass
from pathlib import Path
from .expressions import scalar, vector, integer, CONSTANTS, FUNCTIONS
from .math3d import identity, point, mesh_report, length, sub
PROJECT_SCHEMA='spatial-lab.project/1'
BUNDLE_SCHEMA='spatial-lab.bundle/1'
OPERATORS={}
def canonical(data):return json.dumps(data,sort_keys=True,separators=(',',':'),allow_nan=False)
def digest(data):return hashlib.sha256(canonical(data).encode()).hexdigest()
def register_operator(name,version=1,description='',inputs=()):
"""Register a trusted Python operator: function(context, params, resolved_inputs)."""
def decorate(fn):
if name in OPERATORS:raise ValueError(f'Operator already registered: {name}')
OPERATORS[name]={'function':fn,'version':version,'description':description,'inputs':list(inputs)}
return fn
return decorate
def operator_catalog():
from . import operators # noqa: F401
return {name:{k:v for k,v in op.items() if k!='function'} for name,op in sorted(OPERATORS.items())}
def load_plugin(path):
operator_catalog()
p=Path(path).resolve()
spec=importlib.util.spec_from_file_location('spatial_lab_extension_'+hashlib.sha256(str(p).encode()).hexdigest()[:16],p)
if not spec or not spec.loader:raise ValueError(f'Cannot load plugin: {p}')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
@dataclass
class Context:
variables:dict
node_id:str
def number(self,v):return scalar(v,self.variables)
def vec(self,v,size=3):return vector(v,self.variables,size)
def count(self,v,low=1,high=2048):return integer(v,self.variables,low,high)
def expression(self,expression,**variables):return scalar(expression,{**self.variables,**variables})
def dependencies(node):
out=[]
for v in node.get('inputs',{}).values():
if isinstance(v,str):out.append(v)
elif isinstance(v,list) and all(isinstance(x,str) for x in v):out.extend(v)
else:raise ValueError(f"{node.get('id')}: input references must be node IDs or lists of IDs")
return out
def evaluate(project):
operator_catalog()
if project.get('schema')!=PROJECT_SCHEMA:raise ValueError(f'Expected {PROJECT_SCHEMA}')
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,80}',project.get('id','')):raise ValueError('Project ID must contain 180 letters, digits, dots, underscores or hyphens')
nodes=project.get('nodes')
if not isinstance(nodes,list) or len(nodes)>256:raise ValueError('Project needs a nodes array with at most 256 nodes')
variables={}
for k,v in project.get('parameters',{}).items():
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',k) or k in {*CONSTANTS,*FUNCTIONS,'t','u','v'}:raise ValueError(f'Reserved or invalid parameter name: {k}')
variables[k]=scalar(v)
by_id={}
for node in nodes:
name=node.get('id','')
if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_.-]{0,63}',name):raise ValueError(f'Invalid node ID: {name}')
if name in by_id:raise ValueError(f'Duplicate node ID: {name}')
if not isinstance(node.get('params',{}),dict) or not isinstance(node.get('inputs',{}),dict):raise ValueError(f'{name}: params and inputs must be objects')
by_id[name]=node
results={};visiting=[];order=[]
def visit(name):
if name in results:return results[name]
if name in visiting:raise ValueError('Dependency cycle: '+''.join(visiting+[name]))
if name not in by_id:raise ValueError(f'Missing node: {name}')
node=by_id[name];op=OPERATORS.get(node.get('op'))
if op is None:raise ValueError(f"{name}: unknown operator {node.get('op')!r}; load its plugin explicitly")
if node.get('version',1)!=op['version']:raise ValueError(f'{name}: unsupported operator version')
raw=node.get('inputs',{})
if set(raw)!=set(op['inputs']):raise ValueError(f"{name}: expected inputs {op['inputs']}, got {list(raw)}")
dependencies(node);visiting.append(name)
inputs={k:visit(v) if isinstance(v,str) else [visit(x) for x in v] for k,v in raw.items()}
try:result=op['function'](Context(variables,name),node.get('params',{}),inputs)
except (ValueError,KeyError,TypeError,IndexError,OverflowError) as e:raise ValueError(f'{name} ({node["op"]}): {e}') from e
if not isinstance(result,dict) or result.get('kind') not in ['curve','frames','mesh']:raise ValueError(f'{name}: operator returned an unsupported entity')
results[name]=result;visiting.pop();order.append(name)
return result
for name in by_id:visit(name)
return results,order
def _finite_vectors(vertices,size=3):
if not vertices:raise ValueError('Geometry is empty')
for p in vertices:
if len(p)!=size or any(not isinstance(x,(int,float)) or not math.isfinite(x) or abs(x)>1e9 for x in p):raise ValueError('Invalid or nonfinite geometry coordinates')
def build(project):
results,order=evaluate(project);nodes={n['id']:n for n in project['nodes']}
geometries={};objects=[];reports={};bounds_points=[];warnings=[];node_info=[]
for name in order:
node=nodes[name];entity=results[name];kind=entity['kind']
summary={'id':name,'op':node['op'],'kind':kind,'inputs':node.get('inputs',{}),'params':node.get('params',{}),'visible':node.get('visible',True),'role':node.get('role','geometry')}
if kind=='curve':
pts=entity['points'];_finite_vectors(pts)
summary['points']=len(pts);summary['length_m']=sum(length(sub(b,a)) for a,b in zip(pts,pts[1:]+([pts[0]] if entity.get('closed') else [])))
if 'estimated_chord_error' in entity:summary['estimated_chord_error_m']=entity['estimated_chord_error']
elif kind=='frames':summary['frames']=len(entity['frames'])
else:summary['parts']=len(entity['parts'])
node_info.append(summary)
if not node.get('visible',True):continue
color=vector(node.get('color',[.40,.56,.68]))
if any(c<0 or c>1 for c in color):raise ValueError(f'{name}: color channels must be in [0,1]')
base={'node_id':name,'name':node.get('name',name),'role':node.get('role','geometry'),'color':color}
if kind=='mesh':
if len(entity['parts'])>2048:raise ValueError(f'{name}: more than 2048 instances')
keys=set()
for part in entity['parts']:
key=str(part.get('key','main'))
if key in keys:raise ValueError(f'{name}: duplicate part key {key}')
keys.add(key)
geom={'vertices':part['vertices'],'faces':part['faces']}
gid=digest(geom)
if gid not in geometries:
_finite_vectors(geom['vertices'])
if len(geom['vertices'])>200000 or len(geom['faces'])>500000:raise ValueError('Per-geometry complexity limit exceeded')
report=mesh_report(**geom)
if report['degenerate_triangles'] or report['nonmanifold_edges'] or report['inconsistent_edges']:raise ValueError(f'{name}: invalid mesh topology: {report}')
if part.get('closed',False) and (report['boundary_edges'] or report['signed_volume']<=1e-10):raise ValueError(f'{name}: expected an outward-oriented closed solid: {report}')
geometries[gid]=geom;reports[gid]=report
if report['boundary_edges']:warnings.append(f'{name}: open surface ({report["boundary_edges"]} boundary edges)')
matrix=part.get('matrix',identity());_finite_vectors(matrix,4)
if len(matrix)!=4 or matrix[3]!=[0,0,0,1]:raise ValueError('Transforms must be affine 4×4 matrices')
from .math3d import determinant
if determinant(matrix)<=1e-10:raise ValueError('Transforms must preserve orientation and be invertible')
objects.append({**base,'id':name+'/'+key,'kind':'mesh','geometry':gid,'matrix':matrix})
bounds_points.extend(point(matrix,p) for p in geom['vertices'])
elif kind=='curve':
objects.append({**base,'id':name+'/path','kind':'curve','points':entity['points'],'closed':entity.get('closed',False),'matrix':identity()})
bounds_points.extend(entity['points'])
else:
for i,f in enumerate(entity['frames']):
m=[[f['x'][j],f['y'][j],f['z'][j],f['origin'][j]] for j in range(3)]+[[0,0,0,1]]
objects.append({**base,'id':name+f'/frame-{i:04}','kind':'frame','matrix':m});bounds_points.append(f['origin'])
if len(objects)>4096 or sum(len(g['vertices']) for g in geometries.values())>500000 or len(bounds_points)>2000000:raise ValueError('Scene complexity limit exceeded')
bounds={'min':[min(p[i] for p in bounds_points) for i in range(3)],'max':[max(p[i] for p in bounds_points) for i in range(3)]} if bounds_points else {'min':[-1,-1,-1],'max':[1,1,1]}
bundle={'schema':BUNDLE_SCHEMA,'project_id':project['id'],'name':project.get('name',project['id']),'project_hash':digest(project),'units':'meters','handedness':'right','up_axis':'+Z','matrix_convention':'row arrays; column vectors; local-to-world','recipe':copy.deepcopy(project),'nodes':node_info,'geometries':geometries,'objects':objects,'bounds':bounds,'validation':{'geometry_reports':reports,'warnings':warnings,'self_intersections_checked':False,'walkability_checked':False},'stats':{'nodes':len(nodes),'objects':len(objects),'unique_meshes':len(geometries),'vertices':sum(len(g['vertices']) for g in geometries.values()),'triangles':sum(len(geometries[o['geometry']]['faces']) for o in objects if o['kind']=='mesh')}}
bundle['bundle_hash']=digest(bundle)
return bundle
def validate_bundle(bundle):
"""Validate untrusted bundle data before handing it to an adapter."""
if bundle.get('schema')!=BUNDLE_SCHEMA:raise ValueError('Unsupported bundle schema')
if (bundle.get('units'),bundle.get('handedness'),bundle.get('up_axis'))!=('meters','right','+Z'):raise ValueError('Unsupported coordinate convention')
if digest({k:v for k,v in bundle.items() if k!='bundle_hash'})!=bundle.get('bundle_hash'):raise ValueError('Bundle hash does not match its contents')
for gid,g in bundle['geometries'].items():
if digest(g)!=gid:raise ValueError(f'Geometry hash mismatch: {gid}')
_finite_vectors(g['vertices']);report=mesh_report(**g)
if report['degenerate_triangles'] or report['nonmanifold_edges'] or report['inconsistent_edges']:raise ValueError('Invalid mesh topology in bundle')
ids=set()
for o in bundle['objects']:
if o['id'] in ids:raise ValueError('Duplicate object ID')
ids.add(o['id'])
if o.get('kind') not in ['mesh','curve','frame']:raise ValueError('Unsupported object kind')
m=o['matrix'];_finite_vectors(m,4)
from .math3d import determinant
if len(m)!=4 or m[3]!=[0,0,0,1] or determinant(m)<=1e-10:raise ValueError('Invalid object transform')
if o['kind']=='mesh' and o['geometry'] not in bundle['geometries']:raise ValueError('Missing geometry reference')
if o['kind']=='curve':_finite_vectors(o['points'])
return True
+111
View File
@@ -0,0 +1,111 @@
"""Small, dependency-free geometry routines. Right-handed, Z-up, metres."""
import math
EPS = 1e-10
def add(a, b): return [a[i] + b[i] for i in range(3)]
def sub(a, b): return [a[i] - b[i] for i in range(3)]
def mul(a, s): return [v * s for v in a]
def dot(a, b): return sum(x*y for x, y in zip(a, b))
def cross(a, b): return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]
def length(a): return math.sqrt(dot(a, a))
def unit(a):
d = length(a)
if d < EPS: raise ValueError("Cannot normalize a zero-length vector")
return mul(a, 1/d)
def identity(): return [[float(i == j) for j in range(4)] for i in range(4)]
def matmul(a, b): return [[sum(a[i][k]*b[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
def point(m, p): return [sum(m[i][j]*p[j] for j in range(3)) + m[i][3] for i in range(3)]
def direction(m, p): return [sum(m[i][j]*p[j] for j in range(3)) for i in range(3)]
def determinant(m): return dot(m[0][:3], cross(m[1][:3], m[2][:3]))
def rotate(v, axis, angle):
c, s = math.cos(angle), math.sin(angle)
return add(add(mul(v, c), mul(cross(axis, v), s)), mul(axis, dot(axis, v)*(1-c)))
def transport(v, old_t, new_t):
axis = cross(old_t, new_t); sn = length(axis); cs = max(-1., min(1., dot(old_t, new_t)))
if sn < EPS:
if cs < 0: raise ValueError("Curve reverses direction by 180 degrees; refine or change the path")
return v[:]
return rotate(v, mul(axis, 1/sn), math.atan2(sn, cs))
def transform(translation=(0,0,0), rotation=(0,0,0), scale=(1,1,1)):
if isinstance(scale, (int, float)): scale = [scale]*3
if len(scale) != 3 or any(s <= 0 for s in scale): raise ValueError("Scale must have three positive components")
rx, ry, rz = [math.radians(v) for v in rotation]
cx,sx,cy,sy,cz,sz = math.cos(rx),math.sin(rx),math.cos(ry),math.sin(ry),math.cos(rz),math.sin(rz)
x = [[1,0,0,0],[0,cx,-sx,0],[0,sx,cx,0],[0,0,0,1]]
y = [[cy,0,sy,0],[0,1,0,0],[-sy,0,cy,0],[0,0,0,1]]
z = [[cz,-sz,0,0],[sz,cz,0,0],[0,0,1,0],[0,0,0,1]]
m = matmul(z,matmul(y,x))
for i in range(3):
for j in range(3): m[i][j] *= scale[j]
m[i][3] = translation[i]
return m
def frames(points, closed=False, up=(0,0,1), twist=0):
"""Rotation-minimizing frames, with distributed holonomy correction on closed paths."""
n = len(points)
if n < (3 if closed else 2): raise ValueError("Too few points for frames")
tangents = []
for i in range(n):
a = points[(i-1)%n] if closed or i else points[0]
b = points[(i+1)%n] if closed or i < n-1 else points[-1]
tangents.append(unit(sub(b,a)))
t0 = tangents[0]; right = cross(t0, unit(up))
if length(right) < EPS:
axis = min([[1,0,0],[0,1,0],[0,0,1]], key=lambda x: abs(dot(x,t0)))
right = cross(t0,axis)
rights = [unit(right)]
for i in range(1,n): rights.append(unit(transport(rights[-1],tangents[i-1],tangents[i])))
correction = 0
if closed:
if abs(twist/360-round(twist/360)) > 1e-8:
raise ValueError("Closed frames require twist_degrees to be a multiple of 360")
seam = transport(rights[-1], tangents[-1], tangents[0])
correction = math.atan2(dot(t0,cross(seam,rights[0])),dot(seam,rights[0]))
out = []
for i,(p,t,r) in enumerate(zip(points,tangents,rights)):
a = (correction+math.radians(twist))*i/(n if closed else n-1)
r = unit(rotate(r,t,a)); u = unit(cross(r,t))
out.append({"origin":p[:], "x":r, "y":t, "z":u})
return out
def triangulate_polygon(points):
"""Ear clipping in 2D; accepts either winding, rejects invalid/degenerate profiles."""
n = len(points)
if n < 3: raise ValueError("A section needs at least three vertices")
def orient(a,b,c): return (b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])
area = sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(points,points[1:]+points[:1]))*.5
if abs(area) < EPS: raise ValueError("Section has zero area")
# A simple polygon must not have crossings or repeated vertices.
for i in range(n):
for j in range(i+1,n):
if math.dist(points[i],points[j]) < EPS: raise ValueError("Section has duplicate vertices")
if j == i+1 or (i==0 and j==n-1): continue
a,b,c,d=points[i],points[(i+1)%n],points[j],points[(j+1)%n]
if orient(a,b,c)*orient(a,b,d)<-EPS and orient(c,d,a)*orient(c,d,b)<-EPS:
raise ValueError("Section self-intersects")
ids = list(range(n)) if area > 0 else list(reversed(range(n)))
triangles=[]
while len(ids)>3:
found=False
for k in range(len(ids)):
a,b,c=ids[k-1],ids[k],ids[(k+1)%len(ids)]
if orient(points[a],points[b],points[c])<=EPS: continue
inside=any(all(v>=-EPS for v in [orient(points[a],points[b],points[q]),orient(points[b],points[c],points[q]),orient(points[c],points[a],points[q])]) for q in ids if q not in (a,b,c))
if inside:continue
triangles.append([a,b,c]);ids.pop(k);found=True;break
if not found:raise ValueError("Section cannot be triangulated; remove collinear or crossing edges")
triangles.append(ids)
return triangles, area
def mesh_report(vertices, faces):
edges={}; directed={}; degenerate=[]; volume=0.
for i,f in enumerate(faces):
if len(f)!=3 or len(set(f))!=3 or any(not isinstance(x,int) or x<0 or x>=len(vertices) for x in f):
raise ValueError(f"Invalid triangle {i}: {f}")
a,b,c=[vertices[j] for j in f]
if length(cross(sub(b,a),sub(c,a)))<1e-10: degenerate.append(i)
volume+=dot(a,cross(b,c))/6
for x,y in zip(f,f[1:]+f[:1]):
key=tuple(sorted((x,y)));edges[key]=edges.get(key,0)+1
directed[key]=directed.get(key,0)+(1 if x<y else -1)
return {"vertices":len(vertices),"triangles":len(faces),"boundary_edges":sum(v==1 for v in edges.values()),"nonmanifold_edges":sum(v>2 for v in edges.values()),"inconsistent_edges":sum(edges[k]==2 and v!=0 for k,v in directed.items()),"degenerate_triangles":len(degenerate),"signed_volume":volume}
+142
View File
@@ -0,0 +1,142 @@
"""Built-in construction tools. Plugins register the same small interface."""
import math
from .kernel import register_operator
from .math3d import *
def require(entity,kind):
if entity.get('kind')!=kind:raise ValueError(f'Expected {kind}, got {entity.get("kind")}')
return entity
def mesh(vertices,faces,closed=True):
if closed and mesh_report(vertices,faces)['signed_volume']<0:faces=[list(reversed(f)) for f in faces]
return {'kind':'mesh','parts':[{'key':'main','vertices':vertices,'faces':faces,'closed':closed,'matrix':identity()}]}
@register_operator('curve',description='Sample a parametric 3D curve x(t), y(t), z(t). Chord-error estimate is recorded.',inputs=())
def curve(c,p,inputs):
expressions=p.get('xyz')
if not isinstance(expressions,list) or len(expressions)!=3:raise ValueError('xyz must contain three expressions')
a,b=c.vec(p.get('domain',[0,'tau']),2);n=c.count(p.get('segments',128),low=2,high=4096);closed=bool(p.get('closed',False))
if b<=a:raise ValueError('Domain must increase')
def at(t):return [c.expression(x,t=t) for x in expressions]
points=[at(a+(b-a)*i/n) for i in range(n if closed else n+1)]
if closed and length(sub(points[0],at(b)))>1e-6:raise ValueError('Closed curve endpoints do not coincide within 1e-6 m')
if any(length(sub(x,y))<1e-9 for x,y in zip(points,points[1:])):raise ValueError('Curve has coincident adjacent samples')
error=max(length(sub(at(a+(b-a)*(i+.5)/n),mul(add(points[i],points[(i+1)%len(points)]),.5))) for i in range(n))
if 'max_chord_error' in p and error>c.number(p['max_chord_error']):raise ValueError(f'Estimated chord error {error:.6g} m exceeds max_chord_error; increase segments')
return {'kind':'curve','points':points,'closed':closed,'estimated_chord_error':error}
@register_operator('polyline',description='Define a path directly from mathematical points.',inputs=())
def polyline(c,p,inputs):
pts=[c.vec(v) for v in p['points']]
if not 2<=len(pts)<=4096:raise ValueError('Polyline needs 24096 points')
if p.get('closed') and len(pts)<3:raise ValueError('Closed polyline needs at least 3 points')
if any(length(sub(a,b))<1e-9 for a,b in zip(pts,pts[1:])):raise ValueError('Repeated adjacent point')
if p.get('closed') and length(sub(pts[0],pts[-1]))<1e-9:raise ValueError('Do not repeat the first point of a closed polyline')
return {'kind':'curve','points':pts,'closed':bool(p.get('closed',False))}
@register_operator('frames',description='Parallel-transport local frames along a curve, with optional distributed twist.',inputs=('path',))
def frame_op(c,p,inputs):
path=require(inputs['path'],'curve');closed=path['closed']
return {'kind':'frames','closed':closed,'frames':frames(path['points'],closed,c.vec(p.get('up',[0,0,1])),c.number(p.get('twist_degrees',0)))}
@register_operator('sweep',description='Sweep a closed 2D section in the local X/Z plane along local frames.',inputs=('frames',))
def sweep(c,p,inputs):
fr=require(inputs['frames'],'frames');profile=[c.vec(v,2) for v in p['profile']]
if len(profile)>128:raise ValueError('Profile is limited to 128 vertices')
cap,area=triangulate_polygon(profile)
if area<0:profile.reverse();cap,area=triangulate_polygon(profile)
fs=fr['frames'];n,m=len(fs),len(profile)
if n*m>200000:raise ValueError('Sweep exceeds 200,000 vertices; reduce samples or section resolution')
verts=[add(f['origin'],add(mul(f['x'],x),mul(f['z'],z))) for f in fs for x,z in profile]
faces=[]
for i in range(n if fr['closed'] else n-1):
k=(i+1)%n
for j in range(m):
q=(j+1)%m;a,b,d,e=i*m+j,k*m+j,k*m+q,i*m+q
faces.extend([[a,b,d],[a,d,e]])
capped=bool(p.get('cap',True))
if not fr['closed'] and capped:
faces.extend(cap);faces.extend([[v+(n-1)*m for v in reversed(t)] for t in cap])
return mesh(verts,faces,fr['closed'] or capped)
@register_operator('surface',description='Sample x(u,v), y(u,v), z(u,v); optionally thicken into a closed shell.',inputs=())
def surface(c,p,inputs):
xyz=p['xyz'];ua,ub=c.vec(p.get('u_domain',[0,1]),2);va,vb=c.vec(p.get('v_domain',[0,1]),2)
nu=c.count(p.get('u_segments',32),low=2,high=256);nv=c.count(p.get('v_segments',16),low=2,high=256)
wu,wv=bool(p.get('wrap_u',False)),bool(p.get('wrap_v',False));rows,cols=nu if wu else nu+1,nv if wv else nv+1
if ua>=ub or va>=vb or len(xyz)!=3:raise ValueError('Invalid surface domain or xyz')
def at(u,v):return [c.expression(x,u=u,v=v) for x in xyz]
if wu and any(length(sub(at(ua,va+(vb-va)*i/nv),at(ub,va+(vb-va)*i/nv)))>1e-6 for i in range(nv+1)):raise ValueError('u seam does not close')
if wv and any(length(sub(at(ua+(ub-ua)*i/nu,va),at(ua+(ub-ua)*i/nu,vb)))>1e-6 for i in range(nu+1)):raise ValueError('v seam does not close')
verts=[at(ua+(ub-ua)*i/nu,va+(vb-va)*j/nv) for i in range(rows) for j in range(cols)]
faces=[]
for i in range(nu):
for j in range(nv):
a=i*cols+j;b=((i+1)%rows)*cols+j;d=((i+1)%rows)*cols+(j+1)%cols;e=i*cols+(j+1)%cols
faces.extend([[a,b,d],[a,d,e]])
thick=c.number(p.get('thickness',0))
if thick<0:raise ValueError('Thickness cannot be negative')
if not thick:return mesh(verts,faces,wu and wv)
normals=[[0.,0.,0.] for _ in verts]
for a,b,d in faces:
normal=cross(sub(verts[b],verts[a]),sub(verts[d],verts[a]))
for i in [a,b,d]:normals[i]=add(normals[i],normal)
normals=[unit(n) for n in normals];n=len(verts)
thick_verts=[add(v,mul(norm,thick*.5)) for v,norm in zip(verts,normals)]+[sub(v,mul(norm,thick*.5)) for v,norm in zip(verts,normals)]
all_faces=faces+[[i+n for i in reversed(f)] for f in faces];edges={}
for f in faces:
for a,b in zip(f,f[1:]+f[:1]):
key=tuple(sorted((a,b)));edges.setdefault(key,[]).append((a,b))
for edge in edges.values():
if len(edge)==1:
a,b=edge[0];all_faces.extend([[b,a,a+n],[b,a+n,b+n]])
return mesh(thick_verts,all_faces,True)
@register_operator('loft',description='Connect sampled closed 3D contours with matching vertex counts. Ends are capped when planar.',inputs=('sections',))
def loft(c,p,inputs):
curves=[require(v,'curve') for v in inputs['sections']]
if len(curves)<2 or not all(v['closed'] for v in curves):raise ValueError('Loft needs at least two closed curves')
m=len(curves[0]['points'])
if any(len(v['points'])!=m for v in curves):raise ValueError('All sections must have matching point counts and correspondence')
verts=[v for section in curves for v in section['points']];faces=[]
for i in range(len(curves)-1):
for j in range(m):
q=(j+1)%m;a,b,d,e=i*m+j,(i+1)*m+j,(i+1)*m+q,i*m+q
faces.extend([[a,b,d],[a,d,e]])
cap=bool(p.get('cap',True))
if cap:
for idx,section in [(0,curves[0]),(len(curves)-1,curves[-1])]:
pts=section['points'];origin=pts[0];x=unit(sub(pts[1],origin));normal=None
for pt in pts[2:]:
candidate=cross(x,sub(pt,origin))
if length(candidate)>1e-8:normal=unit(candidate);break
if normal is None:raise ValueError('Degenerate loft end section')
if any(abs(dot(sub(pt,origin),normal))>1e-6 for pt in pts):raise ValueError('Capped loft ends must be planar')
y=cross(normal,x);flat=[[dot(sub(pt,origin),x),dot(sub(pt,origin),y)] for pt in pts]
triangles,_=triangulate_polygon(flat)
if idx:triangles=[list(reversed(f)) for f in triangles]
faces.extend([[j+idx*m for j in f] for f in triangles])
return mesh(verts,faces,cap)
@register_operator('transform',description='Apply an affine transform to mesh instances or curve points; rotations are XYZ Euler degrees.',inputs=('source',))
def transform_op(c,p,inputs):
source=inputs['source'];scale=p.get('scale',[1,1,1]);scale=c.vec(scale) if isinstance(scale,list) else c.number(scale)
matrix=transform(c.vec(p.get('translate',[0,0,0])),c.vec(p.get('rotate',[0,0,0])),scale)
if source['kind']=='curve':return {**source,'points':[point(matrix,v) for v in source['points']]}
require(source,'mesh')
return {'kind':'mesh','parts':[{**part,'matrix':matmul(matrix,part['matrix'])} for part in source['parts']]}
@register_operator('repeat',description='Repeat a mesh using a cumulative step transform; shared mesh data and stable instance IDs.',inputs=('source',))
def repeat(c,p,inputs):
source=require(inputs['source'],'mesh');count=c.count(p.get('count',2),high=256)
if len(source['parts'])*count>2048:raise ValueError('Repeat would exceed 2048 parts')
step=transform(c.vec(p.get('translate',[0,0,0])),c.vec(p.get('rotate',[0,0,0])),c.number(p.get('scale',1)))
current=identity();parts=[]
for i in range(count):
parts.extend({**part,'key':f'{i:04}/'+part['key'],'matrix':matmul(current,part['matrix'])} for part in source['parts'])
current=matmul(step,current)
return {'kind':'mesh','parts':parts}
@register_operator('merge',description='Group mesh parts without boolean operations; preserves individual identities.',inputs=('sources',))
def merge(c,p,inputs):
return {'kind':'mesh','parts':[{**part,'key':f'{i:04}/'+part['key']} for i,source in enumerate(inputs['sources']) for part in require(source,'mesh')['parts']]}
+101
View File
@@ -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
+61
View File
@@ -0,0 +1,61 @@
"""A local, read-only live viewer. Authoring remains in Python/CLI transactions."""
import hashlib
import json
import threading
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
from .kernel import build,operator_catalog
from .project import read_json
def make_server(project_path,port=8767):
path=Path(project_path).resolve();assets=Path(__file__).parent/'web'
state={'source_hash':None,'bundle':None,'error':None};lock=threading.Lock()
def current():
with lock:
try:
with path.open('rb') as f:raw=f.read(64*1024*1024+1)
if len(raw)>64*1024*1024:raise ValueError('JSON input exceeds 64 MiB')
key=hashlib.sha256(raw).hexdigest()
if key!=state['source_hash']:
state['source_hash']=key
try:state['bundle']=build(json.loads(raw));state['error']=None
except (ValueError,TypeError,KeyError) as e:state['error']=str(e)
except (OSError,ValueError) as e:state['error']=str(e)
return state.copy()
class Handler(BaseHTTPRequestHandler):
def log_message(self,fmt,*args):pass
def send(self,data,kind='application/json',status=200,etag=None,download=None):
body=data if isinstance(data,bytes) else json.dumps(data,allow_nan=False).encode()
self.send_response(status);self.send_header('Content-Type',kind);self.send_header('Content-Length',str(len(body)))
self.send_header('Cache-Control','no-cache');self.send_header('X-Content-Type-Options','nosniff')
self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:")
if etag:self.send_header('ETag',etag)
if download:self.send_header('Content-Disposition',f'attachment; filename="{download}"')
self.end_headers()
try:self.wfile.write(body)
except (BrokenPipeError,ConnectionResetError):pass
def do_GET(self):
route=urlsplit(self.path).path
if route in ['/','/app.js','/style.css']:
name='index.html' if route=='/' else route[1:];kind={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8'}[Path(name).suffix]
self.send((assets/name).read_bytes(),kind);return
if route=='/api/operators':self.send(operator_catalog());return
if route in ['/api/bundle','/export.bundle.json','/api/status']:
result=current()
if result['error']:self.send({'error':result['error'],'source':path.name,'stale':bool(result['bundle'])},status=422);return
b=result['bundle'];tag='"'+b['bundle_hash']+'"'
if route=='/api/status':self.send({'source':path.name,'project_hash':b['project_hash'],'bundle_hash':b['bundle_hash'],'stats':b['stats']});return
if self.headers.get('If-None-Match')==tag and route=='/api/bundle':self.send(b'',status=304,etag=tag);return
self.send(b,etag=tag,download=b['project_id']+'.bundle.json' if route=='/export.bundle.json' else None);return
self.send({'error':'Not found'},status=404)
server=ThreadingHTTPServer(('127.0.0.1',port),Handler);server.daemon_threads=True
return server
def serve(path,port):
build(read_json(path))
server=make_server(path,port)
print(f'Spatial Lab: http://127.0.0.1:{server.server_port}\nWatching: {Path(path).resolve()}',flush=True)
try:server.serve_forever()
except KeyboardInterrupt:pass
finally:server.server_close()
+116
View File
@@ -0,0 +1,116 @@
"use strict";
const $=s=>document.querySelector(s), canvas=$("#viewport"), gl=canvas.getContext("webgl2",{antialias:true,alpha:false});
let bundle=null,etag=null,selected=null,isolated=false,showSolid=true,showWire=false,showGuides=true,section=false,clipZ=1e9,resources=[],grid=null;
let yaw=-.9,pitch=.52,distance=62,target=[0,0,5],radius=25,viewMode="orbit",dirty=true;
const add=(a,b)=>a.map((x,i)=>x+b[i]),sub=(a,b)=>a.map((x,i)=>x-b[i]),mul=(a,s)=>a.map(x=>x*s),dot=(a,b)=>a.reduce((s,x,i)=>s+x*b[i],0),cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],unit=a=>mul(a,1/(Math.hypot(...a)||1));
const identity=()=>[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1];
function multiply(a,b){let o=new Array(16).fill(0);for(let c=0;c<4;c++)for(let r=0;r<4;r++)for(let k=0;k<4;k++)o[c*4+r]+=a[k*4+r]*b[c*4+k];return o;}
function lookAt(eye,center,up){let z=unit(sub(eye,center)),x=unit(cross(up,z)),y=cross(z,x);return[x[0],y[0],z[0],0,x[1],y[1],z[1],0,x[2],y[2],z[2],0,-dot(x,eye),-dot(y,eye),-dot(z,eye),1];}
function perspective(fov,aspect,near,far){let f=1/Math.tan(fov/2),nf=1/(near-far);return[f/aspect,0,0,0,0,f,0,0,0,0,(far+near)*nf,-1,0,0,2*far*near*nf,0];}
function ortho(w,h,near,far){return[2/w,0,0,0,0,2/h,0,0,0,0,-2/(far-near),0,0,0,-(far+near)/(far-near),1];}
function colMajor(rows){return rows[0].map((_,i)=>rows.map(r=>r[i])).flat();}
function normalMatrix(m){const a=[m[0],m[1],m[2]],b=[m[4],m[5],m[6]],c=[m[8],m[9],m[10]],d=dot(a,cross(b,c));return[...mul(cross(b,c),1/d),...mul(cross(c,a),1/d),...mul(cross(a,b),1/d)];}
let program,loc={};
function shader(type,src){let s=gl.createShader(type);gl.shaderSource(s,src);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(s));return s;}
function initializeGL(){
if(!gl)throw new Error("WebGL 2 is unavailable. JSON export and the Python kernel still work.");
program=gl.createProgram();gl.attachShader(program,shader(gl.VERTEX_SHADER,`#version 300 es
precision highp float;
layout(location=0) in vec3 position; layout(location=1) in vec3 normal;
uniform mat4 mvp; uniform mat4 model; uniform mat3 normals;
out vec3 world; out vec3 n;
void main(){world=(model*vec4(position,1.0)).xyz;n=normals*normal;gl_Position=mvp*vec4(position,1.0);}`));
gl.attachShader(program,shader(gl.FRAGMENT_SHADER,`#version 300 es
precision highp float;in vec3 world;in vec3 n;uniform vec3 color;uniform float unlit;uniform float clipHeight;out vec4 frag;
void main(){if(world.z>clipHeight)discard;vec3 nn=normalize(n+vec3(0.00001));float light=0.50+0.38*abs(dot(nn,normalize(vec3(-0.35,-0.55,0.78))))+0.12*max(nn.z,0.0);frag=vec4(color*mix(light,1.0,unlit),1.0);}`));
gl.linkProgram(program);if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(program));
for(const name of ["mvp","model","normals","color","unlit","clipHeight"])loc[name]=gl.getUniformLocation(program,name);
gl.enable(gl.DEPTH_TEST);gl.clearColor(.914,.933,.953,1);
}
function makeGeometry(g,lineOnly=false){
const points=g.vertices,normal=points.map(()=>[0,0,0]),faces=g.faces||[];
for(const [a,b,c] of faces){const n=cross(sub(points[b],points[a]),sub(points[c],points[a]));for(const i of [a,b,c])normal[i]=add(normal[i],n);}
const edgeSet=new Set(),edges=[];
if(g.edges){for(const e of g.edges)edges.push(...e);}else for(const f of faces)for(let j=0;j<3;j++){let a=f[j],b=f[(j+1)%3],key=Math.min(a,b)+":"+Math.max(a,b);if(!edgeSet.has(key)){edgeSet.add(key);edges.push(a,b);}}
const vao=gl.createVertexArray();gl.bindVertexArray(vao);const buffers=[];
function attribute(index,data){let b=gl.createBuffer();buffers.push(b);gl.bindBuffer(gl.ARRAY_BUFFER,b);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array(data),gl.STATIC_DRAW);gl.enableVertexAttribArray(index);gl.vertexAttribPointer(index,3,gl.FLOAT,false,0,0);}
attribute(0,points.flat());attribute(1,normal.map(unit).flat());
function indices(data){let b=gl.createBuffer();buffers.push(b);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,b);gl.bufferData(gl.ELEMENT_ARRAY_BUFFER,new Uint32Array(data),gl.STATIC_DRAW);return b;}
const triangles=indices(faces.flat()),lines=indices(edges);gl.bindVertexArray(null);
return{vao,buffers,triangles,lines,triangleCount:faces.length*3,lineCount:edges.length,lineOnly};
}
function dispose(){for(const r of resources){gl.deleteVertexArray(r.vao);for(const b of r.buffers)gl.deleteBuffer(b);}resources=[];}
let geometries=new Map(),drawables=[];
function install(data){
const previous=!!bundle;bundle=data;dispose();geometries=new Map();drawables=[];
for(const [id,g] of Object.entries(data.geometries)){let r=makeGeometry(g);geometries.set(id,r);resources.push(r);}
for(const o of data.objects){let resource=geometries.get(o.geometry);
if(o.kind==="curve"){let edges=o.points.slice(1).map((_,i)=>[i,i+1]);if(o.closed)edges.push([o.points.length-1,0]);resource=makeGeometry({vertices:o.points,edges},true);resources.push(resource);}
if(o.kind==="frame"){resource=makeGeometry({vertices:[[0,0,0],[.65,0,0],[0,.65,0],[0,0,.65]],edges:[[0,1],[0,2],[0,3]]},true);resources.push(resource);}
drawables.push({object:o,resource,model:colMajor(o.matrix)});
}
const min=data.bounds.min,max=data.bounds.max;radius=Math.max(1,Math.hypot(...sub(max,min))/2);
if(!previous){target=mul(add(min,max),.5);distance=radius*2.7;}
const step=10**Math.floor(Math.log10(radius/5)),extent=Math.ceil(radius*1.3/step)*step,points=[],edges=[];const base=Math.floor(min[2]/step)*step;
for(let x=-extent;x<=extent+.001;x+=step){let n=points.length;points.push([x,-extent,base],[x,extent,base],[-extent,x,base],[extent,x,base]);edges.push([n,n+1],[n+2,n+3]);}
grid=makeGeometry({vertices:points,edges},true);resources.push(grid);
$("#project-name").textContent=data.name;document.title=data.name+" — Spatial Lab";
$("#node-count").textContent=data.stats.nodes+" nodes";
$("#statistics").textContent=`${data.stats.objects} objects / ${data.stats.unique_meshes} meshes / ${data.stats.triangles.toLocaleString()} triangles`;
const warning=data.validation.warnings.length;$("#validation").textContent=warning?`${warning} topology notes`:"Mesh topology checked";$("#validation").classList.toggle("warn",warning>0);
$("#validation").title="Checks finite coordinates, triangle indices, edge incidence and solid orientation. Does not test self-intersections or walkability.";
const slider=$("#section-height");slider.min=min[2]-.1;slider.max=max[2]+.1;if(!section){slider.value=max[2]+.1;clipZ=1e9;}
if(selected&&!data.nodes.some(n=>n.id===selected))selected=null;
outline();inspector();dirty=true;
}
function outline(){
const list=$("#nodes");list.replaceChildren();
for(const n of bundle.nodes){let b=document.createElement("button");b.className="node"+(n.id===selected?" selected":"")+(!n.visible?" computational":"");b.dataset.node=n.id;b.title=n.id;
const symbol=document.createElement("span");symbol.className="symbol";symbol.textContent=n.kind==="curve"?"∿":n.kind==="frames"?"⊥":"▱";
const label=document.createElement("span");label.className="label";label.append(document.createTextNode(n.id));let small=document.createElement("small");small.textContent=n.op;label.append(small);
const vis=document.createElement("span");vis.className="visibility";vis.textContent=n.visible?"●":"";
b.append(symbol,label,vis);b.onclick=()=>{selected=selected===n.id?null:n.id;if(!selected)isolated=false;outline();inspector();dirty=true;};list.append(b);
}
}
function inspector(){
const root=$("#inspector");root.replaceChildren();$("#clear-selection").hidden=!selected;$("#isolate").setAttribute("aria-pressed",String(isolated));
if(!selected){$("#selection-name").textContent="Project parameters";$("#selection-status").textContent="All geometry";for(const [k,v] of Object.entries(bundle.recipe.parameters||{})){let row=document.createElement("div");row.className="param-row";let a=document.createElement("span"),b=document.createElement("span");a.textContent=k;b.textContent=typeof v==="string"?v:JSON.stringify(v);row.append(a,b);root.append(row);}return;}
const n=bundle.nodes.find(n=>n.id===selected);$("#selection-name").textContent=n.id;$("#selection-status").textContent=n.visible?(isolated?"Isolated: ":"Selected: ")+n.id:"Construction node: "+n.id;
let dl=document.createElement("dl");for(const [k,v] of Object.entries({operation:n.op,role:n.role,...n.inputs,...n.params})){let dt=document.createElement("dt"),dd=document.createElement("dd");dt.textContent=k;dd.textContent=typeof v==="string"?v:JSON.stringify(v);dl.append(dt,dd);}root.append(dl);
}
function draw(){
if(!gl||!bundle)return;const dpr=Math.min(devicePixelRatio,2),w=Math.round(canvas.clientWidth*dpr),h=Math.round(canvas.clientHeight*dpr);if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;dirty=true;}if(!dirty)return;dirty=false;
gl.viewport(0,0,w,h);gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);gl.useProgram(program);
let offset=[Math.cos(yaw)*Math.cos(pitch),Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch)],eye=add(target,mul(offset,distance)),v=lookAt(eye,target,[0,0,1]);
let p=viewMode==="orbit"?perspective(.70,w/h,.05,Math.max(1000,distance+radius*10)):ortho(distance*.7*w/h,distance*.7,.05,Math.max(1000,distance+radius*10));let pv=multiply(p,v);
function render(r,m,color,lines=false){gl.bindVertexArray(r.vao);gl.uniformMatrix4fv(loc.model,false,m);gl.uniformMatrix4fv(loc.mvp,false,multiply(pv,m));gl.uniformMatrix3fv(loc.normals,false,normalMatrix(m));gl.uniform3fv(loc.color,color);gl.uniform1f(loc.unlit,lines?1:0);gl.uniform1f(loc.clipHeight,section?clipZ:1e9);gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER,lines?r.lines:r.triangles);gl.drawElements(lines?gl.LINES:gl.TRIANGLES,lines?r.lineCount:r.triangleCount,gl.UNSIGNED_INT,0);}
if(showGuides)render(grid,identity(),[.77,.82,.86],true);
let visible=0;
for(const d of drawables){const o=d.object;if(isolated&&o.node_id!==selected)continue;if(d.resource.lineOnly&&!showGuides)continue;visible++;
const selectedObject=selected===o.node_id,color=selectedObject?[.82,.56,.28]:o.color;
if(showSolid&&!d.resource.lineOnly){gl.enable(gl.POLYGON_OFFSET_FILL);gl.polygonOffset(1,1);render(d.resource,d.model,color);gl.disable(gl.POLYGON_OFFSET_FILL);}
if(showWire||d.resource.lineOnly||selectedObject)render(d.resource,d.model,selectedObject?[.38,.24,.12]:showSolid?mul(color,.54):mul(color,.68),true);
}
$("#empty").hidden=visible>0;gl.bindVertexArray(null);
}
function animate(){draw();requestAnimationFrame(animate);}
function setView(name){viewMode=name;if(name==="top"){yaw=-Math.PI/2;pitch=Math.PI/2-.0001;}if(name==="front"){yaw=-Math.PI/2;pitch=0;}if(name==="side"){yaw=0;pitch=0;}if(name==="orbit"){yaw=-.9;pitch=.52;}document.querySelectorAll("[data-view]").forEach(b=>b.classList.toggle("active",b.dataset.view===name));$("#view-label").textContent=name==="orbit"?"Perspective":name[0].toUpperCase()+name.slice(1)+" / orthographic";dirty=true;}
for(const b of document.querySelectorAll("[data-view]"))b.onclick=()=>setView(b.dataset.view);
$("#fit").onclick=()=>{if(bundle){target=mul(add(bundle.bounds.min,bundle.bounds.max),.5);distance=radius*(viewMode==="orbit"?2.7:2.4);dirty=true;}};
function toggle(id,get,set){$(id).onclick=()=>{set(!get());$(id).setAttribute("aria-pressed",String(get()));dirty=true;};}
toggle("#solid",()=>showSolid,v=>{showSolid=v;if(!showSolid&&!showWire){showWire=true;$("#wire").setAttribute("aria-pressed","true");}});
toggle("#wire",()=>showWire,v=>{showWire=v;if(!showWire&&!showSolid){showSolid=true;$("#solid").setAttribute("aria-pressed","true");}});
toggle("#guides",()=>showGuides,v=>showGuides=v);toggle("#isolate",()=>isolated,v=>{isolated=!!selected&&v;inspector();});
$("#clear-selection").onclick=()=>{selected=null;isolated=false;outline();inspector();dirty=true;};
$("#section-enabled").onchange=e=>{section=e.target.checked;$("#section-height").disabled=!section;updateClip();};
function updateClip(){clipZ=Number($("#section-height").value);$("#section-value").textContent=section?clipZ.toFixed(2)+" m":"—";dirty=true;}$("#section-height").oninput=updateClip;
let drag=null;
canvas.addEventListener("pointerdown",e=>{canvas.setPointerCapture(e.pointerId);drag={x:e.clientX,y:e.clientY,pan:e.shiftKey||e.button===1};});
canvas.addEventListener("pointerup",()=>drag=null);canvas.addEventListener("pointercancel",()=>drag=null);
canvas.addEventListener("pointermove",e=>{if(!drag)return;const dx=e.clientX-drag.x,dy=e.clientY-drag.y;drag.x=e.clientX;drag.y=e.clientY;if(drag.pan){const right=[-Math.sin(yaw),Math.cos(yaw),0],up=cross(unit([Math.cos(yaw)*Math.cos(pitch),Math.sin(yaw)*Math.cos(pitch),Math.sin(pitch)]),right);target=add(target,add(mul(right,-dx*distance/canvas.clientHeight*.65),mul(up,dy*distance/canvas.clientHeight*.65)));}else{if(viewMode!=="orbit")setView("orbit");yaw-=dx*.006;pitch=Math.max(-1.5,Math.min(1.5,pitch+dy*.006));}dirty=true;});
canvas.addEventListener("wheel",e=>{e.preventDefault();distance=Math.max(.2,Math.min(radius*30,distance*Math.exp(e.deltaY*.001)));dirty=true;},{passive:false});
canvas.addEventListener("keydown",e=>{if(e.key==="Escape")$("#clear-selection").click();if(e.key.toLowerCase()==="f")$("#fit").click();});
new ResizeObserver(()=>dirty=true).observe(canvas);
function showError(message){$("#error").hidden=false;$("#error").textContent=(bundle?"Source cannot be built. Showing the last valid geometry.\n":"")+message;$("#live-state").textContent="Build error";$("#live-state").classList.add("error");}
async function refresh(){try{const r=await fetch("/api/bundle",{headers:etag?{"If-None-Match":etag}:{}});if(r.status!==304){const data=await r.json();if(!r.ok)throw new Error(data.error||r.statusText);etag=r.headers.get("ETag");install(data);}$("#error").hidden=true;$("#live-state").textContent="Live project";$("#live-state").classList.remove("error");}catch(e){showError(e.message);}finally{setTimeout(refresh,1800);}}
try{initializeGL();refresh();animate();}catch(e){showError(e.message);}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Spatial Lab</title><link rel="stylesheet" href="/style.css"></head>
<body>
<header><strong>Spatial Lab</strong><span class="divider"></span><span id="project-name">Opening project…</span><span id="live-state" role="status">Connecting</span><a href="/export.bundle.json" id="export">Export geometry</a></header>
<main>
<aside>
<section class="outline"><div class="section-title"><h1>Construction</h1><span id="node-count"></span></div><div id="nodes"></div></section>
<section class="inspector"><div class="section-title"><h2 id="selection-name">Project parameters</h2><button id="clear-selection" title="Clear selection" hidden>×</button></div><div id="inspector"></div></section>
<footer>Author with Python or CLI.<br>Source changes appear here automatically.</footer>
</aside>
<div class="workspace">
<nav aria-label="View controls"><div class="button-group"><button data-view="orbit" class="active">Orbit</button><button data-view="top">Top</button><button data-view="front">Front</button><button data-view="side">Side</button><button id="fit">Fit</button></div><div class="button-group"><button id="solid" class="active" aria-pressed="true">Surface</button><button id="wire" aria-pressed="false">Wire</button><button id="guides" class="active" aria-pressed="true">Guides</button><button id="isolate" aria-pressed="false">Isolate</button></div></nav>
<div class="viewport-wrap"><canvas id="viewport" tabindex="0" aria-label="3D mathematical geometry preview. Drag to orbit, shift-drag to pan, scroll to zoom."></canvas><div id="view-label">Perspective</div><div id="empty" hidden>No visible geometry</div><div id="error" role="alert" hidden></div><div class="axis-key"><span>X</span><span>Y</span><span>Z ↑</span></div><div class="help">Drag to orbit · Shift-drag to pan · Scroll to zoom</div></div>
<div class="section-bar"><label><input id="section-enabled" type="checkbox"> Section Z</label><input id="section-height" aria-label="Section height" type="range" min="-10" max="20" step="0.01" disabled><output id="section-value"></output><span id="selection-status">All geometry</span></div>
<div class="status-bar"><span id="statistics">Waiting for geometry</span><span id="validation"> </span><span>Metres / Z up</span></div>
</div>
</main><script src="/app.js"></script></body></html>
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
"""Integration test in real Blender, including geometry edits, pruning and undo.
blender -b --factory-startup --python tests/blender_roundtrip.py
Writes a verified demonstration and report to out/.
"""
import importlib.util
import sys
import tempfile
from pathlib import Path
import bpy
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root))
from spatial_lab.examples import demo_project
from spatial_lab.project import Project, write_json
spec = importlib.util.spec_from_file_location('bridge', root / 'blender/bridge.py')
bridge = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bridge)
scene = bridge.create_scene('Spatial Lab / Braided atrium')
bpy.context.window.scene = scene
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'study.json'
write_json(path, demo_project())
project = Project(path)
original = project.build()
bridge.import_bundle(original, scene)
original_check = bridge.verify_bundle(original, scene)
assert original_check['passed'], original_check
obj = next(o for o in scene.objects if o.get('spatial_lab_id') == 'braided-galleries/0000/main')
pointer = obj.as_pointer()
original_material = obj.material_slots[0].material
modifier = obj.modifiers.new('Artist bevel', 'BEVEL')
artist_material = bpy.data.materials.new('Artist material')
obj.material_slots[0].material = artist_material
marker = bpy.data.objects.new('Unrelated object', None)
scene.collection.objects.link(marker)
changed = project.apply([
{'action': 'set_parameter', 'name': 'width', 'value': 1.8},
{'action': 'set_parameter', 'name': 'levels', 'value': 2},
])
assert changed['geometries'] != original['geometries']
updated = bridge.import_bundle(changed, scene)
changed_check = bridge.verify_bundle(changed, scene)
assert changed_check['passed'], changed_check
assert updated['created'] == 0 and updated['updated'] == 16 and len(updated['removed']) == 1
assert obj.as_pointer() == pointer and obj.modifiers.get(modifier.name).as_pointer() == modifier.as_pointer()
assert obj.material_slots[0].material == artist_material and marker.name in scene.objects
restored = project.travel('undo')
assert restored == original
bridge.import_bundle(restored, scene)
undo_check = bridge.verify_bundle(restored, scene)
assert undo_check['passed'], undo_check
assert obj.as_pointer() == pointer and obj.material_slots[0].material == artist_material
obj.modifiers.remove(modifier)
obj.material_slots[0].material = original_material
bpy.data.objects.remove(marker, do_unlink=True)
bpy.data.materials.remove(artist_material)
out = root / 'out'
write_json(out / 'braided_atrium.bundle.json', restored)
write_json(out / 'blender_verification.json', {
'blender_version': bpy.app.version_string,
'initial': original_check, 'edited': changed_check, 'undo': undo_check,
'changed_import': updated,
'preserved_object_identity_modifiers_materials_and_unrelated_objects': True,
'undo_restored_identical_bundle': True,
})
from mathutils import Vector
target = Vector((0, 0, 4.5))
rotation = (target - Vector((45, -55, 35))).to_track_quat('-Z', 'Y')
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == 'VIEW_3D':
view = area.spaces.active
bridge.enum_set(view.shading, 'type', 'SOLID')
bridge.enum_set(view.shading, 'color_type', 'MATERIAL')
view.region_3d.view_location = target
view.region_3d.view_distance = 62
view.region_3d.view_rotation = rotation
bpy.ops.wm.save_as_mainfile(filepath=str(out / 'Braided_Atrium.blend'))
print('PASS: changed geometry, pruned instance, preserved artist work, exact undo, Blender parity.')
+103
View File
@@ -0,0 +1,103 @@
import copy
import math
import unittest
from spatial_lab.kernel import build,evaluate,validate_bundle,PROJECT_SCHEMA,digest,register_operator
from spatial_lab.expressions import scalar
from spatial_lab.math3d import frames,dot,cross,length,sub,mesh_report
from spatial_lab.examples import demo_project
def project(nodes,parameters=None):return {'schema':PROJECT_SCHEMA,'id':'test','parameters':parameters or {},'nodes':nodes}
def straight():return project([
{'id':'path','op':'polyline','params':{'points':[[0,0,0],[0,3,0]]},'visible':False},
{'id':'frame','op':'frames','inputs':{'path':'path'},'visible':False},
{'id':'solid','op':'sweep','inputs':{'frames':'frame'},'params':{'profile':[[-1,-.5],[1,-.5],[1,.5],[-1,.5]]}}])
class Expressions(unittest.TestCase):
def test_math(self):self.assertAlmostEqual(scalar('sin(pi/2)*radius + sqrt(9)',{'radius':4}),7)
def test_no_python_execution(self):
for text in ["__import__('os')","x.__class__","[1,2][0]","(lambda: 1)()","sum(x for x in [1])"]:
with self.subTest(text=text),self.assertRaises(ValueError):scalar(text)
def test_domain_and_resource_guards(self):
for text in ['1/0','sqrt(-1)','1e309','2**1000000','exp(10000)','unknown+1']:
with self.subTest(text=text),self.assertRaises(ValueError):scalar(text)
class Geometry(unittest.TestCase):
def test_straight_sweep_analytical_volume(self):
b=build(straight());r=next(iter(b['validation']['geometry_reports'].values()))
self.assertAlmostEqual(r['signed_volume'],6);self.assertEqual(r['boundary_edges'],0)
self.assertEqual(b['bounds'],{'min':[-1.,0.,-.5],'max':[1.,3.,.5]})
def test_frames_are_right_handed_orthonormal(self):
pts=[[math.cos(i*.05)*5,math.sin(i*.05)*5,i*.02] for i in range(100)]
for f in frames(pts,twist=720):
self.assertAlmostEqual(dot(f['x'],f['y']),0,places=10)
self.assertAlmostEqual(dot(cross(f['x'],f['y']),f['z']),1,places=10)
def test_closed_frame_seam_and_mesh(self):
p=straight();p['nodes'][0]={'id':'path','op':'curve','params':{'xyz':['5*cos(t)','5*sin(t)',0],'closed':True,'segments':64},'visible':False}
b=build(p);r=next(iter(b['validation']['geometry_reports'].values()))
self.assertEqual(r['boundary_edges'],0);self.assertEqual(r['inconsistent_edges'],0)
result,_=evaluate(p);fs=result['frame']['frames']
self.assertGreater(dot(fs[0]['x'],fs[-1]['x']),.99)
def test_closed_fractional_twist_rejected(self):
p=straight();p['nodes'][0]={'id':'path','op':'curve','params':{'xyz':['5*cos(t)','5*sin(t)',0],'closed':True},'visible':False};p['nodes'][1]['params']={'twist_degrees':180}
with self.assertRaisesRegex(ValueError,'multiple of 360'):build(p)
def test_closed_endpoint_mismatch_rejected(self):
with self.assertRaisesRegex(ValueError,'endpoints'):build(project([{'id':'curve','op':'curve','params':{'xyz':['t',0,0],'closed':True}}]))
def test_chord_quality_requirement_is_enforced(self):
with self.assertRaisesRegex(ValueError,'chord error'):build(project([{'id':'curve','op':'curve','params':{'xyz':['5*cos(t)','5*sin(t)',0],'closed':True,'segments':8,'max_chord_error':.001}}]))
def test_closed_polyline_needs_three_points(self):
with self.assertRaisesRegex(ValueError,'at least 3'):build(project([{'id':'line','op':'polyline','params':{'points':[[0,0,0],[1,0,0]],'closed':True}}]))
def test_concave_section_is_capped(self):
p=straight();p['nodes'][2]['params']['profile']=[[0,0],[2,0],[2,1],[1,1],[1,2],[0,2]]
r=next(iter(build(p)['validation']['geometry_reports'].values()));self.assertAlmostEqual(r['signed_volume'],9);self.assertEqual(r['boundary_edges'],0)
def test_self_crossing_section_rejected(self):
p=straight();p['nodes'][2]['params']['profile']=[[0,0],[2,2],[2,0],[0,2]]
with self.assertRaises(ValueError):build(p)
def test_thick_surface_volume(self):
b=build(project([{'id':'slab','op':'surface','params':{'xyz':['u','v',0],'u_domain':[0,2],'v_domain':[0,3],'u_segments':4,'v_segments':4,'thickness':.4}}]))
r=next(iter(b['validation']['geometry_reports'].values()));self.assertAlmostEqual(r['signed_volume'],2.4);self.assertEqual(r['boundary_edges'],0)
def test_open_surface_is_explicit(self):
b=build(project([{'id':'sheet','op':'surface','params':{'xyz':['u','v','u*v']}}]));self.assertTrue(b['validation']['warnings'])
def test_periodic_surface_torus(self):
p=project([{'id':'torus','op':'surface','params':{'xyz':['(5+cos(v))*cos(u)','(5+cos(v))*sin(u)','sin(v)'],'u_domain':[0,'tau'],'v_domain':[0,'tau'],'wrap_u':True,'wrap_v':True,'u_segments':32,'v_segments':16}}])
r=next(iter(build(p)['validation']['geometry_reports'].values()));self.assertEqual(r['boundary_edges'],0);self.assertGreater(r['signed_volume'],90)
def test_loft(self):
p=project([{'id':'a','op':'polyline','params':{'points':[[0,0,0],[2,0,0],[2,2,0],[0,2,0]],'closed':True},'visible':False},
{'id':'b','op':'polyline','params':{'points':[[0,0,3],[2,0,3],[2,2,3],[0,2,3]],'closed':True},'visible':False},
{'id':'loft','op':'loft','inputs':{'sections':['a','b']}}])
r=next(iter(build(p)['validation']['geometry_reports'].values()));self.assertAlmostEqual(r['signed_volume'],12)
def test_instances_share_geometry_and_have_stable_ids(self):
p=straight();p['nodes'][2]['visible']=False;p['nodes'].append({'id':'array','op':'repeat','inputs':{'source':'solid'},'params':{'count':4,'translate':[5,0,0]}})
b=build(p);self.assertEqual(len(b['geometries']),1);self.assertEqual([o['id'] for o in b['objects']],['array/0000/main','array/0001/main','array/0002/main','array/0003/main']);self.assertEqual(b['bounds']['max'][0],16)
def test_transform_bounds(self):
p=straight();p['nodes'][2]['visible']=False;p['nodes'].append({'id':'moved','op':'transform','inputs':{'source':'solid'},'params':{'translate':[10,0,0],'rotate':[0,0,90],'scale':2}})
b=build(p);self.assertAlmostEqual(b['bounds']['min'][0],4);self.assertAlmostEqual(b['bounds']['max'][1],2)
def test_invalid_reflection(self):
p=straight();p['nodes'].append({'id':'bad','op':'transform','inputs':{'source':'solid'},'params':{'scale':-1}})
with self.assertRaisesRegex(ValueError,'positive'):build(p)
class Graph(unittest.TestCase):
def test_order_independent_dependencies(self):
p=straight();b=build(p);p['nodes'].reverse();other=build(p)
self.assertEqual(b['geometries'],other['geometries']);self.assertEqual(b['objects'],other['objects'])
def test_cycles_and_missing_references(self):
p=straight();p['nodes'][0]={'id':'path','op':'transform','inputs':{'source':'solid'}}
with self.assertRaisesRegex(ValueError,'cycle'):build(p)
p=straight();p['nodes'][1]['inputs']['path']='absent'
with self.assertRaisesRegex(ValueError,'Missing node'):build(p)
def test_duplicate_id_and_version(self):
p=straight();p['nodes'].append(copy.deepcopy(p['nodes'][0]))
with self.assertRaisesRegex(ValueError,'Duplicate'):build(p)
p=straight();p['nodes'][0]['version']=500
with self.assertRaisesRegex(ValueError,'version'):build(p)
def test_bundle_is_deterministic_and_tamper_evident(self):
b=build(straight());self.assertEqual(b,build(straight()));self.assertTrue(validate_bundle(b));b['objects'][0]['matrix'][0][3]=100
with self.assertRaisesRegex(ValueError,'hash'):validate_bundle(b)
def test_demo_is_closed_and_valid(self):
b=build(demo_project());self.assertEqual(b['stats']['objects'],17);self.assertEqual(b['validation']['warnings'],[])
for r in b['validation']['geometry_reports'].values():self.assertEqual(r['boundary_edges'],0)
def test_extension_registry(self):
@register_operator('test-guide',description='Test extension')
def guide(c,p,inputs):return {'kind':'curve','points':[[0,0,0],[c.number(p['length']),0,0]],'closed':False}
b=build(project([{'id':'custom','op':'test-guide','params':{'length':'L'}}],{'L':7}));self.assertEqual(b['bounds']['max'][0],7)
if __name__=='__main__':unittest.main()
+34
View File
@@ -0,0 +1,34 @@
import json
import tempfile
import unittest
from pathlib import Path
from spatial_lab.project import Project,write_json
from spatial_lab.examples import small_project
class Editing(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.path=Path(self.tmp.name)/'study.json';write_json(self.path,small_project());self.project=Project(self.path)
def tearDown(self):self.tmp.cleanup()
def test_edit_undo_redo_exactly(self):
original=self.project.build();modified=self.project.apply([{'action':'set_parameter','name':'height','value':8}])
self.assertNotEqual(original['bundle_hash'],modified['bundle_hash']);self.assertEqual([o['id'] for o in original['objects']],[o['id'] for o in modified['objects']])
self.assertEqual(Project(self.path).travel('undo'),original);self.assertEqual(Project(self.path).travel('redo'),modified)
def test_failed_transaction_changes_nothing(self):
before=self.path.read_bytes()
with self.assertRaises(ValueError):self.project.apply([{'action':'set_parameter','name':'height','value':9},{'action':'patch_node','id':'path','patch':{'params':{'xyz':['1/0',0,0]}}}])
self.assertEqual(self.path.read_bytes(),before);self.assertFalse(self.project.history_path.exists())
def test_dependency_aware_removal(self):
with self.assertRaisesRegex(ValueError,'used by'):self.project.apply([{'action':'remove_node','id':'path'}])
b=self.project.apply([{'action':'remove_node','id':'path','cascade':True}]);self.assertEqual(b['stats']['nodes'],0)
self.assertEqual(self.project.travel('undo')['stats']['nodes'],3)
def test_add_and_patch_nodes(self):
b=self.project.apply([{'action':'upsert_node','node':{'id':'echo','op':'transform','inputs':{'source':'gallery'},'params':{'translate':[0,0,10]}}},{'action':'patch_node','id':'gallery','patch':{'visible':False}}])
self.assertEqual(b['objects'][0]['id'],'echo/main');self.assertGreater(b['bounds']['min'][2],9)
def test_new_edit_clears_redo(self):
self.project.apply([{'action':'set_parameter','name':'height','value':8}]);self.project.travel('undo');self.project.apply([{'action':'set_parameter','name':'height','value':6}])
with self.assertRaisesRegex(ValueError,'Nothing to redo'):self.project.travel('redo')
def test_external_edit_does_not_restore_stale_history(self):
self.project.apply([{'action':'set_parameter','name':'height','value':8}]);p=self.project.read();p['parameters']['height']=10;write_json(self.path,p)
with self.assertRaisesRegex(ValueError,'Nothing to undo'):self.project.travel('undo')
if __name__=='__main__':unittest.main()
+36
View File
@@ -0,0 +1,36 @@
import json
import tempfile
import threading
import unittest
import urllib.request
import urllib.error
from pathlib import Path
from spatial_lab.server import make_server
from spatial_lab.project import Project,write_json
from spatial_lab.examples import small_project
class ViewerServer(unittest.TestCase):
def setUp(self):
self.tmp=tempfile.TemporaryDirectory();self.path=Path(self.tmp.name)/'study.json';write_json(self.path,small_project());self.server=make_server(self.path,0);self.thread=threading.Thread(target=self.server.serve_forever,daemon=True);self.thread.start();self.url=f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self):self.server.shutdown();self.server.server_close();self.thread.join();self.tmp.cleanup()
def get(self,path):return urllib.request.urlopen(self.url+path)
def test_bundle_live_changes_and_etag(self):
with self.get('/api/bundle') as r:a=json.load(r);tag=r.headers['ETag']
with self.assertRaises(urllib.error.HTTPError) as caught:urllib.request.urlopen(urllib.request.Request(self.url+'/api/bundle',headers={'If-None-Match':tag}))
self.assertEqual(caught.exception.code,304);caught.exception.close()
Project(self.path).apply([{'action':'set_parameter','name':'height','value':12}])
with self.get('/api/bundle') as r:b=json.load(r)
self.assertNotEqual(a['project_hash'],b['project_hash'])
def test_error_does_not_silently_serve_stale_geometry(self):
with self.get('/api/bundle') as r:json.load(r)
self.path.write_text('{bad json')
with self.assertRaises(urllib.error.HTTPError) as caught:self.get('/api/bundle')
self.assertEqual(caught.exception.code,422);error=json.load(caught.exception);caught.exception.close();self.assertTrue(error['stale']);self.assertIn('error',error)
def test_no_arbitrary_file_access(self):
with self.assertRaises(urllib.error.HTTPError) as caught:self.get('/../../etc/passwd')
self.assertEqual(caught.exception.code,404);caught.exception.close()
def test_viewer_and_download(self):
with self.get('/') as r:self.assertIn(b'viewport',r.read())
with self.get('/export.bundle.json') as r:self.assertIn('attachment',r.headers['Content-Disposition']);self.assertEqual(json.load(r)['schema'],'spatial-lab.bundle/1')
if __name__=='__main__':unittest.main()