clayspace 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
clayspace/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .cell import inspect_cell, load_cell, new_box_cell, save_cell, validate_cell
2
+ from .model import add_box, add_cylinder, add_plane, add_prism, add_sphere, move_geometry, recompute_bounds, remove_geometry
3
+ from .placement import make_placement, validate_placement
4
+ from .preview import preview_cell, preview_world
5
+ from .resources import manifest, schema
6
+ from .world import inspect_world_snapshot, load_world_snapshot, new_world, place_cell, save_world_snapshot, validate_world_snapshot
7
+
8
+ __version__ = "0.1.1"
9
+ __all__ = [
10
+ "inspect_cell","load_cell","new_box_cell","save_cell","validate_cell",
11
+ "add_box","add_cylinder","add_plane","add_prism","add_sphere","move_geometry","recompute_bounds","remove_geometry",
12
+ "make_placement","validate_placement","preview_cell","preview_world","manifest","schema",
13
+ "inspect_world_snapshot","load_world_snapshot","new_world","place_cell","save_world_snapshot","validate_world_snapshot",
14
+ ]
clayspace/cell.py ADDED
@@ -0,0 +1,232 @@
1
+ """Standalone local Cell ownership: creation, validation, inspection and safe I/O.
2
+
3
+ ClaySpace Cells are host-agnostic local geometry. Host projects may attach their
4
+ own workflow metadata, but ClaySpace itself does not define canon, acceptance,
5
+ publication, identity or ledger policy.
6
+ """
7
+ from __future__ import annotations
8
+ import json
9
+ from copy import deepcopy
10
+ from math import isfinite
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import Any
13
+ from zipfile import ZipFile, is_zipfile
14
+
15
+ MAX_CELL_BYTES = 16 * 1024 * 1024
16
+
17
+
18
+ def _finite(v: Any) -> bool:
19
+ return isinstance(v, (int, float)) and not isinstance(v, bool) and isfinite(v)
20
+
21
+
22
+ def _vector(v: Any, n: int = 3) -> bool:
23
+ return isinstance(v, (list, tuple)) and len(v) == n and all(_finite(x) for x in v)
24
+
25
+
26
+ def _text(v: Any) -> bool:
27
+ return isinstance(v, str) and bool(v.strip())
28
+
29
+
30
+ def _object(v: Any) -> dict:
31
+ return v if isinstance(v, dict) else {}
32
+
33
+
34
+ def _absent(v: Any) -> bool:
35
+ return v is None or v == 'none'
36
+
37
+
38
+ def load_cell(path: str | Path) -> dict:
39
+ """Load JSON or the sole cell.json in a ZIP, without extracting/running files."""
40
+ path = Path(path)
41
+ if is_zipfile(path):
42
+ with ZipFile(path) as archive:
43
+ candidates = []
44
+ for item in archive.infolist():
45
+ parts = PurePosixPath(item.filename)
46
+ if parts.name != 'cell.json' or item.is_dir():
47
+ continue
48
+ if parts.is_absolute() or '..' in parts.parts or '\\' in item.filename:
49
+ raise ValueError('unsafe cell.json archive path')
50
+ if item.file_size > MAX_CELL_BYTES:
51
+ raise ValueError('cell.json exceeds size limit')
52
+ candidates.append(item)
53
+ if len(candidates) != 1:
54
+ raise ValueError('archive must contain exactly one cell.json')
55
+ raw = archive.read(candidates[0])
56
+ else:
57
+ if path.stat().st_size > MAX_CELL_BYTES:
58
+ raise ValueError('cell.json exceeds size limit')
59
+ raw = path.read_bytes()
60
+ value = json.loads(raw.decode('utf-8'))
61
+ if not isinstance(value, dict):
62
+ raise ValueError('cell.json must contain an object')
63
+ return value
64
+
65
+
66
+ def save_cell(cell: dict, path: str | Path, *, overwrite: bool = False) -> Path:
67
+ check = validate_cell(cell)
68
+ if not check['valid']:
69
+ raise ValueError('invalid Cell: ' + '; '.join(check['errors']))
70
+ path = Path(path)
71
+ mode = 'w' if overwrite else 'x'
72
+ with path.open(mode, encoding='utf-8') as stream:
73
+ json.dump(cell, stream, indent=2, ensure_ascii=False, allow_nan=False)
74
+ stream.write('\n')
75
+ return path
76
+
77
+
78
+ def validate_cell(cell: Any) -> dict:
79
+ """Validate either supported local Cell family; malformed data fails closed."""
80
+ base = dict(valid=False, errors=[], schemaFamily=None, schemaVersion=None,
81
+ cellId=None, objectCount=0, bounds=None, stage=None, notCanon=None)
82
+ if not isinstance(cell, dict):
83
+ base['errors'] = ['Cell must be an object']
84
+ return base
85
+ errors: list[str] = []
86
+ v1 = cell.get('schema') == 'clayspace.cell.local-fragment' and cell.get('schemaVersion') == '1.0.0'
87
+ legacy = cell.get('schemaVersion') == '0.1'
88
+ if not (v1 or legacy):
89
+ base.update(schemaFamily=cell.get('schema'), schemaVersion=cell.get('schemaVersion'),
90
+ errors=['unsupported Cell format'])
91
+ return base
92
+ identity = _object(cell.get('identity'))
93
+ cell_id = identity.get('cellId') if v1 else cell.get('id')
94
+ if not _text(cell_id):
95
+ errors.append('Cell ID must be a non-empty string')
96
+
97
+ # A Cell is local. Workflow metadata may exist for a host project, but a
98
+ # baked master-world transform is never part of the portable source Cell.
99
+ if not _absent(cell.get('placement')):
100
+ errors.append('source Cell must remain locally unplaced')
101
+ for field in ('worldTransform', 'masterWorldCoordinates'):
102
+ if cell.get(field) is not None:
103
+ errors.append(f'source Cell must not contain {field}')
104
+ for container in ('portability', 'handoff'):
105
+ value = cell.get(container)
106
+ if value is not None and not isinstance(value, dict):
107
+ errors.append(f'{container} must be an object')
108
+ flags = _object(value)
109
+ if not _absent(flags.get('placement')):
110
+ errors.append(f'{container}.placement must remain none')
111
+ if flags.get('containsWorldTransform') is True:
112
+ errors.append(f'{container} declares a world transform')
113
+ if flags.get('masterWorldCoordinates') not in (None, False):
114
+ errors.append(f'{container} declares master-world coordinates')
115
+
116
+ if v1:
117
+ cs = _object(cell.get('coordinateSystem'))
118
+ axes = cs.get('axes')
119
+ if not isinstance(axes, str) or not all(t in axes.lower() for t in ('x-right', 'y-up', 'z-forward')):
120
+ errors.append('axes must describe x-right / y-up / z-forward')
121
+ origin, units = cs.get('localOrigin'), cs.get('units')
122
+ else:
123
+ cs = cell.get('coordinateSystem')
124
+ if cs != 'x-right-y-up-z-forward-meters':
125
+ errors.append('unsupported coordinateSystem')
126
+ origin, units = cell.get('origin'), cell.get('units')
127
+ if not _vector(origin) or any(x != 0 for x in origin):
128
+ errors.append('local origin must be [0,0,0]')
129
+ if units != 'meters':
130
+ errors.append('units must be meters')
131
+
132
+ geometry = cell.get('geometry' if v1 else 'objects')
133
+ if not isinstance(geometry, list) or not geometry:
134
+ errors.append('Cell geometry must be a non-empty array')
135
+ geometry = []
136
+ ids = set()
137
+ for index, item in enumerate(geometry):
138
+ label = f'geometry[{index}]'
139
+ if not isinstance(item, dict):
140
+ errors.append(f'{label} must be an object')
141
+ continue
142
+ item_id = item.get('id')
143
+ if not _text(item_id):
144
+ errors.append(f'{label} requires a string ID')
145
+ elif item_id in ids:
146
+ errors.append(f'duplicate geometry ID {item_id}')
147
+ else:
148
+ ids.add(item_id)
149
+ if v1:
150
+ primitive = item.get('primitive')
151
+ if primitive not in ('box', 'prism', 'cylinder', 'sphere', 'plane'):
152
+ errors.append(f'{label} unsupported primitive')
153
+ if not _vector(item.get('position')):
154
+ errors.append(f'{label} invalid position')
155
+ if not _vector(item.get('rotationDeg', [0, 0, 0])):
156
+ errors.append(f'{label} invalid rotationDeg')
157
+ scale = item.get('scale', [1, 1, 1])
158
+ if not _vector(scale) or any(v <= 0 for v in scale):
159
+ errors.append(f'{label} invalid scale')
160
+ if primitive == 'box':
161
+ size = item.get('size')
162
+ if not _vector(size) or any(v <= 0 for v in size):
163
+ errors.append(f'{label} invalid size')
164
+ elif primitive == 'prism':
165
+ footprint = item.get('footprintXZ')
166
+ if not isinstance(footprint, list) or len(footprint) < 3 or not all(_vector(p, 2) for p in footprint):
167
+ errors.append(f'{label} invalid footprintXZ')
168
+ y0, y1 = item.get('y0'), item.get('y1')
169
+ if not _finite(y0) or not _finite(y1) or y1 <= y0:
170
+ errors.append(f'{label} invalid prism height')
171
+ elif primitive == 'cylinder':
172
+ if not _finite(item.get('radius')) or item.get('radius', 0) <= 0:
173
+ errors.append(f'{label} invalid radius')
174
+ if not _finite(item.get('height')) or item.get('height', 0) <= 0:
175
+ errors.append(f'{label} invalid height')
176
+ if 'segments' in item and (not isinstance(item['segments'], int) or item['segments'] < 3 or item['segments'] > 256):
177
+ errors.append(f'{label} segments must be an integer from 3 to 256')
178
+ elif primitive == 'sphere':
179
+ if not _finite(item.get('radius')) or item.get('radius', 0) <= 0:
180
+ errors.append(f'{label} invalid radius')
181
+ elif primitive == 'plane':
182
+ size = item.get('size')
183
+ if not _vector(size, 2) or any(v <= 0 for v in size):
184
+ errors.append(f'{label} invalid plane size')
185
+ else:
186
+ if item.get('kind') not in ('box', 'card', 'road-strip'):
187
+ errors.append(f'{label} unsupported kind')
188
+ g = _object(item.get('geometry'))
189
+ if not all(_finite(g.get(k)) for k in ('x','y','z')):
190
+ errors.append(f'{label} invalid xyz')
191
+ if not all(_finite(g.get(k)) and g[k] > 0 for k in ('w','h','d')):
192
+ errors.append(f'{label} invalid dimensions')
193
+ for field in ('yaw','pitch','roll'):
194
+ if field in g and not _finite(g[field]):
195
+ errors.append(f'{label} invalid {field}')
196
+
197
+ if v1 and 'bounds' in cell and cell.get('bounds') is not None and not isinstance(cell.get('bounds'), dict):
198
+ errors.append('bounds must be an object')
199
+ aabb = _object(cell.get('bounds')).get('aabb') if v1 else None
200
+ if v1 and aabb is not None:
201
+ b = _object(aabb)
202
+ if not _vector(b.get('min')) or not _vector(b.get('max')):
203
+ errors.append('bounds.aabb requires finite min/max vectors')
204
+ elif any(hi <= lo for lo, hi in zip(b['min'], b['max'])):
205
+ errors.append('bounds.aabb must have positive dimensions')
206
+
207
+ base.update(valid=not errors, errors=errors,
208
+ schemaFamily='clayspace.cell.local-fragment' if v1 else 'clayspace-cell',
209
+ schemaVersion=cell.get('schemaVersion'), cellId=cell_id,
210
+ objectCount=len(geometry), bounds=deepcopy(aabb),
211
+ stage=cell.get('stage'), notCanon=cell.get('notCanon'))
212
+ return base
213
+
214
+
215
+ def inspect_cell(cell: dict) -> dict:
216
+ result = validate_cell(cell)
217
+ return {**result, 'placementStatus': 'local' if result['valid'] else None}
218
+
219
+
220
+ def new_box_cell(*, cell_id: str, name: str, size=(2.0, 2.0, 2.0)) -> dict:
221
+ """Create a generic ground-centred editable local Cell scaffold."""
222
+ if not _text(cell_id) or not _text(name) or not _vector(size) or any(v <= 0 for v in size):
223
+ raise ValueError('Cell needs a non-empty ID/name and three positive dimensions')
224
+ w, h, d = size
225
+ return {
226
+ 'schema': 'clayspace.cell.local-fragment', 'schemaVersion': '1.0.0',
227
+ 'identity': {'cellId': cell_id, 'title': name},
228
+ 'coordinateSystem': {'axes': 'x-right / y-up / z-forward', 'units': 'meters', 'localOrigin': [0,0,0]},
229
+ 'bounds': {'aabb': {'min': [-w/2,0,-d/2], 'max': [w/2,h,d/2]}},
230
+ 'geometry': [{'id': 'MASS-001', 'primitive': 'box', 'position': [0,h/2,0], 'size': [w,h,d], 'rotationDeg': [0,0,0], 'scale': [1,1,1]}],
231
+ 'portability': {'containsWorldTransform': False, 'placement': 'none'}
232
+ }
clayspace/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ import argparse, json
3
+ from pathlib import Path
4
+ from .cell import inspect_cell, load_cell, new_box_cell, save_cell, validate_cell
5
+ from .model import add_box, add_cylinder, add_sphere, move_geometry, remove_geometry
6
+ from .placement import make_placement
7
+ from .preview import preview_cell, preview_world
8
+ from .resources import manifest, schema
9
+ from .world import inspect_world_snapshot, load_world_snapshot, new_world, place_cell, save_world_snapshot, validate_world_snapshot
10
+
11
+
12
+ def _print(obj): print(json.dumps(obj,indent=2,ensure_ascii=False))
13
+ def _vec(text,n=3):
14
+ parts=[p.strip() for p in text.split(',')]
15
+ if len(parts)!=n: raise argparse.ArgumentTypeError(f'expected {n} comma-separated numbers')
16
+ try:return [float(p) for p in parts]
17
+ except ValueError as exc: raise argparse.ArgumentTypeError('values must be numbers') from exc
18
+
19
+ def _vec3(text): return _vec(text,3)
20
+ def _write_cell(cell,path,force): save_cell(cell,path,overwrite=force)
21
+ def _write_world(world,path,force): save_world_snapshot(world,path,overwrite=force)
22
+
23
+
24
+ def main(argv=None):
25
+ parser=argparse.ArgumentParser(prog='clayspace',description='Standalone local spatial modeling toolkit')
26
+ sub=parser.add_subparsers(dest='cmd',required=True)
27
+ sub.add_parser('about')
28
+ ps=sub.add_parser('schema');ps.add_argument('name',choices=['cell','placement','world'])
29
+
30
+ pc=sub.add_parser('cell');cs=pc.add_subparsers(dest='cell_cmd',required=True)
31
+ p=cs.add_parser('init');p.add_argument('--id',required=True);p.add_argument('--name',required=True);p.add_argument('--size',type=_vec3,default=[2,2,2]);p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
32
+ for name in ('validate','inspect'):
33
+ p=cs.add_parser(name);p.add_argument('path')
34
+ p=cs.add_parser('add-box');p.add_argument('path');p.add_argument('--id',required=True);p.add_argument('--position',type=_vec3,default=[0,0,0]);p.add_argument('--size',type=_vec3,required=True);p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
35
+ p=cs.add_parser('add-cylinder');p.add_argument('path');p.add_argument('--id',required=True);p.add_argument('--position',type=_vec3,default=[0,0,0]);p.add_argument('--radius',type=float,required=True);p.add_argument('--height',type=float,required=True);p.add_argument('--segments',type=int,default=24);p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
36
+ p=cs.add_parser('add-sphere');p.add_argument('path');p.add_argument('--id',required=True);p.add_argument('--position',type=_vec3,default=[0,0,0]);p.add_argument('--radius',type=float,required=True);p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
37
+ p=cs.add_parser('move');p.add_argument('path');p.add_argument('--id',required=True);p.add_argument('--position',type=_vec3,required=True);p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
38
+ p=cs.add_parser('remove');p.add_argument('path');p.add_argument('--id',required=True);p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
39
+ p=cs.add_parser('preview');p.add_argument('path');p.add_argument('--out',required=True)
40
+
41
+ pp=sub.add_parser('placement');pps=pp.add_subparsers(dest='placement_cmd',required=True)
42
+ p=pps.add_parser('make');p.add_argument('cell_path');p.add_argument('--position',required=True,type=_vec3);p.add_argument('--yaw-radians',type=float,default=0);p.add_argument('--scale',type=float,default=1);p.add_argument('--id');p.add_argument('--out')
43
+
44
+ pw=sub.add_parser('world');ws=pw.add_subparsers(dest='world_cmd',required=True)
45
+ p=ws.add_parser('init');p.add_argument('--id',required=True);p.add_argument('--name');p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
46
+ p=ws.add_parser('place');p.add_argument('world_path');p.add_argument('--cell',required=True);p.add_argument('--entity-id',required=True);p.add_argument('--position',type=_vec3,required=True);p.add_argument('--yaw-radians',type=float,default=0);p.add_argument('--scale',type=float,default=1);p.add_argument('--name');p.add_argument('--out',required=True);p.add_argument('--force',action='store_true')
47
+ for name in ('validate','inspect'):
48
+ p=ws.add_parser(name);p.add_argument('path')
49
+ p=ws.add_parser('preview');p.add_argument('path');p.add_argument('--out',required=True)
50
+
51
+ args=parser.parse_args(argv)
52
+ if args.cmd=='about':_print(manifest());return 0
53
+ if args.cmd=='schema':_print(schema(args.name));return 0
54
+ if args.cmd=='cell':
55
+ if args.cell_cmd=='init':
56
+ _write_cell(new_box_cell(cell_id=args.id,name=args.name,size=args.size),args.out,args.force);return 0
57
+ cell=load_cell(args.path)
58
+ if args.cell_cmd=='validate':
59
+ r=validate_cell(cell);_print(r);return 0 if r['valid'] else 1
60
+ if args.cell_cmd=='inspect':
61
+ r=inspect_cell(cell);_print(r);return 0 if r['valid'] else 1
62
+ if args.cell_cmd=='add-box':out=add_box(cell,object_id=args.id,position=args.position,size=args.size)
63
+ elif args.cell_cmd=='add-cylinder':out=add_cylinder(cell,object_id=args.id,position=args.position,radius=args.radius,height=args.height,segments=args.segments)
64
+ elif args.cell_cmd=='add-sphere':out=add_sphere(cell,object_id=args.id,position=args.position,radius=args.radius)
65
+ elif args.cell_cmd=='move':out=move_geometry(cell,object_id=args.id,position=args.position)
66
+ elif args.cell_cmd=='remove':out=remove_geometry(cell,object_id=args.id)
67
+ elif args.cell_cmd=='preview':preview_cell(cell,args.out);return 0
68
+ else:return 2
69
+ _write_cell(out,args.out,args.force);return 0
70
+ if args.cmd=='placement' and args.placement_cmd=='make':
71
+ obj=make_placement(load_cell(args.cell_path),position=args.position,yaw_radians=args.yaw_radians,scale=args.scale,placement_id=args.id)
72
+ text=json.dumps(obj,indent=2,ensure_ascii=False)+'\n'
73
+ Path(args.out).write_text(text,encoding='utf-8') if args.out else print(text,end='')
74
+ return 0
75
+ if args.cmd=='world':
76
+ if args.world_cmd=='init':_write_world(new_world(world_id=args.id,label=args.name),args.out,args.force);return 0
77
+ world=load_world_snapshot(args.world_path if hasattr(args,'world_path') else args.path)
78
+ if args.world_cmd=='place':
79
+ out=place_cell(world,load_cell(args.cell),entity_id=args.entity_id,position=args.position,yaw_radians=args.yaw_radians,scale=args.scale,display_name=args.name)
80
+ _write_world(out,args.out,args.force);return 0
81
+ if args.world_cmd=='validate':r=validate_world_snapshot(world);_print(r);return 0 if r['valid'] else 1
82
+ if args.world_cmd=='inspect':r=inspect_world_snapshot(world);_print(r);return 0 if r['valid'] else 1
83
+ if args.world_cmd=='preview':preview_world(world,args.out);return 0
84
+ return 2
85
+
86
+ if __name__=='__main__':raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://clayspace.invalid/schema/cell-local-fragment-1.0.0.json",
4
+ "title": "ClaySpace Local Fragment Cell 1.0.0",
5
+ "type": "object",
6
+ "required": ["schema", "schemaVersion", "identity", "coordinateSystem", "geometry"],
7
+ "properties": {
8
+ "schema": {"const": "clayspace.cell.local-fragment"},
9
+ "schemaVersion": {"const": "1.0.0"},
10
+ "identity": {"type": "object", "required": ["cellId"]},
11
+ "coordinateSystem": {"type": "object"},
12
+ "placement": {"enum": ["none", null]},
13
+ "masterWorldCoordinates": {"type": "null"},
14
+ "worldTransform": {"type": "null"},
15
+ "geometry": {"type": "array", "minItems": 1}
16
+ },
17
+ "additionalProperties": true
18
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "schemaVersion": "0.1.0",
3
+ "id": "clayspace",
4
+ "name": "ClaySpace",
5
+ "role": "standalone local spatial modeling toolkit for portable Cells and worlds",
6
+ "status": "preview",
7
+ "coordinateConvention": "x-right / y-up / z-forward / meters",
8
+ "principles": {
9
+ "worksWithoutAccountsOrServices": true,
10
+ "localCellsArePortable": true,
11
+ "sourceCellsStayLocal": true,
12
+ "placementIsSeparate": true,
13
+ "hostProtocolsStayExternal": true,
14
+ "hostPolicyIsNotClaySpacePolicy": true
15
+ },
16
+ "mandatoryDependencies": [],
17
+ "packageVersion": "0.1.1",
18
+ "capabilities": {
19
+ "cellCreation": true,
20
+ "primitiveModeling": ["box", "prism", "cylinder", "sphere", "plane"],
21
+ "cellValidation": true,
22
+ "safeCellZipLoading": true,
23
+ "worldComposition": true,
24
+ "placementTransforms": true,
25
+ "selfContainedHtmlPreview": true,
26
+ "interactive3dEditor": false,
27
+ "hostSpecificPolicy": false
28
+ },
29
+ "provenance": {
30
+ "flyWithMe": {
31
+ "project": "Fly With Me",
32
+ "author": "Kun Chen",
33
+ "url": "https://github.com/kunchenguid/fly-with-me",
34
+ "license": "MIT",
35
+ "noticeFile": "licenses/FLY_WITH_ME_MIT.txt"
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://clayspace.invalid/schema/placement-0.1.0.json",
4
+ "title": "ClaySpace Placement 0.1.0",
5
+ "type": "object",
6
+ "required": ["schema", "schemaVersion", "placementId", "cellId", "position", "yawRadians", "scale"],
7
+ "properties": {
8
+ "schema": {"const": "clayspace.placement"},
9
+ "schemaVersion": {"const": "0.1.0"},
10
+ "placementId": {"type": "string"},
11
+ "cellId": {"type": "string"},
12
+ "position": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}},
13
+ "yawRadians": {"type": "number"},
14
+ "scale": {"type": "number", "exclusiveMinimum": 0}
15
+ },
16
+ "additionalProperties": true
17
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://clayspace.invalid/schema/world-snapshot-0.1.0.json",
4
+ "title": "ClaySpace World Snapshot 0.1.0",
5
+ "type": "object",
6
+ "required": ["schema", "schemaVersion", "worldId", "entities"],
7
+ "properties": {
8
+ "schema": {"const": "clayspace.world.snapshot"},
9
+ "schemaVersion": {"const": "0.1.0"},
10
+ "worldId": {"type": "string"},
11
+ "label": {"type": "string"},
12
+ "entities": {
13
+ "type": "array",
14
+ "items": {
15
+ "type": "object",
16
+ "required": ["entityId"],
17
+ "properties": {
18
+ "entityId": {"type": "string"},
19
+ "kind": {"type": "string"},
20
+ "displayName": {"type": "string"},
21
+ "placement": {
22
+ "type": ["object", "null"],
23
+ "properties": {
24
+ "position": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}},
25
+ "yawRadians": {"type": "number"},
26
+ "scale": {"type": "number", "exclusiveMinimum": 0}
27
+ },
28
+ "required": ["position", "yawRadians", "scale"],
29
+ "additionalProperties": true
30
+ },
31
+ "payload": {"type": "object"}
32
+ },
33
+ "additionalProperties": true
34
+ }
35
+ },
36
+ "metadata": {"type": "object"}
37
+ },
38
+ "additionalProperties": true
39
+ }
clayspace/model.py ADDED
@@ -0,0 +1,141 @@
1
+ """Pure-data modeling operations for ClaySpace Cells."""
2
+ from __future__ import annotations
3
+ from copy import deepcopy
4
+ from math import cos, radians, sin
5
+ from .cell import validate_cell
6
+
7
+
8
+ def _require_v1(cell: dict) -> dict:
9
+ check = validate_cell(cell)
10
+ if not check['valid']:
11
+ raise ValueError('invalid Cell: ' + '; '.join(check['errors']))
12
+ if check['schemaFamily'] != 'clayspace.cell.local-fragment':
13
+ raise ValueError('editing requires clayspace.cell.local-fragment 1.0.0')
14
+ return deepcopy(cell)
15
+
16
+
17
+ def _ensure_unique(cell: dict, object_id: str) -> None:
18
+ if not isinstance(object_id, str) or not object_id.strip():
19
+ raise ValueError('object_id must be a non-empty string')
20
+ if any(g.get('id') == object_id for g in cell['geometry'] if isinstance(g, dict)):
21
+ raise ValueError(f'duplicate geometry ID {object_id}')
22
+
23
+
24
+ def _vec3(value, name: str, positive: bool = False):
25
+ if not isinstance(value, (list, tuple)) or len(value) != 3:
26
+ raise ValueError(f'{name} must contain three numbers')
27
+ result = [float(x) for x in value]
28
+ if positive and any(x <= 0 for x in result):
29
+ raise ValueError(f'{name} values must be > 0')
30
+ return result
31
+
32
+
33
+ def _vec2(value, name: str, positive: bool = False):
34
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
35
+ raise ValueError(f'{name} must contain two numbers')
36
+ result = [float(x) for x in value]
37
+ if positive and any(x <= 0 for x in result):
38
+ raise ValueError(f'{name} values must be > 0')
39
+ return result
40
+
41
+
42
+ def add_box(cell: dict, *, object_id: str, position=(0,0,0), size=(1,1,1), rotation_deg=(0,0,0), scale=(1,1,1)) -> dict:
43
+ out = _require_v1(cell); _ensure_unique(out, object_id)
44
+ out['geometry'].append({'id': object_id, 'primitive': 'box', 'position': _vec3(position,'position'), 'size': _vec3(size,'size',True), 'rotationDeg': _vec3(rotation_deg,'rotation_deg'), 'scale': _vec3(scale,'scale',True)})
45
+ return recompute_bounds(out)
46
+
47
+
48
+ def add_cylinder(cell: dict, *, object_id: str, position=(0,0,0), radius=1.0, height=2.0, rotation_deg=(0,0,0), scale=(1,1,1), segments=24) -> dict:
49
+ out = _require_v1(cell); _ensure_unique(out, object_id)
50
+ radius=float(radius);height=float(height);segments=int(segments)
51
+ if radius<=0 or height<=0 or not 3<=segments<=256: raise ValueError('radius/height must be >0 and segments 3..256')
52
+ out['geometry'].append({'id':object_id,'primitive':'cylinder','position':_vec3(position,'position'),'radius':radius,'height':height,'segments':segments,'rotationDeg':_vec3(rotation_deg,'rotation_deg'),'scale':_vec3(scale,'scale',True)})
53
+ return recompute_bounds(out)
54
+
55
+
56
+ def add_sphere(cell: dict, *, object_id: str, position=(0,0,0), radius=1.0, scale=(1,1,1)) -> dict:
57
+ out = _require_v1(cell); _ensure_unique(out, object_id)
58
+ radius=float(radius)
59
+ if radius<=0: raise ValueError('radius must be > 0')
60
+ out['geometry'].append({'id':object_id,'primitive':'sphere','position':_vec3(position,'position'),'radius':radius,'rotationDeg':[0,0,0],'scale':_vec3(scale,'scale',True)})
61
+ return recompute_bounds(out)
62
+
63
+
64
+ def add_plane(cell: dict, *, object_id: str, position=(0,0,0), size=(1,1), rotation_deg=(0,0,0), scale=(1,1,1)) -> dict:
65
+ out = _require_v1(cell); _ensure_unique(out, object_id)
66
+ out['geometry'].append({'id':object_id,'primitive':'plane','position':_vec3(position,'position'),'size':_vec2(size,'size',True),'rotationDeg':_vec3(rotation_deg,'rotation_deg'),'scale':_vec3(scale,'scale',True)})
67
+ return recompute_bounds(out)
68
+
69
+
70
+ def add_prism(cell: dict, *, object_id: str, footprint_xz, y0=0.0, y1=1.0, position=(0,0,0), rotation_deg=(0,0,0), scale=(1,1,1)) -> dict:
71
+ out = _require_v1(cell); _ensure_unique(out, object_id)
72
+ points=[_vec2(p,'footprint point') for p in footprint_xz]
73
+ if len(points)<3: raise ValueError('footprint needs at least three points')
74
+ y0=float(y0);y1=float(y1)
75
+ if y1<=y0: raise ValueError('y1 must be greater than y0')
76
+ out['geometry'].append({'id':object_id,'primitive':'prism','position':_vec3(position,'position'),'footprintXZ':points,'y0':y0,'y1':y1,'rotationDeg':_vec3(rotation_deg,'rotation_deg'),'scale':_vec3(scale,'scale',True)})
77
+ return recompute_bounds(out)
78
+
79
+
80
+ def move_geometry(cell: dict, *, object_id: str, position) -> dict:
81
+ out=_require_v1(cell)
82
+ for item in out['geometry']:
83
+ if item.get('id')==object_id:
84
+ item['position']=_vec3(position,'position')
85
+ return recompute_bounds(out)
86
+ raise KeyError(object_id)
87
+
88
+
89
+ def remove_geometry(cell: dict, *, object_id: str) -> dict:
90
+ out=_require_v1(cell)
91
+ kept=[g for g in out['geometry'] if g.get('id')!=object_id]
92
+ if len(kept)==len(out['geometry']): raise KeyError(object_id)
93
+ if not kept: raise ValueError('a Cell must retain at least one geometry item')
94
+ out['geometry']=kept
95
+ return recompute_bounds(out)
96
+
97
+
98
+ def _rotate_y(x,z,yaw_deg):
99
+ a=radians(yaw_deg);c=cos(a);s=sin(a)
100
+ return x*c-z*s, x*s+z*c
101
+
102
+
103
+ def _item_bounds(item: dict):
104
+ px,py,pz=item.get('position',[0,0,0]); sx,sy,sz=item.get('scale',[1,1,1]); yaw=item.get('rotationDeg',[0,0,0])[1]
105
+ primitive=item.get('primitive')
106
+ points=[]
107
+ if primitive=='box':
108
+ w,h,d=item['size']; w*=sx;h*=sy;d*=sz
109
+ for x in (-w/2,w/2):
110
+ for y in (-h/2,h/2):
111
+ for z in (-d/2,d/2):
112
+ rx,rz=_rotate_y(x,z,yaw);points.append((px+rx,py+y,pz+rz))
113
+ elif primitive=='sphere':
114
+ r=item['radius']; rx=r*sx;ry=r*sy;rz=r*sz
115
+ points=[(px-rx,py-ry,pz-rz),(px+rx,py+ry,pz+rz)]
116
+ elif primitive=='cylinder':
117
+ r=item['radius']; h=item['height']; rr=r*max(sx,sz)
118
+ points=[(px-rr,py-h*sy/2,pz-rr),(px+rr,py+h*sy/2,pz+rr)]
119
+ elif primitive=='plane':
120
+ w,h=item['size'];w*=sx;h*=sy
121
+ for x in (-w/2,w/2):
122
+ for y in (-h/2,h/2):
123
+ rx,rz=_rotate_y(x,0,yaw);points.append((px+rx,py+y,pz+rz))
124
+ elif primitive=='prism':
125
+ for x,z in item['footprintXZ']:
126
+ x*=sx;z*=sz;rx,rz=_rotate_y(x,z,yaw)
127
+ points.extend([(px+rx,py+item['y0']*sy,pz+rz),(px+rx,py+item['y1']*sy,pz+rz)])
128
+ return points
129
+
130
+
131
+ def recompute_bounds(cell: dict) -> dict:
132
+ out=deepcopy(cell)
133
+ points=[]
134
+ for item in out.get('geometry',[]): points.extend(_item_bounds(item))
135
+ if not points: raise ValueError('Cell has no bounded geometry')
136
+ mins=[min(p[i] for p in points) for i in range(3)]; maxs=[max(p[i] for p in points) for i in range(3)]
137
+ # ensure nonzero bounds for planar-only Cells
138
+ for i in range(3):
139
+ if maxs[i] <= mins[i]: mins[i]-=0.0005;maxs[i]+=0.0005
140
+ out['bounds']={'aabb':{'min':mins,'max':maxs}}
141
+ return out
clayspace/placement.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+ from math import isfinite
3
+ from uuid import uuid4
4
+ from .cell import inspect_cell
5
+
6
+
7
+ def _finite3(value) -> bool:
8
+ return isinstance(value, (list, tuple)) and len(value) == 3 and all(isinstance(v, (int, float)) and not isinstance(v, bool) and isfinite(v) for v in value)
9
+
10
+
11
+ def validate_placement(placement: dict) -> dict:
12
+ errors: list[str] = []
13
+ if not isinstance(placement, dict):
14
+ return {"valid": False, "errors": ["placement must be an object"]}
15
+ if placement.get("schema") != "clayspace.placement" or placement.get("schemaVersion") != "0.1.0":
16
+ errors.append("unsupported placement schema")
17
+ for key in ("placementId", "cellId"):
18
+ if not placement.get(key): errors.append(f"missing {key}")
19
+ if not _finite3(placement.get("position")): errors.append("position must be a finite xyz vector")
20
+ yaw = placement.get("yawRadians")
21
+ if not isinstance(yaw, (int, float)) or isinstance(yaw, bool) or not isfinite(yaw): errors.append("yawRadians must be finite")
22
+ scale = placement.get("scale")
23
+ if not isinstance(scale, (int, float)) or isinstance(scale, bool) or not isfinite(scale) or scale <= 0: errors.append("scale must be > 0")
24
+ return {"valid": not errors, "errors": errors}
25
+
26
+
27
+ def make_placement(cell: dict, *, position, yaw_radians: float = 0.0, scale: float = 1.0, placement_id: str | None = None) -> dict:
28
+ info = inspect_cell(cell)
29
+ if not info["valid"]: raise ValueError("invalid Cell: " + "; ".join(info["errors"]))
30
+ placement = {
31
+ "schema": "clayspace.placement", "schemaVersion": "0.1.0",
32
+ "placementId": placement_id or f"clayspace:placement:{uuid4().hex}",
33
+ "cellId": info["cellId"], "position": list(position),
34
+ "yawRadians": yaw_radians, "scale": scale,
35
+ }
36
+ check = validate_placement(placement)
37
+ if not check["valid"]: raise ValueError("invalid placement: " + "; ".join(check["errors"]))
38
+ return placement
clayspace/preview.py ADDED
@@ -0,0 +1,78 @@
1
+ """Dependency-free self-contained HTML previews for Cells and worlds."""
2
+ from __future__ import annotations
3
+ import html
4
+ import json
5
+ from pathlib import Path
6
+ from .cell import validate_cell
7
+ from .world import validate_world_snapshot
8
+
9
+
10
+ def _write(title: str, model: dict, out: str | Path) -> Path:
11
+ out=Path(out)
12
+ data=json.dumps(model,ensure_ascii=False,separators=(',',':')).replace('</','<\\/')
13
+ safe_title=html.escape(title)
14
+ page=f'''<!doctype html>
15
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
16
+ <title>{safe_title} — ClaySpace</title>
17
+ <style>
18
+ html,body{{margin:0;height:100%;background:#111;color:#eee;font:14px system-ui,sans-serif;overflow:hidden}}
19
+ canvas{{display:block;width:100%;height:100%;cursor:grab}}canvas:active{{cursor:grabbing}}
20
+ #hud{{position:fixed;left:14px;top:12px;background:#111c;padding:10px 12px;border:1px solid #ffffff22;border-radius:8px;backdrop-filter:blur(6px);max-width:min(520px,calc(100vw - 40px))}}
21
+ #hud b{{font-size:15px}}#hud small{{display:block;color:#aaa;margin-top:4px;line-height:1.35}}
22
+ button{{margin-top:8px;background:#222;color:#eee;border:1px solid #555;border-radius:5px;padding:5px 8px}}
23
+ </style>
24
+ <canvas id="c"></canvas><div id="hud"><b>{safe_title}</b><small>Drag to orbit · wheel to zoom · double-click to reset<br>Self-contained ClaySpace preview — no network required.</small><button id="reset">Reset view</button></div>
25
+ <script>
26
+ const MODEL={data};
27
+ const canvas=document.getElementById('c'),ctx=canvas.getContext('2d');
28
+ let yaw=.7,pitch=-.45,zoom=1,drag=false,lx=0,ly=0;
29
+ function resize(){{const d=devicePixelRatio||1;canvas.width=innerWidth*d;canvas.height=innerHeight*d;ctx.setTransform(d,0,0,d,0,0);draw();}}
30
+ addEventListener('resize',resize);
31
+ canvas.onpointerdown=e=>{{drag=true;lx=e.clientX;ly=e.clientY;canvas.setPointerCapture(e.pointerId)}};
32
+ canvas.onpointerup=()=>drag=false;canvas.onpointermove=e=>{{if(!drag)return;yaw+=(e.clientX-lx)*.008;pitch=Math.max(-1.45,Math.min(1.45,pitch+(e.clientY-ly)*.008));lx=e.clientX;ly=e.clientY;draw()}};
33
+ canvas.onwheel=e=>{{e.preventDefault();zoom*=Math.exp(-e.deltaY*.001);zoom=Math.max(.1,Math.min(10,zoom));draw()}};canvas.ondblclick=reset;document.getElementById('reset').onclick=reset;
34
+ function reset(){{yaw=.7;pitch=-.45;zoom=1;draw()}}
35
+ function rotY(p,a){{let c=Math.cos(a),s=Math.sin(a);return [p[0]*c-p[2]*s,p[1],p[0]*s+p[2]*c]}}
36
+ function rotX(p,a){{let c=Math.cos(a),s=Math.sin(a);return [p[0],p[1]*c-p[2]*s,p[1]*s+p[2]*c]}}
37
+ function rotZ(p,a){{let c=Math.cos(a),s=Math.sin(a);return [p[0]*c-p[1]*s,p[0]*s+p[1]*c,p[2]]}}
38
+ function applyLocal(p,shape){{let s=shape.scale||[1,1,1],r=shape.rotationDeg||[0,0,0],q=[p[0]*s[0],p[1]*s[1],p[2]*s[2]];q=rotX(q,r[0]*Math.PI/180);q=rotY(q,r[1]*Math.PI/180);q=rotZ(q,r[2]*Math.PI/180);let pos=shape.position||[0,0,0];return [q[0]+pos[0],q[1]+pos[1],q[2]+pos[2]]}}
39
+ function worldPoint(p,shape){{let q=applyLocal(p,shape),w=shape.world||null;if(w){{q=[q[0]*(w.scale||1),q[1]*(w.scale||1),q[2]*(w.scale||1)];q=rotY(q,w.yawRadians||0);let t=w.position||[0,0,0];q=[q[0]+t[0],q[1]+t[1],q[2]+t[2]]}}return q}}
40
+ function geom(shape){{let v=[],e=[];const edge=(a,b)=>e.push([a,b]);if(shape.primitive==='box'){{let [w,h,d]=shape.size;for(let y of [-h/2,h/2])for(let z of [-d/2,d/2])for(let x of [-w/2,w/2])v.push([x,y,z]);[[0,1],[2,3],[4,5],[6,7],[0,2],[1,3],[4,6],[5,7],[0,4],[1,5],[2,6],[3,7]].forEach(x=>edge(...x));}}
41
+ else if(shape.primitive==='sphere'){{let r=shape.radius,n=20;for(let ring=0;ring<3;ring++){{let base=v.length;for(let i=0;i<n;i++){{let a=i*2*Math.PI/n;if(ring===0)v.push([Math.cos(a)*r,Math.sin(a)*r,0]);if(ring===1)v.push([Math.cos(a)*r,0,Math.sin(a)*r]);if(ring===2)v.push([0,Math.cos(a)*r,Math.sin(a)*r]);}}for(let i=0;i<n;i++)edge(base+i,base+(i+1)%n);}}}}
42
+ else if(shape.primitive==='cylinder'){{let r=shape.radius,h=shape.height,n=Math.max(6,Math.min(48,shape.segments||24));for(let y of [-h/2,h/2])for(let i=0;i<n;i++){{let a=i*2*Math.PI/n;v.push([Math.cos(a)*r,y,Math.sin(a)*r]);}}for(let i=0;i<n;i++){{edge(i,(i+1)%n);edge(n+i,n+(i+1)%n);if(i%(Math.ceil(n/8))===0)edge(i,n+i);}}}}
43
+ else if(shape.primitive==='plane'){{let [w,h]=shape.size;v=[[-w/2,-h/2,0],[w/2,-h/2,0],[w/2,h/2,0],[-w/2,h/2,0]];[[0,1],[1,2],[2,3],[3,0]].forEach(x=>edge(...x));}}
44
+ else if(shape.primitive==='prism'){{let f=shape.footprintXZ,n=f.length,y0=shape.y0,y1=shape.y1;for(let y of [y0,y1])for(let p of f)v.push([p[0],y,p[1]]);for(let i=0;i<n;i++){{edge(i,(i+1)%n);edge(n+i,n+(i+1)%n);edge(i,n+i);}}}}
45
+ return {{v,e}};}}
46
+ function collect(){{let pts=[];for(const s of MODEL.shapes){{let g=geom(s);for(const p of g.v)pts.push(worldPoint(p,s));}}return pts}}
47
+ function bounds(pts){{if(!pts.length)return {{c:[0,0,0],r:10}};let mi=[Infinity,Infinity,Infinity],ma=[-Infinity,-Infinity,-Infinity];for(const p of pts)for(let i=0;i<3;i++){{mi[i]=Math.min(mi[i],p[i]);ma[i]=Math.max(ma[i],p[i]);}}let c=mi.map((x,i)=>(x+ma[i])/2),r=Math.max(...ma.map((x,i)=>x-mi[i]))||1;return {{c,r}}}}
48
+ function project(p,b){{let q=[p[0]-b.c[0],p[1]-b.c[1],p[2]-b.c[2]];q=rotY(q,-yaw);q=rotX(q,-pitch);let dist=b.r*2.5/zoom,z=q[2]+dist,f=Math.max(10,Math.min(innerWidth,innerHeight))*.9;let k=f/Math.max(.2,z+b.r*1.2);return [innerWidth/2+q[0]*k,innerHeight/2-q[1]*k,q[2]]}}
49
+ function grid(b){{ctx.strokeStyle='#ffffff10';ctx.lineWidth=1;let step=Math.max(1,Math.pow(10,Math.floor(Math.log10(b.r||1)))/2),n=12;for(let i=-n;i<=n;i++){{for(const pair of [[[i*step,0,-n*step],[i*step,0,n*step]],[[-n*step,0,i*step],[n*step,0,i*step]]]){{let a=project(pair[0],b),c=project(pair[1],b);ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(c[0],c[1]);ctx.stroke();}}}}}}
50
+ function draw(){{ctx.clearRect(0,0,innerWidth,innerHeight);let all=collect(),b=bounds(all);grid(b);let lines=[];for(const s of MODEL.shapes){{let g=geom(s),pv=g.v.map(p=>project(worldPoint(p,s),b));for(const [a,c] of g.e)lines.push([pv[a],pv[c],s]);}}lines.sort((a,c)=>(a[0][2]+a[1][2])-(c[0][2]+c[1][2]));for(const [a,c,s] of lines){{ctx.strokeStyle=s.stroke||'#a7d7ff';ctx.lineWidth=1.6;ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(c[0],c[1]);ctx.stroke();}}}}
51
+ resize();
52
+ </script>'''
53
+ out.write_text(page,encoding='utf-8')
54
+ return out
55
+
56
+
57
+ def preview_cell(cell: dict, out: str | Path) -> Path:
58
+ check=validate_cell(cell)
59
+ if not check['valid']: raise ValueError('invalid Cell: '+'; '.join(check['errors']))
60
+ if check['schemaFamily']!='clayspace.cell.local-fragment': raise ValueError('preview requires local-fragment Cell format')
61
+ title=(cell.get('identity') or {}).get('title') or check['cellId'] or 'ClaySpace Cell'
62
+ return _write(title,{'shapes':cell['geometry']},out)
63
+
64
+
65
+ def preview_world(world: dict, out: str | Path) -> Path:
66
+ check=validate_world_snapshot(world)
67
+ if not check['valid']: raise ValueError('invalid world: '+'; '.join(check['errors']))
68
+ shapes=[]
69
+ for entity in world.get('entities',[]):
70
+ payload=entity.get('payload') or {};cell=payload.get('cell');placement=entity.get('placement')
71
+ if not isinstance(cell,dict) or not isinstance(placement,dict): continue
72
+ ci=validate_cell(cell)
73
+ if not ci['valid'] or ci['schemaFamily']!='clayspace.cell.local-fragment': continue
74
+ world_transform={'position':placement['position'],'yawRadians':placement['yawRadians'],'scale':placement['scale']}
75
+ for item in cell['geometry']:
76
+ shape=dict(item);shape['world']=world_transform;shapes.append(shape)
77
+ title=world.get('label') or world.get('worldId') or 'ClaySpace World'
78
+ return _write(title,{'shapes':shapes},out)
clayspace/resources.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from importlib.resources import files
4
+
5
+
6
+ def _load(name: str) -> dict:
7
+ return json.loads(files("clayspace.data").joinpath(name).read_text(encoding="utf-8"))
8
+
9
+
10
+ def manifest() -> dict:
11
+ return _load("manifest.json")
12
+
13
+
14
+ def schema(name: str) -> dict:
15
+ names = {
16
+ "cell": "cell-local-fragment-1.0.0.json",
17
+ "placement": "placement-0.1.0.json",
18
+ "world": "world-snapshot-0.1.0.json",
19
+ }
20
+ if name not in names:
21
+ raise KeyError(f"unknown schema {name!r}")
22
+ return _load(names[name])
clayspace/world.py ADDED
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from copy import deepcopy
4
+ from math import isfinite
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from .cell import inspect_cell, validate_cell
8
+
9
+
10
+ def _finite(value: Any) -> bool:
11
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value)
12
+
13
+
14
+ def _finite3(value: Any) -> bool:
15
+ return isinstance(value, (list, tuple)) and len(value) == 3 and all(_finite(v) for v in value)
16
+
17
+
18
+ def load_world_snapshot(path: str | Path) -> dict:
19
+ value=json.loads(Path(path).read_text(encoding='utf-8'))
20
+ if not isinstance(value,dict): raise ValueError('world snapshot must be an object')
21
+ return value
22
+
23
+
24
+ def save_world_snapshot(world: dict, path: str | Path, *, overwrite: bool=False) -> Path:
25
+ check=validate_world_snapshot(world)
26
+ if not check['valid']: raise ValueError('invalid world: '+'; '.join(check['errors']))
27
+ path=Path(path);mode='w' if overwrite else 'x'
28
+ with path.open(mode,encoding='utf-8') as f: json.dump(world,f,indent=2,ensure_ascii=False,allow_nan=False);f.write('\n')
29
+ return path
30
+
31
+
32
+ def new_world(*, world_id: str, label: str | None=None) -> dict:
33
+ if not isinstance(world_id,str) or not world_id.strip(): raise ValueError('world_id required')
34
+ return {'schema':'clayspace.world.snapshot','schemaVersion':'0.1.0','worldId':world_id,'label':label or world_id,'entities':[],'metadata':{}}
35
+
36
+
37
+ def place_cell(world: dict, cell: dict, *, entity_id: str, position=(0,0,0), yaw_radians=0.0, scale=1.0, display_name: str | None=None, embed=True) -> dict:
38
+ if not validate_world_snapshot(world)['valid']: raise ValueError('invalid world')
39
+ info=inspect_cell(cell)
40
+ if not info['valid']: raise ValueError('invalid Cell: '+'; '.join(info['errors']))
41
+ if any(e.get('entityId')==entity_id for e in world['entities'] if isinstance(e,dict)): raise ValueError(f'duplicate entityId {entity_id}')
42
+ if not _finite3(position): raise ValueError('position must be xyz')
43
+ if not _finite(yaw_radians): raise ValueError('yaw_radians must be finite')
44
+ if not _finite(scale) or scale<=0: raise ValueError('scale must be > 0')
45
+ out=deepcopy(world)
46
+ payload={'cellId':info['cellId']}
47
+ if embed: payload['cell']=deepcopy(cell)
48
+ out['entities'].append({'entityId':entity_id,'kind':'clayspace-cell','displayName':display_name or info['cellId'],'placement':{'position':list(position),'yawRadians':float(yaw_radians),'scale':float(scale)},'payload':payload})
49
+ return out
50
+
51
+
52
+ def validate_world_snapshot(snapshot: dict) -> dict:
53
+ errors: list[str] = []
54
+ if not isinstance(snapshot, dict): return {"valid": False, "errors": ["world snapshot must be an object"], "entityCount": 0}
55
+ if snapshot.get("schema") != "clayspace.world.snapshot" or snapshot.get("schemaVersion") != "0.1.0": errors.append("unsupported world snapshot schema")
56
+ if not snapshot.get("worldId"): errors.append("missing worldId")
57
+ entities = snapshot.get("entities")
58
+ if not isinstance(entities, list): errors.append("entities must be an array"); entities=[]
59
+ ids: set[str] = set()
60
+ for index, entity in enumerate(entities):
61
+ if not isinstance(entity, dict): errors.append(f"entity {index} must be an object"); continue
62
+ entity_id = entity.get("entityId")
63
+ if not entity_id: errors.append(f"entity {index} missing entityId")
64
+ elif entity_id in ids: errors.append(f"duplicate entityId {entity_id}")
65
+ else: ids.add(entity_id)
66
+ placement = entity.get("placement")
67
+ if placement is not None:
68
+ if not isinstance(placement, dict): errors.append(f"{entity_id or index} placement must be an object")
69
+ else:
70
+ if not _finite3(placement.get("position")): errors.append(f"{entity_id or index} placement.position must be a finite xyz vector")
71
+ if not _finite(placement.get("yawRadians")): errors.append(f"{entity_id or index} placement.yawRadians must be finite")
72
+ if not _finite(placement.get("scale")) or placement.get('scale',0)<=0: errors.append(f"{entity_id or index} placement.scale must be > 0")
73
+ payload=entity.get('payload')
74
+ if isinstance(payload,dict) and isinstance(payload.get('cell'),dict):
75
+ c=validate_cell(payload['cell'])
76
+ errors.extend(f"{entity_id or index} cell: {e}" for e in c['errors'])
77
+ return {"valid": not errors, "errors": errors, "entityCount": len(entities), "worldId": snapshot.get("worldId")}
78
+
79
+
80
+ def inspect_world_snapshot(snapshot: dict) -> dict:
81
+ check=validate_world_snapshot(snapshot)
82
+ entities=snapshot.get('entities') if isinstance(snapshot,dict) and isinstance(snapshot.get('entities'),list) else []
83
+ placed=sum(1 for item in entities if isinstance(item,dict) and isinstance(item.get('placement'),dict))
84
+ embedded=sum(1 for item in entities if isinstance(item,dict) and isinstance(item.get('payload'),dict) and isinstance(item['payload'].get('cell'),dict))
85
+ return {**check,'placedEntityCount':placed,'unplacedEntityCount':max(0,len(entities)-placed),'embeddedCellCount':embedded}
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: clayspace
3
+ Version: 0.1.1
4
+ Summary: Standalone local spatial modeling toolkit for building portable Cells and worlds on your own computer.
5
+ Author: ClaySpace contributors
6
+ License-Expression: MIT
7
+ Project-URL: Upstream lineage: Fly With Me, https://github.com/kunchenguid/fly-with-me
8
+ Keywords: clayspace,modeling,spatial,3d,blockout,worldbuilding,geometry,offline
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ License-File: THIRD_PARTY_NOTICES.md
20
+ License-File: licenses/FLY_WITH_ME_MIT.txt
21
+ Dynamic: license-file
22
+
23
+ # ClaySpace
24
+
25
+ **ClaySpace is a standalone spatial modeling toolkit you can install on your
26
+ own computer and use without an account, hosted service, or host-world SDK.**
27
+ Build a room, a building, a street, a stage, a landscape
28
+ blockout, an abstract structure, or a whole world from portable local pieces
29
+ called **Cells**.
30
+
31
+ ClaySpace is intentionally lightweight. Its job is to give geometry stable
32
+ identity, coordinates, placement, and a viewable spatial structure without
33
+ forcing a particular art pipeline or story system on top of it.
34
+
35
+ > **Upstream license notice:** ClaySpace's development lineage includes
36
+ > [Fly With Me](https://github.com/kunchenguid/fly-with-me) by Kun Chen, used
37
+ > under the MIT License. The original `Copyright (c) 2026 Kun Chen` notice and
38
+ > MIT terms are included with this distribution in
39
+ > `licenses/FLY_WITH_ME_MIT.txt`. ClaySpace is an independent project and is
40
+ > not maintained or endorsed by the Fly With Me project.
41
+
42
+ ```bash
43
+ python -m pip install clayspace==0.1.1
44
+ ```
45
+
46
+ There are no required runtime dependencies and no network calls in the core
47
+ package.
48
+
49
+ ## Make something
50
+
51
+ Start a local Cell:
52
+
53
+ ```bash
54
+ clayspace cell init --id MY-HOUSE --name "My house" --size 12,8,18 --out house.json
55
+ ```
56
+
57
+ Add and edit primitive geometry:
58
+
59
+ ```bash
60
+ clayspace cell add-box house.json --id GARAGE --position 8,1.5,3 --size 6,3,7 --out house-2.json
61
+ clayspace cell add-cylinder house-2.json --id TOWER --position -4,6,0 --radius 2 --height 12 --out house-3.json
62
+ clayspace cell add-sphere house-3.json --id DOME --position -4,12,0 --radius 2.4 --out house-4.json
63
+ clayspace cell move house-4.json --id GARAGE --position 8,1.5,-2 --out house-5.json
64
+ clayspace cell preview house-5.json --out house-preview.html
65
+ ```
66
+
67
+ `preview` writes a **self-contained HTML file** with a dependency-free orbit,
68
+ zoom, grid, and wireframe/shaded primitive viewer. Open it in any modern
69
+ browser. Nothing is uploaded anywhere.
70
+
71
+ You can also work directly in Python:
72
+
73
+ ```python
74
+ from clayspace import new_box_cell, add_box, add_cylinder, save_cell
75
+
76
+ cell = new_box_cell(cell_id="MY-SET", name="My set", size=(20, 1, 20))
77
+ cell = add_box(cell, object_id="WALL-A", position=(0, 2, -8), size=(20, 4, 0.4))
78
+ cell = add_cylinder(cell, object_id="COLUMN", position=(3, 2, 0), radius=0.5, height=4)
79
+ save_cell(cell, "my-set.json")
80
+ ```
81
+
82
+ ## Compose a world
83
+
84
+ Cells stay local and portable. A **world** places them with transforms without
85
+ rewriting the Cell itself.
86
+
87
+ ```bash
88
+ clayspace world init --id MY-WORLD --name "My world" --out world.json
89
+ clayspace world place world.json --cell house-5.json --entity-id HOUSE-01 --position 30,0,-12 --yaw-radians 0.6 --out world-2.json
90
+ clayspace world preview world-2.json --out world-preview.html
91
+ ```
92
+
93
+ That separation means the same building can be placed many times, moved, or
94
+ shared without baking any one world's coordinates into the source object.
95
+
96
+ ## Cells and geometry
97
+
98
+ The public `clayspace.cell.local-fragment` format uses meters with
99
+ `x-right / y-up / z-forward` coordinates. 0.1.1 supports these procedural
100
+ primitives:
101
+
102
+ - boxes;
103
+ - prisms with arbitrary X/Z footprints;
104
+ - cylinders;
105
+ - spheres;
106
+ - planes/cards.
107
+
108
+ Cells may also carry additional metadata and asset references. The validator
109
+ checks structural portability; it is not a structural-engineering, collision,
110
+ or mesh-quality certification system.
111
+
112
+ For compatibility, ClaySpace also reads the earlier compact `0.1` Cell form and
113
+ richer production Cells that carry extra host metadata. Host-specific fields do
114
+ not become part of ClaySpace policy.
115
+
116
+ ## What ClaySpace does *not* require
117
+
118
+ You do **not** need:
119
+
120
+ - another project's SDK;
121
+ - a cloud account;
122
+ - a ledger or database service;
123
+ - a server;
124
+ - a particular renderer;
125
+ - permission from a host system to make your own Cells and worlds.
126
+
127
+ Other projects can choose to depend on ClaySpace. ClaySpace itself does not
128
+ depend on their identity, workflow, publication, or governance systems.
129
+
130
+ ## Fly With Me provenance and license
131
+
132
+ ClaySpace's development lineage includes
133
+ [**Fly With Me**](https://github.com/kunchenguid/fly-with-me), the open-source
134
+ browser world/flight project by **Kun Chen**. Fly With Me is licensed under the
135
+ MIT License, copyright (c) 2026 Kun Chen. Its original MIT notice is included
136
+ verbatim in `licenses/FLY_WITH_ME_MIT.txt` and described in
137
+ `THIRD_PARTY_NOTICES.md`.
138
+
139
+ We are deliberately explicit about that origin. ClaySpace is an independent
140
+ project and is not maintained or endorsed by Kun Chen or the Fly With Me
141
+ project.
142
+
143
+ The ClaySpace package itself is also released under the MIT License; see
144
+ `LICENSE`.
145
+
146
+ ## Current scope
147
+
148
+ ClaySpace 0.1.1 is an early modeling/blockout release, not a replacement for a
149
+ full DCC such as Blender. It provides portable spatial primitives, Cell/world
150
+ composition, transforms, validation, and local browser previews. Mesh import,
151
+ advanced materials, rigging, sculpting, and a full desktop GUI are not part of
152
+ this release.
@@ -0,0 +1,20 @@
1
+ clayspace/__init__.py,sha256=FpSx4Klx-0cnESpRrGAIIUEqscbt8vqvT8RiKaEWR8w,937
2
+ clayspace/cell.py,sha256=o6PfuVnalhQsNfojvv8Qx_XdaueBS8v4hV1kEcCuR4A,10941
3
+ clayspace/cli.py,sha256=oN8z8w9LpxGa69EIlWJ7C6jT0p68vNbl7ok-rG7mfw0,7001
4
+ clayspace/model.py,sha256=c8k_WQk9CllYb2ykJigRv8mu7w4Y7ttcYIlXHHjXADQ,6698
5
+ clayspace/placement.py,sha256=XM6hGjBcuYjzoI-WUTHJ6pu-7eeMBSILzcmktqpTgHI,1957
6
+ clayspace/preview.py,sha256=dVgRrmFQgtU-xhdbsDs3IRySVdOewy2X2ScC6G6vK_I,7550
7
+ clayspace/resources.py,sha256=Tn783CzxXnKNc5T3oD2a42Kk4T0j1w1SXiyvrAlz87M,568
8
+ clayspace/world.py,sha256=uWHs8I53JlJ1rsTg8Wns2f2rGO1eiRqPiP-1jO3_O2I,5082
9
+ clayspace/data/cell-local-fragment-1.0.0.json,sha256=-uZw4ym0uPTELJ9j-m73bZJzwxy1d5AvBIBF5xBRYh4,735
10
+ clayspace/data/manifest.json,sha256=H1UeaaNUVURyq3khWU2pE8XQeRA62oJ6X9JIKtQeTUg,1153
11
+ clayspace/data/placement-0.1.0.json,sha256=R4rAK6akhcty60ATatMJgMMj8JMM13yrRT3Q_HxMlIY,697
12
+ clayspace/data/world-snapshot-0.1.0.json,sha256=hkblJvqhpzkHcjDTs6DJ78IpltrctZElWN-iyQKHwt0,1328
13
+ clayspace-0.1.1.dist-info/licenses/LICENSE,sha256=8LYNcu_EegxXLVje2xnQJ10SEm9rGTCeySpQIYS-vIc,1079
14
+ clayspace-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md,sha256=SEj71C3RIIFTnOaENzbIc3vM5BTCQ8ETg_MEKsdLgHo,692
15
+ clayspace-0.1.1.dist-info/licenses/licenses/FLY_WITH_ME_MIT.txt,sha256=lFAWvTfhunIRYi72DuHSOrcniWuncQ7dIej76YOGOWk,1065
16
+ clayspace-0.1.1.dist-info/METADATA,sha256=6s6beG89j8wwJDCUNCa-Wo6bdsVbqDCax8aMtpexZfA,5833
17
+ clayspace-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
+ clayspace-0.1.1.dist-info/entry_points.txt,sha256=cSJvOVWIPUPgaKHD5BxgDZVUAzuze5twFePPtJ3zWjU,49
19
+ clayspace-0.1.1.dist-info/top_level.txt,sha256=EJDy0B2jbeuY2dJsmKrNozeO1573sSAc1wGwXTksM4E,10
20
+ clayspace-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clayspace = clayspace.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ClaySpace contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ # Third-party notices
2
+
3
+ ## Fly With Me
4
+
5
+ ClaySpace's development lineage includes **Fly With Me**, an open-source browser
6
+ world/flight project by **Kun Chen**:
7
+
8
+ https://github.com/kunchenguid/fly-with-me
9
+
10
+ Fly With Me is distributed under the MIT License, copyright (c) 2026 Kun Chen.
11
+ The original license text is preserved verbatim in
12
+ `licenses/FLY_WITH_ME_MIT.txt`.
13
+
14
+ ClaySpace is an independent project. Kun Chen and the Fly With Me project do
15
+ not maintain, sponsor, or endorse ClaySpace.
16
+
17
+ The public ClaySpace Python package is licensed separately under its own MIT
18
+ License. The Fly With Me notice remains included to preserve the upstream
19
+ license and make the project's provenance explicit.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kun Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ clayspace