clayspace 0.1.1__tar.gz

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.
@@ -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,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,130 @@
1
+ # ClaySpace
2
+
3
+ **ClaySpace is a standalone spatial modeling toolkit you can install on your
4
+ own computer and use without an account, hosted service, or host-world SDK.**
5
+ Build a room, a building, a street, a stage, a landscape
6
+ blockout, an abstract structure, or a whole world from portable local pieces
7
+ called **Cells**.
8
+
9
+ ClaySpace is intentionally lightweight. Its job is to give geometry stable
10
+ identity, coordinates, placement, and a viewable spatial structure without
11
+ forcing a particular art pipeline or story system on top of it.
12
+
13
+ > **Upstream license notice:** ClaySpace's development lineage includes
14
+ > [Fly With Me](https://github.com/kunchenguid/fly-with-me) by Kun Chen, used
15
+ > under the MIT License. The original `Copyright (c) 2026 Kun Chen` notice and
16
+ > MIT terms are included with this distribution in
17
+ > `licenses/FLY_WITH_ME_MIT.txt`. ClaySpace is an independent project and is
18
+ > not maintained or endorsed by the Fly With Me project.
19
+
20
+ ```bash
21
+ python -m pip install clayspace==0.1.1
22
+ ```
23
+
24
+ There are no required runtime dependencies and no network calls in the core
25
+ package.
26
+
27
+ ## Make something
28
+
29
+ Start a local Cell:
30
+
31
+ ```bash
32
+ clayspace cell init --id MY-HOUSE --name "My house" --size 12,8,18 --out house.json
33
+ ```
34
+
35
+ Add and edit primitive geometry:
36
+
37
+ ```bash
38
+ clayspace cell add-box house.json --id GARAGE --position 8,1.5,3 --size 6,3,7 --out house-2.json
39
+ clayspace cell add-cylinder house-2.json --id TOWER --position -4,6,0 --radius 2 --height 12 --out house-3.json
40
+ clayspace cell add-sphere house-3.json --id DOME --position -4,12,0 --radius 2.4 --out house-4.json
41
+ clayspace cell move house-4.json --id GARAGE --position 8,1.5,-2 --out house-5.json
42
+ clayspace cell preview house-5.json --out house-preview.html
43
+ ```
44
+
45
+ `preview` writes a **self-contained HTML file** with a dependency-free orbit,
46
+ zoom, grid, and wireframe/shaded primitive viewer. Open it in any modern
47
+ browser. Nothing is uploaded anywhere.
48
+
49
+ You can also work directly in Python:
50
+
51
+ ```python
52
+ from clayspace import new_box_cell, add_box, add_cylinder, save_cell
53
+
54
+ cell = new_box_cell(cell_id="MY-SET", name="My set", size=(20, 1, 20))
55
+ cell = add_box(cell, object_id="WALL-A", position=(0, 2, -8), size=(20, 4, 0.4))
56
+ cell = add_cylinder(cell, object_id="COLUMN", position=(3, 2, 0), radius=0.5, height=4)
57
+ save_cell(cell, "my-set.json")
58
+ ```
59
+
60
+ ## Compose a world
61
+
62
+ Cells stay local and portable. A **world** places them with transforms without
63
+ rewriting the Cell itself.
64
+
65
+ ```bash
66
+ clayspace world init --id MY-WORLD --name "My world" --out world.json
67
+ 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
68
+ clayspace world preview world-2.json --out world-preview.html
69
+ ```
70
+
71
+ That separation means the same building can be placed many times, moved, or
72
+ shared without baking any one world's coordinates into the source object.
73
+
74
+ ## Cells and geometry
75
+
76
+ The public `clayspace.cell.local-fragment` format uses meters with
77
+ `x-right / y-up / z-forward` coordinates. 0.1.1 supports these procedural
78
+ primitives:
79
+
80
+ - boxes;
81
+ - prisms with arbitrary X/Z footprints;
82
+ - cylinders;
83
+ - spheres;
84
+ - planes/cards.
85
+
86
+ Cells may also carry additional metadata and asset references. The validator
87
+ checks structural portability; it is not a structural-engineering, collision,
88
+ or mesh-quality certification system.
89
+
90
+ For compatibility, ClaySpace also reads the earlier compact `0.1` Cell form and
91
+ richer production Cells that carry extra host metadata. Host-specific fields do
92
+ not become part of ClaySpace policy.
93
+
94
+ ## What ClaySpace does *not* require
95
+
96
+ You do **not** need:
97
+
98
+ - another project's SDK;
99
+ - a cloud account;
100
+ - a ledger or database service;
101
+ - a server;
102
+ - a particular renderer;
103
+ - permission from a host system to make your own Cells and worlds.
104
+
105
+ Other projects can choose to depend on ClaySpace. ClaySpace itself does not
106
+ depend on their identity, workflow, publication, or governance systems.
107
+
108
+ ## Fly With Me provenance and license
109
+
110
+ ClaySpace's development lineage includes
111
+ [**Fly With Me**](https://github.com/kunchenguid/fly-with-me), the open-source
112
+ browser world/flight project by **Kun Chen**. Fly With Me is licensed under the
113
+ MIT License, copyright (c) 2026 Kun Chen. Its original MIT notice is included
114
+ verbatim in `licenses/FLY_WITH_ME_MIT.txt` and described in
115
+ `THIRD_PARTY_NOTICES.md`.
116
+
117
+ We are deliberately explicit about that origin. ClaySpace is an independent
118
+ project and is not maintained or endorsed by Kun Chen or the Fly With Me
119
+ project.
120
+
121
+ The ClaySpace package itself is also released under the MIT License; see
122
+ `LICENSE`.
123
+
124
+ ## Current scope
125
+
126
+ ClaySpace 0.1.1 is an early modeling/blockout release, not a replacement for a
127
+ full DCC such as Blender. It provides portable spatial primitives, Cell/world
128
+ composition, transforms, validation, and local browser previews. Mesh import,
129
+ advanced materials, rigging, sculpting, and a full desktop GUI are not part of
130
+ this release.
@@ -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,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
+ ]
@@ -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
+ }
@@ -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())