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.
- clayspace-0.1.1/LICENSE +21 -0
- clayspace-0.1.1/PKG-INFO +152 -0
- clayspace-0.1.1/README.md +130 -0
- clayspace-0.1.1/THIRD_PARTY_NOTICES.md +19 -0
- clayspace-0.1.1/clayspace/__init__.py +14 -0
- clayspace-0.1.1/clayspace/cell.py +232 -0
- clayspace-0.1.1/clayspace/cli.py +86 -0
- clayspace-0.1.1/clayspace/data/cell-local-fragment-1.0.0.json +18 -0
- clayspace-0.1.1/clayspace/data/manifest.json +38 -0
- clayspace-0.1.1/clayspace/data/placement-0.1.0.json +17 -0
- clayspace-0.1.1/clayspace/data/world-snapshot-0.1.0.json +39 -0
- clayspace-0.1.1/clayspace/model.py +141 -0
- clayspace-0.1.1/clayspace/placement.py +38 -0
- clayspace-0.1.1/clayspace/preview.py +78 -0
- clayspace-0.1.1/clayspace/resources.py +22 -0
- clayspace-0.1.1/clayspace/world.py +85 -0
- clayspace-0.1.1/clayspace.egg-info/PKG-INFO +152 -0
- clayspace-0.1.1/clayspace.egg-info/SOURCES.txt +24 -0
- clayspace-0.1.1/clayspace.egg-info/dependency_links.txt +1 -0
- clayspace-0.1.1/clayspace.egg-info/entry_points.txt +2 -0
- clayspace-0.1.1/clayspace.egg-info/top_level.txt +1 -0
- clayspace-0.1.1/licenses/FLY_WITH_ME_MIT.txt +21 -0
- clayspace-0.1.1/pyproject.toml +39 -0
- clayspace-0.1.1/setup.cfg +4 -0
- clayspace-0.1.1/tests/test_boundaries.py +71 -0
- clayspace-0.1.1/tests/test_core.py +88 -0
clayspace-0.1.1/LICENSE
ADDED
|
@@ -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.
|
clayspace-0.1.1/PKG-INFO
ADDED
|
@@ -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())
|