Initial Spatial Lab release with verified Blender bridge
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
* text=auto
|
||||
*.blend filter=lfs diff=lfs merge=lfs -text
|
||||
+18
@@ -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
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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']}
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run from any working directory without installation."""
|
||||
from spatial_lab.cli import main
|
||||
raise SystemExit(main())
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .cli import main
|
||||
raise SystemExit(main())
|
||||
@@ -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())
|
||||
@@ -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'}]}
|
||||
@@ -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)
|
||||
@@ -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 1–80 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
|
||||
@@ -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}
|
||||
@@ -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 2–4096 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']]}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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);}
|
||||
@@ -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
@@ -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.')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user