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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ oi-export-scene = export_scene:main