open-industries 0.1.0__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.
- open_industries-0.1.0/PKG-INFO +171 -0
- open_industries-0.1.0/README.md +201 -0
- open_industries-0.1.0/pyproject.toml +34 -0
- open_industries-0.1.0/setup.cfg +4 -0
- open_industries-0.1.0/tools/geospatial/LICENSE +21 -0
- open_industries-0.1.0/tools/geospatial/README.md +131 -0
- open_industries-0.1.0/tools/geospatial/export_scene.py +417 -0
- open_industries-0.1.0/tools/geospatial/open_industries.egg-info/PKG-INFO +171 -0
- open_industries-0.1.0/tools/geospatial/open_industries.egg-info/SOURCES.txt +10 -0
- open_industries-0.1.0/tools/geospatial/open_industries.egg-info/dependency_links.txt +1 -0
- open_industries-0.1.0/tools/geospatial/open_industries.egg-info/entry_points.txt +2 -0
- open_industries-0.1.0/tools/geospatial/open_industries.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: open-industries
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static Open Industries scene exporter for georeferenced 3D Tiles
|
|
5
|
+
Author: Mapped Assembly
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Isayah Culbertson
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/Mapped-Assembly/Open-Industries
|
|
29
|
+
Project-URL: Repository, https://github.com/Mapped-Assembly/Open-Industries
|
|
30
|
+
Project-URL: Issues, https://github.com/Mapped-Assembly/Open-Industries/issues
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Requires-Python: >=3.11
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# Static OI scenes → 3D Tiles 1.1
|
|
42
|
+
|
|
43
|
+
A small, independently runnable Python converter and local CesiumJS/Three.js
|
|
44
|
+
interoperability demo for [issue #18](https://github.com/Mapped-Assembly/Open-Industries/issues/18).
|
|
45
|
+
It consumes the geometry already bundled in a portable OI scene. It does not
|
|
46
|
+
tessellate CAD, perform physics, or build a general-purpose streaming hierarchy.
|
|
47
|
+
|
|
48
|
+
## Reproduce
|
|
49
|
+
|
|
50
|
+
Python 3.11+ is sufficient for conversion and contract tests. The separate
|
|
51
|
+
viewer/validator package uses Node 22.18+ and a committed npm lockfile. Root
|
|
52
|
+
application dependencies and provider credentials are unnecessary.
|
|
53
|
+
|
|
54
|
+
From the repository root:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
python tools/geospatial/export_scene.py benchmarks/field-lab/fixtures/field-lab.oi.json tools/geospatial/public/export --longitude=-73.977 --latitude=40.684 --height=30 --heading=0 --license-note="Existing conceptual FIELD-LAB fixture; redistribution permission unresolved"
|
|
58
|
+
python -m unittest discover -s tools/geospatial -p test_export.py -v
|
|
59
|
+
cd tools/geospatial
|
|
60
|
+
npm ci --ignore-scripts
|
|
61
|
+
npx playwright install chromium
|
|
62
|
+
npm run validate
|
|
63
|
+
npm run test:browser
|
|
64
|
+
npm run serve
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Open <http://127.0.0.1:4194/> for CesiumJS or
|
|
68
|
+
<http://127.0.0.1:4194/?viewer=three> for the independent 3D Tiles renderer.
|
|
69
|
+
The server is local; nothing is published or deployed. Both runtimes load the
|
|
70
|
+
same local tileset without Cesium ion, imagery, login, or tokens. Dependencies
|
|
71
|
+
and browsers need network access during installation only.
|
|
72
|
+
|
|
73
|
+
`--ignore-scripts` avoids unused native SQLite build hooks pulled in by the
|
|
74
|
+
validator's archive tooling. This adapter validates directory-based JSON/GLB,
|
|
75
|
+
not SQLite tile archives. The pinned sharp override supplies platform packages
|
|
76
|
+
instead of the old validator dependency's legacy installation hook. Runtime
|
|
77
|
+
dependencies retain their own licenses.
|
|
78
|
+
|
|
79
|
+
## Coordinate contract
|
|
80
|
+
|
|
81
|
+
- Input is `astra.scene` v1, meters, right-handed Y-up. OI rotations use
|
|
82
|
+
Three.js Euler XYZ in degrees.
|
|
83
|
+
- At heading zero, OI +X is east, +Y is up, and -Z is north. The caller explicitly
|
|
84
|
+
supplies WGS84 longitude, latitude, **ellipsoidal** height, and heading.
|
|
85
|
+
Heading rotates clockwise from true north, in [0, 360).
|
|
86
|
+
- GLB positions remain Y-up. A 3D Tiles runtime applies the standard Y-up→Z-up
|
|
87
|
+
rotation `C`, mapping (x,y,z) to (x,-z,y). Child transforms are
|
|
88
|
+
`C × OI_pose × inverse(C)`. The root is WGS84 ENU→ECEF with heading.
|
|
89
|
+
Tile bounds are Z-up; all serialized matrices are column-major.
|
|
90
|
+
The empty root uses a conservative extent-sized geometric error to request
|
|
91
|
+
refinement; exact leaf geometry has zero geometric error.
|
|
92
|
+
- Stored mesh vertices are already normalized and stored instance positions
|
|
93
|
+
already locate those normalized origins. **Do not add originOffset again.**
|
|
94
|
+
The offset is retained as source provenance.
|
|
95
|
+
- The Three.js viewer recenters the ECEF scene into the original local meter
|
|
96
|
+
frame for numerical precision. It loads the identical geographic tileset.
|
|
97
|
+
- Geographic anchors are illustrative. The converter does not fetch terrain,
|
|
98
|
+
infer ground height, geocode a lab, or establish surveyed accuracy.
|
|
99
|
+
|
|
100
|
+
## Data and metadata mapping
|
|
101
|
+
|
|
102
|
+
| Input | Output |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| Indexed part geometry and linear RGB | Shared-asset GLBs with unlit, double-sided materials |
|
|
105
|
+
| Visible instance placement/rotation | One leaf tile per instance; same asset content reused |
|
|
106
|
+
| Asset ID/name and source digest | glTF EXT_mesh_features + EXT_structural_metadata property table |
|
|
107
|
+
| Instance ID/name, asset ID, source kind/digest | 3D Tiles tile metadata; inherited when geometry is picked |
|
|
108
|
+
| Project identity/revision, cloud binding, source normalization offset, part IDs | Allowlisted provenance.json sidecar |
|
|
109
|
+
| Input file byte digest and checkout revision | Export report and provenance; revision is null outside Git |
|
|
110
|
+
| Hidden geometry, animation, hierarchy grouping, BOM/compiler findings, source documents, room decoration | Explicitly excluded; no claim of lossless IR transfer |
|
|
111
|
+
|
|
112
|
+
There is no static feature ID per animated component: all primitives of a shared
|
|
113
|
+
asset refer to its single asset feature. Part identities are preserved in node
|
|
114
|
+
extras and the sidecar. Instance identity belongs to the leaf tile and remains
|
|
115
|
+
distinct when two instances share one GLB. Arbitrary provider settings, raw IR,
|
|
116
|
+
source documents and credentials are not copied; this allowlist is stricter than
|
|
117
|
+
the full OI portable export. It cannot detect secrets pasted into an allowed name.
|
|
118
|
+
|
|
119
|
+
## Validation and evidence
|
|
120
|
+
|
|
121
|
+
The converter writes tileset.json, content-addressed GLBs, provenance.json and
|
|
122
|
+
export-report.json with source/output hashes, Python version, payload size and
|
|
123
|
+
conversion timing. Existing unrelated files in the output directory are not
|
|
124
|
+
deleted; the report and validators enumerate only current referenced content.
|
|
125
|
+
Use a fresh output directory for a distributable package.
|
|
126
|
+
|
|
127
|
+
`npm run validate` records complete diagnostics from 3d-tiles-validator 0.6.1
|
|
128
|
+
and gltf-validator 2.0.0-dev.3.10. The latter reports metadata extensions as
|
|
129
|
+
unsupported informational messages; the 3D Tiles validator additionally checks
|
|
130
|
+
those extensions. Zero core errors alone is not proof that every extension or
|
|
131
|
+
engineering property is valid.
|
|
132
|
+
|
|
133
|
+
`npm run test:browser` starts and closes its own Vite server, verifies placement
|
|
134
|
+
against independent Cesium WGS84/ENU and Three.js Euler implementations within
|
|
135
|
+
1 cm, loads both runtimes, and clicks pipette geometry to check exact identity.
|
|
136
|
+
It saves screenshots, browser/runtime versions, load timing, and memory metrics
|
|
137
|
+
under evidence/. External browser requests are blocked in this test.
|
|
138
|
+
|
|
139
|
+
The workflow runs Python checks on Linux/Windows and viewer/conformance checks
|
|
140
|
+
at two demonstration anchors on Linux. Screenshots, validator diagnostics,
|
|
141
|
+
checksums, geometry, and timing are uploaded as CI artifacts. Load/capture timing
|
|
142
|
+
on a software-rendered browser is not interactive FPS or physical performance.
|
|
143
|
+
|
|
144
|
+
The upstream FIELD-LAB merge has passing [benchmark](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844141),
|
|
145
|
+
[scene](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844134),
|
|
146
|
+
and [runtime](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844184)
|
|
147
|
+
workflows at e88f5ce5f8eeb4eda6a49757d5325699cd815cea.
|
|
148
|
+
Adapter CI is separate and must be checked at the published PR head.
|
|
149
|
+
|
|
150
|
+
## Openness and limits
|
|
151
|
+
|
|
152
|
+
The new source/documents in **this tools/geospatial directory** are MIT licensed.
|
|
153
|
+
That license does not relicense the repository, imported FIELD-LAB or cleanroom
|
|
154
|
+
assets, dependencies, or generated models. `--license-note` records the user's
|
|
155
|
+
evidence or unresolved status; it does not grant permission or certify rights.
|
|
156
|
+
Confirm original asset permissions before redistributing a grant demonstration.
|
|
157
|
+
|
|
158
|
+
For a fully MIT-licensed interchange fixture, use
|
|
159
|
+
`fixtures/open-equipment.oi.json`: two instances of a newly authored asymmetric
|
|
160
|
+
workbench envelope, including a 90-degree rotation and shared geometry. Its
|
|
161
|
+
conceptual boxes do not reuse the existing lab/CAD assets. Substitute that input
|
|
162
|
+
in the converter command and set `--license-note="MIT; tools/geospatial/LICENSE"`.
|
|
163
|
+
The browser smoke test specifically targets the 21-instance FIELD-LAB fixture;
|
|
164
|
+
the minimal fixture can be inspected manually and checked with the validators.
|
|
165
|
+
|
|
166
|
+
This first version exports the **static base layout**, not frame zero of the
|
|
167
|
+
authored animation. No animation, scientific validity, perception, provider
|
|
168
|
+
generation, cold-chain solver, live operation, or physical validation is implied.
|
|
169
|
+
The small FIELD-LAB fixture is not a scalability benchmark. A state/replay
|
|
170
|
+
overlay and resolved asset licensing remain follow-on work; issue #18 should
|
|
171
|
+
stay open until its remaining evidence gates are addressed.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Mergence
|
|
2
|
+
|
|
3
|
+
**Arrange hardware in a 3D space, animate how it moves, and share the layout for review.**
|
|
4
|
+
|
|
5
|
+
Mergence is a spatial workbench that runs locally in your browser for planning fabrication shops, manufacturing spaces, and laboratories. Import equipment from [Form](https://github.com/caid-technologies/Form-OSS) or a STEP CAD file, place it in a room at real-world scale, and preview a workflow before moving physical equipment.
|
|
6
|
+
|
|
7
|
+
[Run locally](#run-locally) · [Try the local demo](#try-the-local-demo) · [Documentation](#documentation)
|
|
8
|
+
|
|
9
|
+
## Why use it?
|
|
10
|
+
|
|
11
|
+
A hardware model describes an individual piece of equipment. Planning a workspace also means deciding where that equipment goes, what sits around it, and how people or materials move through the room.
|
|
12
|
+
|
|
13
|
+
Mergence brings those models into one editable scene. A maker can compare workbench arrangements, a manufacturing team can illustrate material flow, and a lab team can review an equipment layout or sampling route. The output is a room layout, an animation, and files that others can reopen or review.
|
|
14
|
+
|
|
15
|
+
## Try the local demo
|
|
16
|
+
|
|
17
|
+
Follow [Run locally](#run-locally) to install the project and start `npm run dev`, then open the [cleanroom demo](http://127.0.0.1:5173/?scene=cleanroom). It includes four rooms, stainless workbenches, and a Form-authored swab-sampling robot. The animation visits Rooms A and C and skips occupied Room B, showing how equipment, space, and a schedule fit together. The demo opens without replacing your saved workspace.
|
|
18
|
+
|
|
19
|
+
To make your own layout, open the [local workbench](http://127.0.0.1:5173):
|
|
20
|
+
|
|
21
|
+
1. Choose **Space brief / local demo**, select a maker, manufacturing, or biofab space, and click **Build space layout**. This creates a preset layout with labeled equipment placeholders and a material-flow animation.
|
|
22
|
+
2. Or choose **Import project** / **Drop files or browse** to open your own Form JSON, STEP model, or saved Mergence scene.
|
|
23
|
+
3. Adjust the room and equipment, then use **Animate**, **GIF studio**, or **Export scene JSON** to inspect and share the result.
|
|
24
|
+
|
|
25
|
+
You can import, edit, animate, and export locally without signing in. Optional cloud features use GitHub sign-in and a configured Supabase backend; the workbench itself runs on your machine.
|
|
26
|
+
|
|
27
|
+
## What you can do today
|
|
28
|
+
|
|
29
|
+
| Capability | What it does |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| Import equipment | Open Form project JSON and `.step` / `.stp` CAD files, with geometry normalized to meters. |
|
|
32
|
+
| Arrange a room | Set width, depth, and height; move, rotate, rename, duplicate, hide, or remove equipment instances; undo and redo edits. |
|
|
33
|
+
| Inspect a design | View available components, bill of materials (BOM), validation findings, and source/project metadata. |
|
|
34
|
+
| Author motion | Add position and rotation keyframes for equipment or components, then play or scrub the timeline. |
|
|
35
|
+
| Export a visual review | Render GIFs of a room, a floor section, or a selected asset, with companion JSON recording spatial and animation context. |
|
|
36
|
+
| Reuse equipment | Save geometry and previews in a device asset library and add them to another layout. |
|
|
37
|
+
| Save and reopen | Export portable scene JSON, restore local drafts, or save named cloud scenes with revision history, comparison, and restore. |
|
|
38
|
+
| Work with agents locally | Use optional Form/MCP workflows to author equipment, create or revise saved scenes from an external agent, and return animation feedback to Form. |
|
|
39
|
+
|
|
40
|
+
## How Form and Mergence work together
|
|
41
|
+
|
|
42
|
+
### Generate and transfer projects in OpenIndustries
|
|
43
|
+
|
|
44
|
+
- **Generate Forma project → Build with Form** checks the local Forma installation before enabling generation. Choose **Deterministic demo** for a sample without credentials, or **Live generation** with your server-configured provider and model. The resulting equipment is added to the current room, with its design, BOM, and validation data retained.
|
|
45
|
+
- **Export selected Forma project** downloads the selected equipment as `.forma.json`, using the same Hardware IR shape as Forma's JSON export. Referenced STEP/CAD files remain separate; room placement and animation belong to the OI project.
|
|
46
|
+
- **Export OI project** downloads a portable `.oi.json` containing the room, available equipment geometry, instance names/transforms/visibility, retained Forma design data, and animation. **Import OI project** opens it on a fresh browser without an account. Unsaved room changes are protected by the existing save/discard/cancel prompt.
|
|
47
|
+
- Existing `mergence-scene.json` and `astra.scene` v1 files still open. The **Export scene JSON** action uses the same portable OI export. Missing geometry remains marked missing; exporting cannot restore unavailable source files.
|
|
48
|
+
|
|
49
|
+
**Hosted generation:** the current Vercel build serves only the browser app; it does not run the Node/Python generator. It supports both project file workflows. Generation controls explain how to use the local workbench instead of silently disappearing. See [local generation setup](docs/development.md#local-form-generation).
|
|
50
|
+
|
|
51
|
+
**Form authors the equipment; Mergence places and reviews it in a space.** Form OSS provides hardware generation, validation, and compiled project data. Mergence imports that output and adds room layout, instance placement, animation, and visual review.
|
|
52
|
+
|
|
53
|
+
1. **Create or bring equipment.** Author and compile a project in Form, or use an existing STEP file from another CAD tool.
|
|
54
|
+
2. **Import it into Mergence.** Select the Form JSON and any referenced STEP files together. Standalone STEP files also work.
|
|
55
|
+
3. **Arrange and animate.** Place equipment in the room and add keyframes to illustrate its motion or a workflow.
|
|
56
|
+
4. **Save or share.** Export a portable scene, render a GIF plus review metadata, or save a cloud scene.
|
|
57
|
+
5. **Iterate when needed.** In the optional local agent workflow, send animation feedback to Form, review the revised design, and reimport its compiled artifact.
|
|
58
|
+
|
|
59
|
+
An **asset** is reusable imported equipment geometry. An **instance** is one placed copy of that asset. A **scene** combines the room, instances, and animation. Multiple instances can share one asset while keeping independent positions and motion.
|
|
60
|
+
|
|
61
|
+
See the [workspace guide](docs/workspace.md) for the full editing workflow and the [Form handoff contract](docs/form-handoff.md) for supported project formats.
|
|
62
|
+
|
|
63
|
+
## Saving your work
|
|
64
|
+
|
|
65
|
+
| Option | Where it lives | Best for |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| Automatic local draft | The current browser/device | Returning to work after a refresh. This is separate from a cloud save. |
|
|
68
|
+
| Device asset library | The current browser/device | Reusing equipment and its GIF previews across layouts. |
|
|
69
|
+
| Portable scene JSON | A file you export | Reopening or transferring a scene with its geometry and animation, without cloud services. |
|
|
70
|
+
| Cloud scene | Your signed-in Supabase account | Saving named scenes across devices. Enable **Include cloud geometry** when saving if the other device needs the models. |
|
|
71
|
+
| Cloud files | Optional private Supabase Storage | Explicitly uploading geometry bundles, GIF previews, and supported source attachments. |
|
|
72
|
+
|
|
73
|
+
Browser storage is subject to quota and eviction, so export a scene file for a portable copy. Signing in does not automatically upload your imported files. A metadata-only cloud scene may show missing-geometry placeholders on another device until you provide the source assets. See [cloud storage setup](docs/cloud-storage.md) for enabling geometry transfers.
|
|
74
|
+
|
|
75
|
+
## Run locally
|
|
76
|
+
|
|
77
|
+
### Browser workbench
|
|
78
|
+
|
|
79
|
+
Install **Node.js 22** and Git, then run:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/caid-technologies/Form-Industries.git
|
|
83
|
+
cd Form-Industries
|
|
84
|
+
npm ci
|
|
85
|
+
npm run dev
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Open <http://127.0.0.1:5173>. The development command starts both Vite and the local API server. To view the bundled example, open <http://127.0.0.1:5173/?scene=cleanroom>.
|
|
89
|
+
|
|
90
|
+
Python, provider API keys, and Supabase configuration are optional for local imports, room editing, animation, the device library, and exports.
|
|
91
|
+
|
|
92
|
+
To serve a production build locally:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm run build
|
|
96
|
+
npm start
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Open <http://127.0.0.1:8787>.
|
|
100
|
+
|
|
101
|
+
### Optional services
|
|
102
|
+
|
|
103
|
+
Copy [`.env.example`](.env.example) to `.env` when you need configuration. Restart development or rebuild after changing browser variables.
|
|
104
|
+
|
|
105
|
+
| Feature | Setup |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| Local **Build with Form** | Install Python 3.11+ and `requirements-form.txt` into `.venv`. Follow the [local generation guide](docs/development.md#local-form-generation), then try **Deterministic demo** without provider credentials. |
|
|
108
|
+
| Live Form generation | Configure server-side provider credentials in `.env` and select a provider/model in the UI. Live provider calls have not yet been verified in this project. |
|
|
109
|
+
| GitHub sign-in and cloud scenes | Configure Supabase Auth/Postgres and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUBLISHABLE_KEY`. See [Auth/database setup](supabase/README.md). |
|
|
110
|
+
| Private cloud geometry and GIFs | Apply the storage migrations and set `VITE_CLOUD_STORAGE_ENABLED=true`. See the [storage guide](docs/cloud-storage.md). |
|
|
111
|
+
| Disable local generation | Set `VITE_FORM_GENERATION_ENABLED=false` for browser-only deployments. The generation panel explains local setup; import/export stays available. |
|
|
112
|
+
| Local agents and CLI | See [OpenCode/MCP setup](docs/development.md#optional-form-mcp-demo), [scene authoring through MCP](docs/mcp-scenes.md), and the [Mergence CLI](docs/development.md#astra-cli). |
|
|
113
|
+
|
|
114
|
+
Only public Supabase browser configuration belongs in `VITE_` variables. Provider credentials and Supabase service-role keys must stay out of the browser bundle.
|
|
115
|
+
|
|
116
|
+
## Technology and repository map
|
|
117
|
+
|
|
118
|
+
| Part | Technology | Location |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| Browser interface | React 19, TypeScript, Vite | [`src/main.tsx`](src/main.tsx), [`src/components/`](src/components/) |
|
|
121
|
+
| 3D workspace and animation | Three.js | [`src/lib/`](src/lib/), [`src/components/workspace-viewer.tsx`](src/components/workspace-viewer.tsx) |
|
|
122
|
+
| STEP conversion | `occt-import-js` / OpenCascade WebAssembly in a worker | [`src/lib/step.ts`](src/lib/step.ts), [`public/step-worker.js`](public/step-worker.js) |
|
|
123
|
+
| GIF export | `gifenc`, rendered in the browser | [`src/lib/gif.ts`](src/lib/gif.ts) |
|
|
124
|
+
| Cloud accounts and persistence | Supabase Auth, Postgres, optional private Storage | [`supabase/`](supabase/), [`src/lib/scene-repository.ts`](src/lib/scene-repository.ts) |
|
|
125
|
+
| Local Form bridge and agent tools | Node.js/Express, Python `caid-forma-core==0.3.5`, MCP | [`server/`](server/), [`opencode.json`](opencode.json) |
|
|
126
|
+
| Room transfer CLI | Node.js | [`cli/astra.mjs`](cli/astra.mjs) |
|
|
127
|
+
| Verification and examples | Model tests, browser smoke tests, bundled scenes | [`scripts/`](scripts/), [`public/examples/cleanroom/`](public/examples/cleanroom/) |
|
|
128
|
+
|
|
129
|
+
## Current scope and limitations
|
|
130
|
+
|
|
131
|
+
Mergence is a working prototype for spatial planning and visual review. Keep these boundaries in mind when evaluating the demo:
|
|
132
|
+
|
|
133
|
+
- **Animation is visual.** Keyframes illustrate movement; they do not provide physics simulation, collision guarantees, electrical revalidation, or manufacturing approval.
|
|
134
|
+
- **Space briefs use presets and planning envelopes.** These placeholders represent equipment positions and sizes. Replace them with Form-authored projects or CAD for detailed review.
|
|
135
|
+
- **The cleanroom route is illustrative.** Its access schedule is example data, and the mostly fused robot CAD uses whole-robot approach/retract motion to approximate sampling. See the [example notes](public/examples/cleanroom/README.md).
|
|
136
|
+
- **Imports have practical limits.** Equipment imports are capped at 25 MiB per file, 75 MiB per batch, and 2 million vertices per asset. Large assemblies and STEP variants still need broader validation.
|
|
137
|
+
- **Companion CAD must be selected explicitly.** Mergence does not automatically fetch remote CAD URLs or server-local paths. Missing CAD uses labeled envelopes when the project provides them, or reports an error.
|
|
138
|
+
- **Agent integration is optional and local.** The local workbench supports imports and visual review; the Form generation and feedback bridge uses the local API server. Design changes go through an explicit review and reimport loop.
|
|
139
|
+
|
|
140
|
+
## Development checks
|
|
141
|
+
|
|
142
|
+
For the TypeScript build and model/contract checks:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm run build
|
|
146
|
+
npm run test:cad
|
|
147
|
+
npm run test:scene
|
|
148
|
+
npm run test:workspace
|
|
149
|
+
npm run test:mcp
|
|
150
|
+
npm run test:mcp-scenes
|
|
151
|
+
npm run test:scene-history
|
|
152
|
+
npm run test:mcp-agent-workflows
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
For browser smoke tests, run `npm start` in another terminal after building, then run `npm test`, `npm run test:gif`, or `npm run test:fullscreen`. These need Chrome. The main `npm test` suite also needs the Python Form installation and network access for a STEP fixture; the GIF suite does not need those two dependencies. Screenshots go to `test-results/`.
|
|
156
|
+
|
|
157
|
+
Live cloud tests need a dedicated Supabase test project and test-only credentials; follow the [workspace](docs/workspace.md#verification) and [storage](docs/cloud-storage.md#verification) guides.
|
|
158
|
+
|
|
159
|
+
## Publishing packages
|
|
160
|
+
|
|
161
|
+
The repository publishes two distributions from a GitHub Release:
|
|
162
|
+
|
|
163
|
+
- [`mergence`](https://www.npmjs.com/package/mergence) is the npm package for the browser workbench and local CLI.
|
|
164
|
+
- [`open-industries`](https://pypi.org/project/open-industries/) is the Python package for the static geospatial scene exporter. Its CLI is `oi-export-scene`.
|
|
165
|
+
|
|
166
|
+
The `Publish packages` workflow validates both builds before publishing. Add a current npm automation token as the repository `NPM_TOKEN` secret, and configure a PyPI trusted publisher for the `pypi` environment, before creating the first release. Package versions are taken from `package.json` and `pyproject.toml`; update both together, then publish a GitHub Release for that version.
|
|
167
|
+
|
|
168
|
+
## Documentation
|
|
169
|
+
|
|
170
|
+
- [Workspace guide](docs/workspace.md) — editing, animation, persistence, missing-geometry recovery, and the inspector.
|
|
171
|
+
- [Geospatial interchange](tools/geospatial/README.md) — static OI scenes to 3D Tiles 1.1, CesiumJS and an independent Three.js viewer, with geographic placement and provenance checks.
|
|
172
|
+
- [Form handoff contract](docs/form-handoff.md) — compiled artifacts, supported formats, provenance, and CAD resolution.
|
|
173
|
+
- [Revision history and restore](docs/scene-history.md) — compare saved versions, restore a new head, and preserve exact geometry bindings.
|
|
174
|
+
- [Cross-agent walkthrough](docs/mcp-agent-workflows.md) — deterministic Grok/ChatGPT/Codex fixtures, local agent setup, and create/update/reopen commands.
|
|
175
|
+
- [External-agent scene authoring](docs/mcp-scenes.md) — MCP setup, typed tools, revision URLs, and the deterministic cleanroom fixture.
|
|
176
|
+
- [Authoritative city-dump runtime](docs/mcp-game-runtime.md) — authenticated two-player matches, private material inspection, durable processing and database scheduler setup.
|
|
177
|
+
- [Development and integration guide](docs/development.md) — local generation, MCP, CLI, import contracts, and GIF details.
|
|
178
|
+
- [Scene links](docs/scene-links.md) — private scene/revision URLs, expiring shared links, and deployment steps.
|
|
179
|
+
- [Cloud storage guide](docs/cloud-storage.md) — private asset transfers, setup, limits, and lifecycle.
|
|
180
|
+
- [Supabase Auth and database setup](supabase/README.md) — schema, GitHub login, migrations, and ownership checks.
|
|
181
|
+
- [Cleanroom example](public/examples/cleanroom/README.md) — scene behavior, source models, and CAD licensing.
|
|
182
|
+
|
|
183
|
+
## Third-party software
|
|
184
|
+
|
|
185
|
+
Form OSS / Form Core is used under **MPL-2.0**; preserve applicable notices and obligations when reusing upstream code. STEP conversion uses `occt-import-js` and its OpenCascade/WebAssembly distribution; retain their bundled license notices. GIF encoding uses `gifenc` (MIT), and the test decoder is `omggif` (MIT).
|
|
186
|
+
|
|
187
|
+
The cleanroom robot and workbench source projects declare their mechanical CAD under **CERN-OHL-S-2.0**; see the [example provenance notes](public/examples/cleanroom/README.md). Upstream STEP test geometry is fetched by the smoke test rather than bundled in this repository.
|
|
188
|
+
|
|
189
|
+
## Citation
|
|
190
|
+
|
|
191
|
+
If you use OpenIndustries / Mergence in research, publications, or other academic work, please cite the project:
|
|
192
|
+
|
|
193
|
+
```bibtex
|
|
194
|
+
@software{mapped_assembly_open_industries_2026,
|
|
195
|
+
author = {{Mapped Assembly}},
|
|
196
|
+
title = {OpenIndustries (Mergence): Spatial workbench for hardware and manufacturing-system planning},
|
|
197
|
+
year = {2026},
|
|
198
|
+
url = {https://github.com/Mapped-Assembly/Open-Industries},
|
|
199
|
+
note = {Open-source software}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "open-industries"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Static Open Industries scene exporter for georeferenced 3D Tiles"
|
|
9
|
+
readme = {file = "tools/geospatial/README.md", content-type = "text/markdown"}
|
|
10
|
+
license = {file = "tools/geospatial/LICENSE"}
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{name = "Mapped Assembly"}]
|
|
13
|
+
dependencies = []
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/Mapped-Assembly/Open-Industries"
|
|
26
|
+
Repository = "https://github.com/Mapped-Assembly/Open-Industries"
|
|
27
|
+
Issues = "https://github.com/Mapped-Assembly/Open-Industries/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
oi-export-scene = "export_scene:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
package-dir = {"" = "tools/geospatial"}
|
|
34
|
+
py-modules = ["export_scene"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isayah Culbertson
|
|
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,131 @@
|
|
|
1
|
+
# Static OI scenes → 3D Tiles 1.1
|
|
2
|
+
|
|
3
|
+
A small, independently runnable Python converter and local CesiumJS/Three.js
|
|
4
|
+
interoperability demo for [issue #18](https://github.com/Mapped-Assembly/Open-Industries/issues/18).
|
|
5
|
+
It consumes the geometry already bundled in a portable OI scene. It does not
|
|
6
|
+
tessellate CAD, perform physics, or build a general-purpose streaming hierarchy.
|
|
7
|
+
|
|
8
|
+
## Reproduce
|
|
9
|
+
|
|
10
|
+
Python 3.11+ is sufficient for conversion and contract tests. The separate
|
|
11
|
+
viewer/validator package uses Node 22.18+ and a committed npm lockfile. Root
|
|
12
|
+
application dependencies and provider credentials are unnecessary.
|
|
13
|
+
|
|
14
|
+
From the repository root:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
python tools/geospatial/export_scene.py benchmarks/field-lab/fixtures/field-lab.oi.json tools/geospatial/public/export --longitude=-73.977 --latitude=40.684 --height=30 --heading=0 --license-note="Existing conceptual FIELD-LAB fixture; redistribution permission unresolved"
|
|
18
|
+
python -m unittest discover -s tools/geospatial -p test_export.py -v
|
|
19
|
+
cd tools/geospatial
|
|
20
|
+
npm ci --ignore-scripts
|
|
21
|
+
npx playwright install chromium
|
|
22
|
+
npm run validate
|
|
23
|
+
npm run test:browser
|
|
24
|
+
npm run serve
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open <http://127.0.0.1:4194/> for CesiumJS or
|
|
28
|
+
<http://127.0.0.1:4194/?viewer=three> for the independent 3D Tiles renderer.
|
|
29
|
+
The server is local; nothing is published or deployed. Both runtimes load the
|
|
30
|
+
same local tileset without Cesium ion, imagery, login, or tokens. Dependencies
|
|
31
|
+
and browsers need network access during installation only.
|
|
32
|
+
|
|
33
|
+
`--ignore-scripts` avoids unused native SQLite build hooks pulled in by the
|
|
34
|
+
validator's archive tooling. This adapter validates directory-based JSON/GLB,
|
|
35
|
+
not SQLite tile archives. The pinned sharp override supplies platform packages
|
|
36
|
+
instead of the old validator dependency's legacy installation hook. Runtime
|
|
37
|
+
dependencies retain their own licenses.
|
|
38
|
+
|
|
39
|
+
## Coordinate contract
|
|
40
|
+
|
|
41
|
+
- Input is `astra.scene` v1, meters, right-handed Y-up. OI rotations use
|
|
42
|
+
Three.js Euler XYZ in degrees.
|
|
43
|
+
- At heading zero, OI +X is east, +Y is up, and -Z is north. The caller explicitly
|
|
44
|
+
supplies WGS84 longitude, latitude, **ellipsoidal** height, and heading.
|
|
45
|
+
Heading rotates clockwise from true north, in [0, 360).
|
|
46
|
+
- GLB positions remain Y-up. A 3D Tiles runtime applies the standard Y-up→Z-up
|
|
47
|
+
rotation `C`, mapping (x,y,z) to (x,-z,y). Child transforms are
|
|
48
|
+
`C × OI_pose × inverse(C)`. The root is WGS84 ENU→ECEF with heading.
|
|
49
|
+
Tile bounds are Z-up; all serialized matrices are column-major.
|
|
50
|
+
The empty root uses a conservative extent-sized geometric error to request
|
|
51
|
+
refinement; exact leaf geometry has zero geometric error.
|
|
52
|
+
- Stored mesh vertices are already normalized and stored instance positions
|
|
53
|
+
already locate those normalized origins. **Do not add originOffset again.**
|
|
54
|
+
The offset is retained as source provenance.
|
|
55
|
+
- The Three.js viewer recenters the ECEF scene into the original local meter
|
|
56
|
+
frame for numerical precision. It loads the identical geographic tileset.
|
|
57
|
+
- Geographic anchors are illustrative. The converter does not fetch terrain,
|
|
58
|
+
infer ground height, geocode a lab, or establish surveyed accuracy.
|
|
59
|
+
|
|
60
|
+
## Data and metadata mapping
|
|
61
|
+
|
|
62
|
+
| Input | Output |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| Indexed part geometry and linear RGB | Shared-asset GLBs with unlit, double-sided materials |
|
|
65
|
+
| Visible instance placement/rotation | One leaf tile per instance; same asset content reused |
|
|
66
|
+
| Asset ID/name and source digest | glTF EXT_mesh_features + EXT_structural_metadata property table |
|
|
67
|
+
| Instance ID/name, asset ID, source kind/digest | 3D Tiles tile metadata; inherited when geometry is picked |
|
|
68
|
+
| Project identity/revision, cloud binding, source normalization offset, part IDs | Allowlisted provenance.json sidecar |
|
|
69
|
+
| Input file byte digest and checkout revision | Export report and provenance; revision is null outside Git |
|
|
70
|
+
| Hidden geometry, animation, hierarchy grouping, BOM/compiler findings, source documents, room decoration | Explicitly excluded; no claim of lossless IR transfer |
|
|
71
|
+
|
|
72
|
+
There is no static feature ID per animated component: all primitives of a shared
|
|
73
|
+
asset refer to its single asset feature. Part identities are preserved in node
|
|
74
|
+
extras and the sidecar. Instance identity belongs to the leaf tile and remains
|
|
75
|
+
distinct when two instances share one GLB. Arbitrary provider settings, raw IR,
|
|
76
|
+
source documents and credentials are not copied; this allowlist is stricter than
|
|
77
|
+
the full OI portable export. It cannot detect secrets pasted into an allowed name.
|
|
78
|
+
|
|
79
|
+
## Validation and evidence
|
|
80
|
+
|
|
81
|
+
The converter writes tileset.json, content-addressed GLBs, provenance.json and
|
|
82
|
+
export-report.json with source/output hashes, Python version, payload size and
|
|
83
|
+
conversion timing. Existing unrelated files in the output directory are not
|
|
84
|
+
deleted; the report and validators enumerate only current referenced content.
|
|
85
|
+
Use a fresh output directory for a distributable package.
|
|
86
|
+
|
|
87
|
+
`npm run validate` records complete diagnostics from 3d-tiles-validator 0.6.1
|
|
88
|
+
and gltf-validator 2.0.0-dev.3.10. The latter reports metadata extensions as
|
|
89
|
+
unsupported informational messages; the 3D Tiles validator additionally checks
|
|
90
|
+
those extensions. Zero core errors alone is not proof that every extension or
|
|
91
|
+
engineering property is valid.
|
|
92
|
+
|
|
93
|
+
`npm run test:browser` starts and closes its own Vite server, verifies placement
|
|
94
|
+
against independent Cesium WGS84/ENU and Three.js Euler implementations within
|
|
95
|
+
1 cm, loads both runtimes, and clicks pipette geometry to check exact identity.
|
|
96
|
+
It saves screenshots, browser/runtime versions, load timing, and memory metrics
|
|
97
|
+
under evidence/. External browser requests are blocked in this test.
|
|
98
|
+
|
|
99
|
+
The workflow runs Python checks on Linux/Windows and viewer/conformance checks
|
|
100
|
+
at two demonstration anchors on Linux. Screenshots, validator diagnostics,
|
|
101
|
+
checksums, geometry, and timing are uploaded as CI artifacts. Load/capture timing
|
|
102
|
+
on a software-rendered browser is not interactive FPS or physical performance.
|
|
103
|
+
|
|
104
|
+
The upstream FIELD-LAB merge has passing [benchmark](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844141),
|
|
105
|
+
[scene](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844134),
|
|
106
|
+
and [runtime](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844184)
|
|
107
|
+
workflows at e88f5ce5f8eeb4eda6a49757d5325699cd815cea.
|
|
108
|
+
Adapter CI is separate and must be checked at the published PR head.
|
|
109
|
+
|
|
110
|
+
## Openness and limits
|
|
111
|
+
|
|
112
|
+
The new source/documents in **this tools/geospatial directory** are MIT licensed.
|
|
113
|
+
That license does not relicense the repository, imported FIELD-LAB or cleanroom
|
|
114
|
+
assets, dependencies, or generated models. `--license-note` records the user's
|
|
115
|
+
evidence or unresolved status; it does not grant permission or certify rights.
|
|
116
|
+
Confirm original asset permissions before redistributing a grant demonstration.
|
|
117
|
+
|
|
118
|
+
For a fully MIT-licensed interchange fixture, use
|
|
119
|
+
`fixtures/open-equipment.oi.json`: two instances of a newly authored asymmetric
|
|
120
|
+
workbench envelope, including a 90-degree rotation and shared geometry. Its
|
|
121
|
+
conceptual boxes do not reuse the existing lab/CAD assets. Substitute that input
|
|
122
|
+
in the converter command and set `--license-note="MIT; tools/geospatial/LICENSE"`.
|
|
123
|
+
The browser smoke test specifically targets the 21-instance FIELD-LAB fixture;
|
|
124
|
+
the minimal fixture can be inspected manually and checked with the validators.
|
|
125
|
+
|
|
126
|
+
This first version exports the **static base layout**, not frame zero of the
|
|
127
|
+
authored animation. No animation, scientific validity, perception, provider
|
|
128
|
+
generation, cold-chain solver, live operation, or physical validation is implied.
|
|
129
|
+
The small FIELD-LAB fixture is not a scalability benchmark. A state/replay
|
|
130
|
+
overlay and resolved asset licensing remain follow-on work; issue #18 should
|
|
131
|
+
stay open until its remaining evidence gates are addressed.
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Export bounded, static OI scenes as georeferenced 3D Tiles 1.1.
|
|
2
|
+
|
|
3
|
+
Only Python's standard library is required. This is a small indexed-mesh GLB
|
|
4
|
+
adapter, not a CAD tessellator or scalable tiling engine. Existing OI geometry
|
|
5
|
+
is already normalized; originOffset is provenance, not a second translation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import struct
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
Json = dict[str, Any]
|
|
23
|
+
Vec3 = tuple[float, float, float]
|
|
24
|
+
Matrix = list[list[float]]
|
|
25
|
+
MAX_BYTES = 75 * 1024 * 1024
|
|
26
|
+
C: Matrix = [[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]]
|
|
27
|
+
CI: Matrix = [[1, 0, 0, 0], [0, 0, 1, 0], [0, -1, 0, 0], [0, 0, 0, 1]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def identity() -> Matrix:
|
|
31
|
+
"""Return a row-major identity matrix for internal computation."""
|
|
32
|
+
return [[float(i == j) for j in range(4)] for i in range(4)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def multiply(a: Matrix, b: Matrix) -> Matrix:
|
|
36
|
+
"""Multiply row-major matrices; serialization is column-major."""
|
|
37
|
+
return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def transform(matrix: Matrix, point: Vec3) -> Vec3:
|
|
41
|
+
"""Transform a point with an affine matrix."""
|
|
42
|
+
return tuple(sum(matrix[i][j] * point[j] for j in range(3)) + matrix[i][3] for i in range(3))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def columns(matrix: Matrix) -> list[float]:
|
|
46
|
+
"""Serialize an internal matrix using the glTF/3D Tiles column convention."""
|
|
47
|
+
return [matrix[i][j] for j in range(4) for i in range(4)]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pose_matrix(position: Vec3, rotation: Vec3) -> Matrix:
|
|
51
|
+
"""Match Three.js Euler XYZ in degrees: translation times Rx times Ry times Rz."""
|
|
52
|
+
x, y, z = (math.radians(v) for v in rotation)
|
|
53
|
+
rx = [[1, 0, 0, 0], [0, math.cos(x), -math.sin(x), 0], [0, math.sin(x), math.cos(x), 0], [0, 0, 0, 1]]
|
|
54
|
+
ry = [[math.cos(y), 0, math.sin(y), 0], [0, 1, 0, 0], [-math.sin(y), 0, math.cos(y), 0], [0, 0, 0, 1]]
|
|
55
|
+
rz = [[math.cos(z), -math.sin(z), 0, 0], [math.sin(z), math.cos(z), 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
|
|
56
|
+
result = multiply(multiply(rx, ry), rz)
|
|
57
|
+
for i in range(3):
|
|
58
|
+
result[i][3] = position[i]
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def finite(value: Any, label: str) -> float:
|
|
63
|
+
"""Require a finite numeric value without accepting booleans."""
|
|
64
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
65
|
+
raise ValueError(f"{label} must be a finite number")
|
|
66
|
+
return float(value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def vector(value: Any, label: str) -> Vec3:
|
|
70
|
+
"""Validate a bounded three-component vector."""
|
|
71
|
+
if not isinstance(value, list) or len(value) != 3:
|
|
72
|
+
raise ValueError(f"{label} must have three components")
|
|
73
|
+
result = tuple(finite(v, label) for v in value)
|
|
74
|
+
if any(abs(v) > 1e6 for v in result):
|
|
75
|
+
raise ValueError(f"{label} exceeds the supported local coordinate range")
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def text_id(value: Any, label: str) -> str:
|
|
80
|
+
"""Require an identifier or name of bounded length."""
|
|
81
|
+
if not isinstance(value, str) or not 0 < len(value) <= 512:
|
|
82
|
+
raise ValueError(f"{label} must be a nonempty string of at most 512 characters")
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Anchor:
|
|
88
|
+
"""WGS84 demonstration origin; heading is clockwise from true north."""
|
|
89
|
+
|
|
90
|
+
longitude: float
|
|
91
|
+
latitude: float
|
|
92
|
+
height: float
|
|
93
|
+
heading: float
|
|
94
|
+
|
|
95
|
+
def __post_init__(self) -> None:
|
|
96
|
+
"""Reject invalid geographic anchors before generating artifacts."""
|
|
97
|
+
for key in ("longitude", "latitude", "height", "heading"):
|
|
98
|
+
finite(getattr(self, key), key)
|
|
99
|
+
if not -180 <= self.longitude <= 180 or not -90 <= self.latitude <= 90:
|
|
100
|
+
raise ValueError("longitude/latitude must be within WGS84 geographic bounds")
|
|
101
|
+
if not -1000 <= self.height <= 100000 or not 0 <= self.heading < 360:
|
|
102
|
+
raise ValueError("height must be -1000..100000 m and heading 0..<360 degrees")
|
|
103
|
+
|
|
104
|
+
def matrix(self) -> Matrix:
|
|
105
|
+
"""Map heading-adjusted ENU to ECEF using the WGS84 ellipsoid."""
|
|
106
|
+
lon, lat, heading = map(math.radians, (self.longitude, self.latitude, self.heading))
|
|
107
|
+
a, e2 = 6378137.0, 6.6943799901413165e-3
|
|
108
|
+
n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
|
|
109
|
+
origin = ((n + self.height) * math.cos(lat) * math.cos(lon),
|
|
110
|
+
(n + self.height) * math.cos(lat) * math.sin(lon),
|
|
111
|
+
(n * (1 - e2) + self.height) * math.sin(lat))
|
|
112
|
+
east = (-math.sin(lon), math.cos(lon), 0.0)
|
|
113
|
+
north = (-math.sin(lat) * math.cos(lon), -math.sin(lat) * math.sin(lon), math.cos(lat))
|
|
114
|
+
up = (math.cos(lat) * math.cos(lon), math.cos(lat) * math.sin(lon), math.sin(lat))
|
|
115
|
+
frame = identity()
|
|
116
|
+
for i in range(3):
|
|
117
|
+
frame[i][:3] = [east[i], north[i], up[i]]
|
|
118
|
+
frame[i][3] = origin[i]
|
|
119
|
+
rotation = identity()
|
|
120
|
+
rotation[0][:2] = [math.cos(heading), math.sin(heading)]
|
|
121
|
+
rotation[1][:2] = [-math.sin(heading), math.cos(heading)]
|
|
122
|
+
return multiply(frame, rotation)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def write_json(path: Path, value: Any) -> None:
|
|
126
|
+
"""Write deterministic UTF-8 JSON with no nonstandard numeric values."""
|
|
127
|
+
path.write_text(json.dumps(value, indent=2, ensure_ascii=False, allow_nan=False) + "\n", encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def digest(data: bytes) -> str:
|
|
131
|
+
"""Compute byte identity for input and output evidence."""
|
|
132
|
+
return hashlib.sha256(data).hexdigest()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def bounds(points: list[Vec3]) -> list[float]:
|
|
136
|
+
"""Create a 3D Tiles axis-aligned box from already Z-up points."""
|
|
137
|
+
if not points:
|
|
138
|
+
raise ValueError("No visible geometry to export")
|
|
139
|
+
lo = [min(p[i] for p in points) for i in range(3)]
|
|
140
|
+
hi = [max(p[i] for p in points) for i in range(3)]
|
|
141
|
+
half = [max((hi[i] - lo[i]) / 2, 1e-7) for i in range(3)]
|
|
142
|
+
return [(hi[i] + lo[i]) / 2 for i in range(3)] + [half[0], 0, 0, 0, half[1], 0, 0, 0, half[2]]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def asset_points(asset: Json) -> list[Vec3]:
|
|
146
|
+
"""Return normalized Y-up points without reapplying source originOffset."""
|
|
147
|
+
return [tuple(part["vertices"][i:i + 3]) for part in asset["parts"] for i in range(0, len(part["vertices"]), 3)]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def validate_scene(scene: Json) -> tuple[dict[str, Json], dict[str, Json]]:
|
|
151
|
+
"""Validate the supported static scene and exact source/version bindings.
|
|
152
|
+
|
|
153
|
+
Animation is reported as excluded, never sampled or presented as physics.
|
|
154
|
+
Missing geometry is rejected rather than replaced with an invented envelope.
|
|
155
|
+
"""
|
|
156
|
+
if scene.get("format") != "astra.scene" or scene.get("version") != 1 or scene.get("units") != "m" or scene.get("upAxis") != "Y":
|
|
157
|
+
raise ValueError("Only astra.scene v1 with meters/Y-up is supported")
|
|
158
|
+
refs: dict[str, Json] = {}
|
|
159
|
+
assets: dict[str, Json] = {}
|
|
160
|
+
versions: dict[str, Json] = {}
|
|
161
|
+
for ref in scene.get("assets", []):
|
|
162
|
+
key = text_id(ref.get("id"), "asset.id")
|
|
163
|
+
if key in refs:
|
|
164
|
+
raise ValueError("Duplicate asset reference")
|
|
165
|
+
refs[key] = ref
|
|
166
|
+
for asset in scene.get("bundledAssets", []):
|
|
167
|
+
key = text_id(asset.get("id"), "bundled asset.id")
|
|
168
|
+
if key in assets:
|
|
169
|
+
raise ValueError("Duplicate bundled asset")
|
|
170
|
+
assets[key] = asset
|
|
171
|
+
for entry in scene.get("bundledVersions", []):
|
|
172
|
+
key = text_id(entry.get("cloudVersionId"), "cloudVersionId")
|
|
173
|
+
if key in versions:
|
|
174
|
+
raise ValueError("Duplicate cloud version")
|
|
175
|
+
versions[key] = entry["asset"]
|
|
176
|
+
instances = scene.get("instances")
|
|
177
|
+
if not isinstance(instances, list) or not 1 <= len(instances) <= 1000:
|
|
178
|
+
raise ValueError("A scene must have 1..1000 instances")
|
|
179
|
+
ids: set[str] = set()
|
|
180
|
+
bound: dict[str, Json] = {}
|
|
181
|
+
for instance in instances:
|
|
182
|
+
key = text_id(instance.get("id"), "instance.id")
|
|
183
|
+
text_id(instance.get("name"), "instance.name")
|
|
184
|
+
if key in ids:
|
|
185
|
+
raise ValueError("Duplicate instance ID")
|
|
186
|
+
ids.add(key)
|
|
187
|
+
vector(instance.get("position"), "position")
|
|
188
|
+
vector(instance.get("rotation"), "rotation")
|
|
189
|
+
if not isinstance(instance.get("visible"), bool):
|
|
190
|
+
raise ValueError("visible must be boolean")
|
|
191
|
+
asset_id = instance.get("assetId")
|
|
192
|
+
asset = versions.get(instance["cloudVersionId"]) if "cloudVersionId" in instance else assets.get(asset_id)
|
|
193
|
+
ref = refs.get(asset_id)
|
|
194
|
+
if not asset or not ref or asset.get("id") != asset_id:
|
|
195
|
+
raise ValueError(f"Missing exact geometry binding for {key}")
|
|
196
|
+
text_id(asset.get("name"), "asset.name")
|
|
197
|
+
if asset.get("units") != "m" or asset.get("upAxis") != "Y" or asset.get("schemaVersion") != 1:
|
|
198
|
+
raise ValueError("Asset must use schemaVersion 1, meters/Y-up")
|
|
199
|
+
source = asset.get("source", {})
|
|
200
|
+
if source.get("kind") not in ("form", "step", "generated"):
|
|
201
|
+
raise ValueError("Invalid source kind")
|
|
202
|
+
text_id(source.get("filename"), "source filename")
|
|
203
|
+
text_id(source.get("digest"), "source digest")
|
|
204
|
+
for field in ("kind", "digest", "version", "projectId"):
|
|
205
|
+
if source.get(field) != ref.get("source", {}).get(field):
|
|
206
|
+
raise ValueError(f"Source revision mismatch: {key}/{field}")
|
|
207
|
+
project = asset.get("formProject") or {}
|
|
208
|
+
if ref.get("projectRevision") is not None and ref["projectRevision"] != project.get("revision"):
|
|
209
|
+
raise ValueError("Source project revision mismatch")
|
|
210
|
+
if source.get("projectId") is not None and project.get("projectId") not in (None, source["projectId"]):
|
|
211
|
+
raise ValueError("Source project ID mismatch")
|
|
212
|
+
vector(asset.get("originOffset"), "originOffset")
|
|
213
|
+
parts = asset.get("parts")
|
|
214
|
+
if not isinstance(parts, list) or not parts:
|
|
215
|
+
raise ValueError("Asset has no mesh parts")
|
|
216
|
+
part_ids: set[str] = set()
|
|
217
|
+
total = 0
|
|
218
|
+
for part in parts:
|
|
219
|
+
part_id = text_id(part.get("id"), "part.id")
|
|
220
|
+
text_id(part.get("name"), "part.name")
|
|
221
|
+
if part_id in part_ids:
|
|
222
|
+
raise ValueError("Duplicate part ID")
|
|
223
|
+
part_ids.add(part_id)
|
|
224
|
+
vertices, indices = part.get("vertices"), part.get("indices")
|
|
225
|
+
if not isinstance(vertices, list) or len(vertices) < 9 or len(vertices) % 3:
|
|
226
|
+
raise ValueError("Invalid mesh vertices")
|
|
227
|
+
for v in vertices:
|
|
228
|
+
if abs(finite(v, "vertex")) > 1e6:
|
|
229
|
+
raise ValueError("Vertex exceeds local range")
|
|
230
|
+
total += len(vertices) // 3
|
|
231
|
+
if total > 2_000_000:
|
|
232
|
+
raise ValueError("Asset exceeds 2 million vertices")
|
|
233
|
+
if not isinstance(indices, list) or not indices or len(indices) % 3 or any(type(i) is not int or not 0 <= i < len(vertices) // 3 for i in indices):
|
|
234
|
+
raise ValueError("Invalid triangle indices")
|
|
235
|
+
color = vector(part.get("color", [0.7, 0.8, 0.75]), "part color")
|
|
236
|
+
if any(not 0 <= c <= 1 for c in color):
|
|
237
|
+
raise ValueError("Color must be linear RGB within 0..1")
|
|
238
|
+
bound[key] = asset
|
|
239
|
+
return bound, refs
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class Glb:
|
|
243
|
+
"""Encode existing indexed OI meshes and bounded asset feature metadata."""
|
|
244
|
+
|
|
245
|
+
def __init__(self) -> None:
|
|
246
|
+
"""Initialize aligned binary storage and a glTF 2.0 document."""
|
|
247
|
+
self.binary = bytearray()
|
|
248
|
+
self.doc: Json = {"asset": {"version": "2.0", "generator": "OI static geospatial adapter v1"},
|
|
249
|
+
"scene": 0, "scenes": [{"nodes": []}], "nodes": [], "meshes": [],
|
|
250
|
+
"materials": [], "accessors": [], "bufferViews": [],
|
|
251
|
+
"extensionsUsed": ["KHR_materials_unlit", "EXT_mesh_features", "EXT_structural_metadata"]}
|
|
252
|
+
|
|
253
|
+
def view(self, data: bytes, target: int | None = None) -> int:
|
|
254
|
+
"""Append one four-byte-aligned glTF buffer view."""
|
|
255
|
+
self.binary.extend(b"\0" * ((-len(self.binary)) % 4))
|
|
256
|
+
item: Json = {"buffer": 0, "byteOffset": len(self.binary), "byteLength": len(data)}
|
|
257
|
+
if target is not None:
|
|
258
|
+
item["target"] = target
|
|
259
|
+
self.binary.extend(data)
|
|
260
|
+
self.doc["bufferViews"].append(item)
|
|
261
|
+
return len(self.doc["bufferViews"]) - 1
|
|
262
|
+
|
|
263
|
+
def accessor(self, values: list[Any], component: int, kind: str, target: int) -> int:
|
|
264
|
+
"""Encode validated positions or triangle indices as a glTF accessor."""
|
|
265
|
+
data = struct.pack("<" + ("f" if component == 5126 else "I") * len(values), *values)
|
|
266
|
+
count = len(values) // (3 if kind == "VEC3" else 1)
|
|
267
|
+
item: Json = {"bufferView": self.view(data, target), "componentType": component, "count": count, "type": kind}
|
|
268
|
+
if kind == "VEC3":
|
|
269
|
+
item["min"] = [min(values[i::3]) for i in range(3)]
|
|
270
|
+
item["max"] = [max(values[i::3]) for i in range(3)]
|
|
271
|
+
self.doc["accessors"].append(item)
|
|
272
|
+
return len(self.doc["accessors"]) - 1
|
|
273
|
+
|
|
274
|
+
def encode(self, asset: Json) -> bytes:
|
|
275
|
+
"""Serialize a shared-asset GLB with explicit feature ID zero per vertex."""
|
|
276
|
+
properties: Json = {}
|
|
277
|
+
feature = {"assetId": asset["id"], "assetName": asset["name"], "sourceDigest": asset["source"]["digest"]}
|
|
278
|
+
for key, value in feature.items():
|
|
279
|
+
raw = value.encode("utf-8")
|
|
280
|
+
properties[key] = {"values": self.view(raw), "stringOffsets": self.view(struct.pack("<II", 0, len(raw))), "stringOffsetType": "UINT32"}
|
|
281
|
+
self.doc["extensions"] = {"EXT_structural_metadata": {
|
|
282
|
+
"schema": {"id": "oi_asset_v1", "classes": {"equipment": {"properties": {key: {"type": "STRING"} for key in feature}}}},
|
|
283
|
+
"propertyTables": [{"class": "equipment", "count": 1, "properties": properties}]}}
|
|
284
|
+
for part in asset["parts"]:
|
|
285
|
+
material = len(self.doc["materials"])
|
|
286
|
+
self.doc["materials"].append({"pbrMetallicRoughness": {"baseColorFactor": part.get("color", [0.7, 0.8, 0.75]) + [1]},
|
|
287
|
+
"doubleSided": True, "extensions": {"KHR_materials_unlit": {}}})
|
|
288
|
+
primitive = {"attributes": {"POSITION": self.accessor(part["vertices"], 5126, "VEC3", 34962),
|
|
289
|
+
"_FEATURE_ID_0": self.accessor([0.0] * (len(part["vertices"]) // 3), 5126, "SCALAR", 34962)},
|
|
290
|
+
"indices": self.accessor(part["indices"], 5125, "SCALAR", 34963), "material": material,
|
|
291
|
+
"extensions": {"EXT_mesh_features": {"featureIds": [{"featureCount": 1, "attribute": 0, "propertyTable": 0}]}}}
|
|
292
|
+
mesh = len(self.doc["meshes"])
|
|
293
|
+
self.doc["meshes"].append({"name": part["name"], "primitives": [primitive]})
|
|
294
|
+
self.doc["scenes"][0]["nodes"].append(len(self.doc["nodes"]))
|
|
295
|
+
self.doc["nodes"].append({"mesh": mesh, "name": part["name"], "extras": {"partId": part["id"]}})
|
|
296
|
+
self.doc["buffers"] = [{"byteLength": len(self.binary)}]
|
|
297
|
+
raw_json = json.dumps(self.doc, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
298
|
+
raw_json += b" " * ((-len(raw_json)) % 4)
|
|
299
|
+
raw_bin = bytes(self.binary) + b"\0" * ((-len(self.binary)) % 4)
|
|
300
|
+
length = 12 + 8 + len(raw_json) + 8 + len(raw_bin)
|
|
301
|
+
return struct.pack("<III", 0x46546C67, 2, length) + struct.pack("<II", len(raw_json), 0x4E4F534A) + raw_json + struct.pack("<II", len(raw_bin), 0x004E4942) + raw_bin
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def source_revision(path: Path) -> str | None:
|
|
305
|
+
"""Return the checkout revision if available; never invent an attribution."""
|
|
306
|
+
try:
|
|
307
|
+
return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=path.parent, text=True, stderr=subprocess.DEVNULL).strip()
|
|
308
|
+
except (OSError, subprocess.CalledProcessError):
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def export_scene(source_path: Path, output: Path, anchor: Anchor, license_note: str) -> Json:
|
|
313
|
+
"""Export validated static geometry and an allowlisted provenance sidecar.
|
|
314
|
+
|
|
315
|
+
Only selected source fields are copied; arbitrary Form source documents,
|
|
316
|
+
provider configuration and credentials are never copied to outputs.
|
|
317
|
+
"""
|
|
318
|
+
started = time.perf_counter()
|
|
319
|
+
raw = source_path.read_bytes()
|
|
320
|
+
if len(raw) > MAX_BYTES:
|
|
321
|
+
raise ValueError("Scene exceeds the 75 MiB supported input limit")
|
|
322
|
+
scene = json.loads(raw)
|
|
323
|
+
bound, refs = validate_scene(scene)
|
|
324
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
geometry = output / "geometry"
|
|
326
|
+
geometry.mkdir(exist_ok=True)
|
|
327
|
+
emitted: dict[str, str] = {}
|
|
328
|
+
children: list[Json] = []
|
|
329
|
+
instances: list[Json] = []
|
|
330
|
+
all_points: list[Vec3] = []
|
|
331
|
+
hidden: list[str] = []
|
|
332
|
+
for instance in scene["instances"]:
|
|
333
|
+
if not instance["visible"]:
|
|
334
|
+
hidden.append(instance["id"])
|
|
335
|
+
continue
|
|
336
|
+
asset = bound[instance["id"]]
|
|
337
|
+
# Exact geometry/provenance content identity permits shared immutable GLBs.
|
|
338
|
+
encoded = Glb().encode(asset)
|
|
339
|
+
content_hash = digest(encoded)
|
|
340
|
+
filename = f"geometry/{content_hash}.glb"
|
|
341
|
+
if content_hash not in emitted:
|
|
342
|
+
(output / filename).write_bytes(encoded)
|
|
343
|
+
emitted[content_hash] = filename
|
|
344
|
+
pose = pose_matrix(vector(instance["position"], "position"), vector(instance["rotation"], "rotation"))
|
|
345
|
+
# Content is GLB Y-up; runtimes apply C. Tile transforms are already Z-up.
|
|
346
|
+
tile_pose = multiply(multiply(C, pose), CI)
|
|
347
|
+
points = asset_points(asset)
|
|
348
|
+
all_points.extend(transform(multiply(C, pose), p) for p in points)
|
|
349
|
+
project = asset.get("formProject") or {}
|
|
350
|
+
metadata = {"instanceId": instance["id"], "assetId": asset["id"], "name": instance["name"],
|
|
351
|
+
"sourceKind": asset["source"]["kind"], "sourceDigest": asset["source"]["digest"]}
|
|
352
|
+
children.append({"boundingVolume": {"box": bounds([transform(C, p) for p in points])},
|
|
353
|
+
"transform": columns(tile_pose), "geometricError": 0,
|
|
354
|
+
"content": {"uri": filename}, "metadata": {"class": "equipment", "properties": metadata}})
|
|
355
|
+
instances.append({**metadata, "contentUri": filename, "sourceFilename": asset["source"]["filename"],
|
|
356
|
+
"sourceVersion": asset["source"].get("version"),
|
|
357
|
+
"projectId": project.get("projectId", asset["source"].get("projectId")),
|
|
358
|
+
"projectRevision": project.get("revision", refs[asset["id"]].get("projectRevision")),
|
|
359
|
+
"cloudVersionId": instance.get("cloudVersionId"),
|
|
360
|
+
"sourceNormalizationOffset": asset["originOffset"],
|
|
361
|
+
"position": instance["position"], "rotationDegreesXYZ": instance["rotation"],
|
|
362
|
+
"partIds": [part["id"] for part in asset["parts"]]})
|
|
363
|
+
root_box = bounds(all_points)
|
|
364
|
+
schema = {"id": "oi_scene_v1", "classes": {"equipment": {"properties": {
|
|
365
|
+
key: {"type": "STRING"} for key in ("instanceId", "assetId", "name", "sourceKind", "sourceDigest")}}}}
|
|
366
|
+
# A nonzero empty-root error requests refinement to exact leaf content.
|
|
367
|
+
root_error = max(root_box[3], root_box[7], root_box[11]) * 2
|
|
368
|
+
tileset = {"asset": {"version": "1.1"}, "geometricError": root_error, "schema": schema,
|
|
369
|
+
"root": {"boundingVolume": {"box": root_box}, "transform": columns(anchor.matrix()),
|
|
370
|
+
"geometricError": root_error, "refine": "ADD", "children": children},
|
|
371
|
+
"extras": {"provenanceUri": "provenance.json", "evidence": "conceptual-static-scene"}}
|
|
372
|
+
write_json(output / "tileset.json", tileset)
|
|
373
|
+
write_json(output / "provenance.json", {
|
|
374
|
+
"schema": "oi-geospatial-provenance/v1", "sourceSha256": digest(raw),
|
|
375
|
+
"sourceRevision": source_revision(source_path), "sourceFilename": source_path.name,
|
|
376
|
+
"anchor": vars(anchor), "placement": "demonstration anchor; not surveyed or deployed",
|
|
377
|
+
"frame": "OI +X east, +Y up, -Z north at heading 0; clockwise heading from true north",
|
|
378
|
+
"normalization": "Stored vertices are normalized. Stored instance position already locates the normalized origin. originOffset is retained only as provenance.",
|
|
379
|
+
"licenseNote": license_note, "instances": instances,
|
|
380
|
+
"excluded": {"hiddenInstances": hidden, "animationTracks": len(scene.get("animation", {}).get("tracks", [])),
|
|
381
|
+
"fields": ["BOM, compiler findings, arbitrary source documents", "room decoration, hierarchy grouping, textures, physics and animation"]},
|
|
382
|
+
"capabilities": {"staticGeometry": "passed", "georeferencedPlacement": "passed",
|
|
383
|
+
"animation": "not-tested", "physicalValidation": "not-tested",
|
|
384
|
+
"licensePermission": "not-tested", "viewerInteroperability": "not-tested"}})
|
|
385
|
+
files = sorted([output / "tileset.json", output / "provenance.json"] + [output / name for name in emitted.values()])
|
|
386
|
+
report = {"schema": "oi-geospatial-export/v1", "inputSha256": digest(raw),
|
|
387
|
+
"pythonVersion": sys.version, "instances": len(instances), "uniqueGlbs": len(emitted),
|
|
388
|
+
"verticesPerInstanceTotal": len(all_points), "rootBox": root_box,
|
|
389
|
+
"conversionSeconds": time.perf_counter() - started, "payloadBytes": sum(p.stat().st_size for p in files),
|
|
390
|
+
"checksums": {p.relative_to(output).as_posix(): digest(p.read_bytes()) for p in files},
|
|
391
|
+
"conformance": "not-tested; run npm run validate in tools/geospatial",
|
|
392
|
+
"limitations": ["static base layout; no timeline sampling", "one small site; no LOD simplification", "conceptual geometry; no engineering certification"]}
|
|
393
|
+
write_json(output / "export-report.json", report)
|
|
394
|
+
return report
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def main() -> int:
|
|
398
|
+
"""Run the CLI with an explicit geographic anchor and provenance statement."""
|
|
399
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
400
|
+
parser.add_argument("scene", type=Path)
|
|
401
|
+
parser.add_argument("output", type=Path)
|
|
402
|
+
parser.add_argument("--longitude", required=True, type=float)
|
|
403
|
+
parser.add_argument("--latitude", required=True, type=float)
|
|
404
|
+
parser.add_argument("--height", required=True, type=float, help="WGS84 ellipsoidal height in meters; not terrain height")
|
|
405
|
+
parser.add_argument("--heading", required=True, type=float, help="Clockwise degrees from true north; 0 <= heading < 360")
|
|
406
|
+
parser.add_argument("--license-note", required=True, help="Source license/permission evidence or explicit unresolved status; not a permission grant")
|
|
407
|
+
args = parser.parse_args()
|
|
408
|
+
try:
|
|
409
|
+
result = export_scene(args.scene, args.output, Anchor(args.longitude, args.latitude, args.height, args.heading), args.license_note)
|
|
410
|
+
except (ValueError, KeyError, TypeError, OSError) as exc:
|
|
411
|
+
parser.exit(1, f"Export failed: {exc}\n")
|
|
412
|
+
print(json.dumps({"instances": result["instances"], "uniqueGlbs": result["uniqueGlbs"], "payloadBytes": result["payloadBytes"]}))
|
|
413
|
+
return 0
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
if __name__ == "__main__":
|
|
417
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: open-industries
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static Open Industries scene exporter for georeferenced 3D Tiles
|
|
5
|
+
Author: Mapped Assembly
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Isayah Culbertson
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/Mapped-Assembly/Open-Industries
|
|
29
|
+
Project-URL: Repository, https://github.com/Mapped-Assembly/Open-Industries
|
|
30
|
+
Project-URL: Issues, https://github.com/Mapped-Assembly/Open-Industries/issues
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Requires-Python: >=3.11
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# Static OI scenes → 3D Tiles 1.1
|
|
42
|
+
|
|
43
|
+
A small, independently runnable Python converter and local CesiumJS/Three.js
|
|
44
|
+
interoperability demo for [issue #18](https://github.com/Mapped-Assembly/Open-Industries/issues/18).
|
|
45
|
+
It consumes the geometry already bundled in a portable OI scene. It does not
|
|
46
|
+
tessellate CAD, perform physics, or build a general-purpose streaming hierarchy.
|
|
47
|
+
|
|
48
|
+
## Reproduce
|
|
49
|
+
|
|
50
|
+
Python 3.11+ is sufficient for conversion and contract tests. The separate
|
|
51
|
+
viewer/validator package uses Node 22.18+ and a committed npm lockfile. Root
|
|
52
|
+
application dependencies and provider credentials are unnecessary.
|
|
53
|
+
|
|
54
|
+
From the repository root:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
python tools/geospatial/export_scene.py benchmarks/field-lab/fixtures/field-lab.oi.json tools/geospatial/public/export --longitude=-73.977 --latitude=40.684 --height=30 --heading=0 --license-note="Existing conceptual FIELD-LAB fixture; redistribution permission unresolved"
|
|
58
|
+
python -m unittest discover -s tools/geospatial -p test_export.py -v
|
|
59
|
+
cd tools/geospatial
|
|
60
|
+
npm ci --ignore-scripts
|
|
61
|
+
npx playwright install chromium
|
|
62
|
+
npm run validate
|
|
63
|
+
npm run test:browser
|
|
64
|
+
npm run serve
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Open <http://127.0.0.1:4194/> for CesiumJS or
|
|
68
|
+
<http://127.0.0.1:4194/?viewer=three> for the independent 3D Tiles renderer.
|
|
69
|
+
The server is local; nothing is published or deployed. Both runtimes load the
|
|
70
|
+
same local tileset without Cesium ion, imagery, login, or tokens. Dependencies
|
|
71
|
+
and browsers need network access during installation only.
|
|
72
|
+
|
|
73
|
+
`--ignore-scripts` avoids unused native SQLite build hooks pulled in by the
|
|
74
|
+
validator's archive tooling. This adapter validates directory-based JSON/GLB,
|
|
75
|
+
not SQLite tile archives. The pinned sharp override supplies platform packages
|
|
76
|
+
instead of the old validator dependency's legacy installation hook. Runtime
|
|
77
|
+
dependencies retain their own licenses.
|
|
78
|
+
|
|
79
|
+
## Coordinate contract
|
|
80
|
+
|
|
81
|
+
- Input is `astra.scene` v1, meters, right-handed Y-up. OI rotations use
|
|
82
|
+
Three.js Euler XYZ in degrees.
|
|
83
|
+
- At heading zero, OI +X is east, +Y is up, and -Z is north. The caller explicitly
|
|
84
|
+
supplies WGS84 longitude, latitude, **ellipsoidal** height, and heading.
|
|
85
|
+
Heading rotates clockwise from true north, in [0, 360).
|
|
86
|
+
- GLB positions remain Y-up. A 3D Tiles runtime applies the standard Y-up→Z-up
|
|
87
|
+
rotation `C`, mapping (x,y,z) to (x,-z,y). Child transforms are
|
|
88
|
+
`C × OI_pose × inverse(C)`. The root is WGS84 ENU→ECEF with heading.
|
|
89
|
+
Tile bounds are Z-up; all serialized matrices are column-major.
|
|
90
|
+
The empty root uses a conservative extent-sized geometric error to request
|
|
91
|
+
refinement; exact leaf geometry has zero geometric error.
|
|
92
|
+
- Stored mesh vertices are already normalized and stored instance positions
|
|
93
|
+
already locate those normalized origins. **Do not add originOffset again.**
|
|
94
|
+
The offset is retained as source provenance.
|
|
95
|
+
- The Three.js viewer recenters the ECEF scene into the original local meter
|
|
96
|
+
frame for numerical precision. It loads the identical geographic tileset.
|
|
97
|
+
- Geographic anchors are illustrative. The converter does not fetch terrain,
|
|
98
|
+
infer ground height, geocode a lab, or establish surveyed accuracy.
|
|
99
|
+
|
|
100
|
+
## Data and metadata mapping
|
|
101
|
+
|
|
102
|
+
| Input | Output |
|
|
103
|
+
| --- | --- |
|
|
104
|
+
| Indexed part geometry and linear RGB | Shared-asset GLBs with unlit, double-sided materials |
|
|
105
|
+
| Visible instance placement/rotation | One leaf tile per instance; same asset content reused |
|
|
106
|
+
| Asset ID/name and source digest | glTF EXT_mesh_features + EXT_structural_metadata property table |
|
|
107
|
+
| Instance ID/name, asset ID, source kind/digest | 3D Tiles tile metadata; inherited when geometry is picked |
|
|
108
|
+
| Project identity/revision, cloud binding, source normalization offset, part IDs | Allowlisted provenance.json sidecar |
|
|
109
|
+
| Input file byte digest and checkout revision | Export report and provenance; revision is null outside Git |
|
|
110
|
+
| Hidden geometry, animation, hierarchy grouping, BOM/compiler findings, source documents, room decoration | Explicitly excluded; no claim of lossless IR transfer |
|
|
111
|
+
|
|
112
|
+
There is no static feature ID per animated component: all primitives of a shared
|
|
113
|
+
asset refer to its single asset feature. Part identities are preserved in node
|
|
114
|
+
extras and the sidecar. Instance identity belongs to the leaf tile and remains
|
|
115
|
+
distinct when two instances share one GLB. Arbitrary provider settings, raw IR,
|
|
116
|
+
source documents and credentials are not copied; this allowlist is stricter than
|
|
117
|
+
the full OI portable export. It cannot detect secrets pasted into an allowed name.
|
|
118
|
+
|
|
119
|
+
## Validation and evidence
|
|
120
|
+
|
|
121
|
+
The converter writes tileset.json, content-addressed GLBs, provenance.json and
|
|
122
|
+
export-report.json with source/output hashes, Python version, payload size and
|
|
123
|
+
conversion timing. Existing unrelated files in the output directory are not
|
|
124
|
+
deleted; the report and validators enumerate only current referenced content.
|
|
125
|
+
Use a fresh output directory for a distributable package.
|
|
126
|
+
|
|
127
|
+
`npm run validate` records complete diagnostics from 3d-tiles-validator 0.6.1
|
|
128
|
+
and gltf-validator 2.0.0-dev.3.10. The latter reports metadata extensions as
|
|
129
|
+
unsupported informational messages; the 3D Tiles validator additionally checks
|
|
130
|
+
those extensions. Zero core errors alone is not proof that every extension or
|
|
131
|
+
engineering property is valid.
|
|
132
|
+
|
|
133
|
+
`npm run test:browser` starts and closes its own Vite server, verifies placement
|
|
134
|
+
against independent Cesium WGS84/ENU and Three.js Euler implementations within
|
|
135
|
+
1 cm, loads both runtimes, and clicks pipette geometry to check exact identity.
|
|
136
|
+
It saves screenshots, browser/runtime versions, load timing, and memory metrics
|
|
137
|
+
under evidence/. External browser requests are blocked in this test.
|
|
138
|
+
|
|
139
|
+
The workflow runs Python checks on Linux/Windows and viewer/conformance checks
|
|
140
|
+
at two demonstration anchors on Linux. Screenshots, validator diagnostics,
|
|
141
|
+
checksums, geometry, and timing are uploaded as CI artifacts. Load/capture timing
|
|
142
|
+
on a software-rendered browser is not interactive FPS or physical performance.
|
|
143
|
+
|
|
144
|
+
The upstream FIELD-LAB merge has passing [benchmark](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844141),
|
|
145
|
+
[scene](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844134),
|
|
146
|
+
and [runtime](https://github.com/Mapped-Assembly/Open-Industries/actions/runs/37550844184)
|
|
147
|
+
workflows at e88f5ce5f8eeb4eda6a49757d5325699cd815cea.
|
|
148
|
+
Adapter CI is separate and must be checked at the published PR head.
|
|
149
|
+
|
|
150
|
+
## Openness and limits
|
|
151
|
+
|
|
152
|
+
The new source/documents in **this tools/geospatial directory** are MIT licensed.
|
|
153
|
+
That license does not relicense the repository, imported FIELD-LAB or cleanroom
|
|
154
|
+
assets, dependencies, or generated models. `--license-note` records the user's
|
|
155
|
+
evidence or unresolved status; it does not grant permission or certify rights.
|
|
156
|
+
Confirm original asset permissions before redistributing a grant demonstration.
|
|
157
|
+
|
|
158
|
+
For a fully MIT-licensed interchange fixture, use
|
|
159
|
+
`fixtures/open-equipment.oi.json`: two instances of a newly authored asymmetric
|
|
160
|
+
workbench envelope, including a 90-degree rotation and shared geometry. Its
|
|
161
|
+
conceptual boxes do not reuse the existing lab/CAD assets. Substitute that input
|
|
162
|
+
in the converter command and set `--license-note="MIT; tools/geospatial/LICENSE"`.
|
|
163
|
+
The browser smoke test specifically targets the 21-instance FIELD-LAB fixture;
|
|
164
|
+
the minimal fixture can be inspected manually and checked with the validators.
|
|
165
|
+
|
|
166
|
+
This first version exports the **static base layout**, not frame zero of the
|
|
167
|
+
authored animation. No animation, scientific validity, perception, provider
|
|
168
|
+
generation, cold-chain solver, live operation, or physical validation is implied.
|
|
169
|
+
The small FIELD-LAB fixture is not a scalability benchmark. A state/replay
|
|
170
|
+
overlay and resolved asset licensing remain follow-on work; issue #18 should
|
|
171
|
+
stay open until its remaining evidence gates are addressed.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tools/geospatial/LICENSE
|
|
4
|
+
tools/geospatial/README.md
|
|
5
|
+
tools/geospatial/export_scene.py
|
|
6
|
+
tools/geospatial/open_industries.egg-info/PKG-INFO
|
|
7
|
+
tools/geospatial/open_industries.egg-info/SOURCES.txt
|
|
8
|
+
tools/geospatial/open_industries.egg-info/dependency_links.txt
|
|
9
|
+
tools/geospatial/open_industries.egg-info/entry_points.txt
|
|
10
|
+
tools/geospatial/open_industries.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export_scene
|