@certe/atmos-editor 0.1.0 → 0.3.0

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@certe/atmos-editor",
3
3
  "description": "Browser-based Unity-style editor for the Atmos Engine — hierarchy, inspector, gizmos",
4
- "version": "0.1.0",
4
+ "version": "0.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/certesolutions-cyber/atmos.git",
@@ -9,49 +9,57 @@
9
9
  },
10
10
  "homepage": "https://github.com/certesolutions-cyber/atmos",
11
11
  "license": "GPL-3.0-or-later",
12
- "keywords": ["editor", "game-engine", "webgpu", "scene-editor", "gizmo", "inspector", "atmos"],
12
+ "keywords": [
13
+ "editor",
14
+ "game-engine",
15
+ "webgpu",
16
+ "scene-editor",
17
+ "gizmo",
18
+ "inspector",
19
+ "atmos"
20
+ ],
13
21
  "type": "module",
14
- "main": "src/index.ts",
15
- "types": "src/index.ts",
22
+ "main": "dist/index.js",
23
+ "types": "dist/index.d.ts",
16
24
  "exports": {
17
- ".": "./src/index.ts",
18
- "./player": "./src/player-entry.ts",
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ },
29
+ "./player": {
30
+ "types": "./dist/player-entry.d.ts",
31
+ "import": "./dist/player-entry.js"
32
+ },
19
33
  "./vite": {
20
34
  "types": "./vite-plugin.d.ts",
21
- "import": "./vite-plugin.mjs"
35
+ "import": "./vite-plugin.mjs",
36
+ "require": "./vite-plugin.cjs"
22
37
  }
23
38
  },
24
- "publishConfig": {
25
- "main": "dist/index.js",
26
- "types": "dist/index.d.ts",
27
- "exports": {
28
- ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js"
31
- },
32
- "./player": {
33
- "types": "./dist/player-entry.d.ts",
34
- "import": "./dist/player-entry.js"
35
- },
36
- "./vite": {
37
- "types": "./vite-plugin.d.ts",
38
- "import": "./vite-plugin.mjs"
39
- }
40
- }
41
- },
42
- "files": ["dist", "!dist/__tests__", "vite-plugin.mjs", "vite-plugin.d.ts", "package.json", "README.md", "LICENCE"],
39
+ "files": [
40
+ "dist",
41
+ "!dist/__tests__",
42
+ "vite-plugin.mjs",
43
+ "vite-plugin.cjs",
44
+ "vite-plugin.d.ts",
45
+ "package.json",
46
+ "README.md",
47
+ "LICENCE"
48
+ ],
43
49
  "peerDependencies": {
44
50
  "vite": ">=5.0.0"
45
51
  },
46
52
  "peerDependenciesMeta": {
47
- "vite": { "optional": true }
53
+ "vite": {
54
+ "optional": true
55
+ }
48
56
  },
49
57
  "dependencies": {
50
- "@certe/atmos-animation": "^0.1.0",
51
- "@certe/atmos-assets": "^0.1.0",
52
- "@certe/atmos-core": "^0.1.0",
53
- "@certe/atmos-math": "^0.1.0",
54
- "@certe/atmos-renderer": "^0.1.0",
58
+ "@certe/atmos-animation": "^0.3.0",
59
+ "@certe/atmos-assets": "^0.3.0",
60
+ "@certe/atmos-core": "^0.3.0",
61
+ "@certe/atmos-math": "^0.3.0",
62
+ "@certe/atmos-renderer": "^0.3.0",
55
63
  "react": "^19.0.0",
56
64
  "react-dom": "^19.0.0"
57
65
  },
@@ -0,0 +1,397 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ const DEFAULT_EXCLUDE = new Set([
5
+ 'node_modules', '.git', 'dist', '.vite', '.turbo', '__pycache__',
6
+ ]);
7
+
8
+ function scanDirectory(dirPath, relativeTo, exclude) {
9
+ const entries = [];
10
+ let items;
11
+ try {
12
+ items = fs.readdirSync(dirPath, { withFileTypes: true });
13
+ } catch {
14
+ return entries;
15
+ }
16
+ for (const item of items) {
17
+ if (exclude.has(item.name) || item.name.startsWith('.')) continue;
18
+ const fullPath = path.join(dirPath, item.name);
19
+ const relPath = path.relative(relativeTo, fullPath).replace(/\\/g, '/');
20
+ if (item.isDirectory()) {
21
+ entries.push({
22
+ path: relPath,
23
+ name: item.name,
24
+ kind: 'directory',
25
+ extension: '',
26
+ children: scanDirectory(fullPath, relativeTo, exclude),
27
+ });
28
+ } else {
29
+ const ext = path.extname(item.name).slice(1);
30
+ entries.push({
31
+ path: relPath,
32
+ name: item.name,
33
+ kind: 'file',
34
+ extension: ext,
35
+ });
36
+ }
37
+ }
38
+ return entries.sort((a, b) => {
39
+ if (a.kind !== b.kind) return a.kind === 'directory' ? -1 : 1;
40
+ return a.name.localeCompare(b.name);
41
+ });
42
+ }
43
+
44
+ function generateEditorHtml(entry) {
45
+ return `<!DOCTYPE html>
46
+ <html lang="en">
47
+ <head>
48
+ <meta charset="UTF-8" />
49
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
50
+ <title>Atmos Editor</title>
51
+ </head>
52
+ <body>
53
+ <script type="module" src="/${entry}"></script>
54
+ </body>
55
+ </html>`;
56
+ }
57
+
58
+ function generatePlayerHtml(entry) {
59
+ return `<!DOCTYPE html>
60
+ <html lang="en">
61
+ <head>
62
+ <meta charset="UTF-8" />
63
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
64
+ <title>Atmos Game</title>
65
+ <style>
66
+ * { margin: 0; padding: 0; box-sizing: border-box; }
67
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
68
+ #atmos-container { position: relative; width: 100%; height: 100%; }
69
+ #atmos-canvas { width: 100%; height: 100%; display: block; }
70
+ #atmos-ui { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
71
+ #atmos-ui * { pointer-events: auto; }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <div id="atmos-container">
76
+ <canvas id="atmos-canvas"></canvas>
77
+ <div id="atmos-ui"></div>
78
+ </div>
79
+ <script type="module" src="/${entry}"></script>
80
+ </body>
81
+ </html>`;
82
+ }
83
+
84
+ const VIRTUAL_BUILD_ENTRY = 'virtual:atmos-build-entry';
85
+ const RESOLVED_BUILD_ENTRY = '\0virtual:atmos-build-entry';
86
+
87
+ function copyDirSync(src, dest) {
88
+ fs.mkdirSync(dest, { recursive: true });
89
+ for (const item of fs.readdirSync(src, { withFileTypes: true })) {
90
+ const srcPath = path.join(src, item.name);
91
+ const destPath = path.join(dest, item.name);
92
+ if (item.isDirectory()) {
93
+ copyDirSync(srcPath, destPath);
94
+ } else {
95
+ fs.copyFileSync(srcPath, destPath);
96
+ }
97
+ }
98
+ }
99
+
100
+ /** Collect body data from an IncomingMessage. */
101
+ function collectBody(req) {
102
+ return new Promise((resolve, reject) => {
103
+ const chunks = [];
104
+ req.on('data', (c) => chunks.push(c));
105
+ req.on('end', () => resolve(Buffer.concat(chunks)));
106
+ req.on('error', reject);
107
+ });
108
+ }
109
+
110
+ /** @param {import('./vite-plugin.d.ts').AtmosPluginOptions} [options] */
111
+ function atmosPlugin(options) {
112
+ const include = options?.include ?? ['src'];
113
+ const exclude = new Set([...DEFAULT_EXCLUDE, ...(options?.exclude ?? [])]);
114
+ const entry = options?.entry ?? 'src/main.ts';
115
+ let root = '';
116
+ let generatedIndex = '';
117
+ let isBuild = false;
118
+
119
+ return {
120
+ name: 'atmos-editor',
121
+
122
+ config(cfg, { command }) {
123
+ root = cfg.root || process.cwd();
124
+ isBuild = command === 'build';
125
+
126
+ if (isBuild) {
127
+ // In build mode, generate player HTML (not editor)
128
+ const indexPath = path.resolve(root, 'index.html');
129
+ const userHasIndex = fs.existsSync(indexPath);
130
+ if (!userHasIndex) {
131
+ fs.writeFileSync(indexPath, generatePlayerHtml(VIRTUAL_BUILD_ENTRY));
132
+ generatedIndex = indexPath;
133
+ }
134
+ return {
135
+ build: {
136
+ target: cfg.build?.target ?? 'esnext',
137
+ rollupOptions: {
138
+ ...cfg.build?.rollupOptions,
139
+ },
140
+ },
141
+ };
142
+ }
143
+
144
+ // Dev mode — generate editor HTML if no index.html exists
145
+ const indexPath = path.resolve(root, 'index.html');
146
+ if (!fs.existsSync(indexPath)) {
147
+ fs.writeFileSync(indexPath, generateEditorHtml(entry));
148
+ generatedIndex = indexPath;
149
+ }
150
+ return {
151
+ build: {
152
+ target: cfg.build?.target ?? 'esnext',
153
+ },
154
+ };
155
+ },
156
+
157
+ resolveId(id) {
158
+ if (id === VIRTUAL_BUILD_ENTRY || id === '/' + VIRTUAL_BUILD_ENTRY) {
159
+ return RESOLVED_BUILD_ENTRY;
160
+ }
161
+ },
162
+
163
+ load(id) {
164
+ if (id !== RESOLVED_BUILD_ENTRY) return;
165
+ let sceneName = 'main';
166
+ try {
167
+ const settingsPath = path.join(root, 'project-settings.json');
168
+ if (fs.existsSync(settingsPath)) {
169
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
170
+ if (settings.defaultScene) sceneName = settings.defaultScene;
171
+ }
172
+ } catch { /* use default */ }
173
+ return `
174
+ import { startPlayer, createEditorPhysics } from '@certe/atmos-editor/player';
175
+ const scriptModules = import.meta.glob('/src/scripts/*.ts', { eager: true });
176
+ try {
177
+ const physics = await createEditorPhysics();
178
+ const app = await startPlayer({
179
+ scene: 'scenes/${sceneName}.scene.json',
180
+ physics,
181
+ scriptModules,
182
+ });
183
+ } catch (err) {
184
+ document.body.style.background = '#111';
185
+ document.body.style.color = '#f88';
186
+ document.body.style.padding = '2em';
187
+ document.body.style.fontFamily = 'monospace';
188
+ document.body.textContent = 'Atmos: ' + (err instanceof Error ? err.message : String(err));
189
+ }
190
+ `;
191
+ },
192
+
193
+ transformIndexHtml: {
194
+ order: 'pre',
195
+ handler(html) {
196
+ if (!isBuild) return html;
197
+ // Replace any existing script src with the virtual build entry
198
+ const hasScript = /<script\s+type="module"\s+src="[^"]*"[^>]*><\/script>/.test(html);
199
+ if (hasScript) {
200
+ return html.replace(
201
+ /<script\s+type="module"\s+src="[^"]*"([^>]*)><\/script>/,
202
+ `<script type="module" src="/${VIRTUAL_BUILD_ENTRY}"$1></script>`,
203
+ );
204
+ }
205
+ // No script tag — inject one before </body>
206
+ return html.replace('</body>', ` <script type="module" src="/${VIRTUAL_BUILD_ENTRY}"></script>\n</body>`);
207
+ },
208
+ },
209
+
210
+ closeBundle() {
211
+ if (generatedIndex) {
212
+ try { fs.unlinkSync(generatedIndex); } catch { /* ignore */ }
213
+ generatedIndex = '';
214
+ }
215
+ },
216
+
217
+ writeBundle(options) {
218
+ if (!isBuild) return;
219
+ const outDir = options.dir || path.resolve(root, 'dist');
220
+ const assetDirs = ['scenes', 'materials', 'textures', 'models'];
221
+ for (const dir of assetDirs) {
222
+ const src = path.resolve(root, dir);
223
+ if (fs.existsSync(src)) {
224
+ copyDirSync(src, path.join(outDir, dir));
225
+ }
226
+ }
227
+ },
228
+
229
+ configureServer(server) {
230
+ root = server.config.root;
231
+
232
+ // Auto-generate index.html when none exists
233
+ const indexPath = path.resolve(root, 'index.html');
234
+ if (!fs.existsSync(indexPath)) {
235
+ server.middlewares.use((req, res, next) => {
236
+ if (req.url === '/' || req.url === '/index.html') {
237
+ const rawHtml = generateEditorHtml(entry);
238
+ server.transformIndexHtml(req.url, rawHtml).then((html) => {
239
+ res.statusCode = 200;
240
+ res.setHeader('Content-Type', 'text/html');
241
+ res.end(html);
242
+ });
243
+ return;
244
+ }
245
+ next();
246
+ });
247
+ }
248
+
249
+ // Serve asset tree as JSON
250
+ server.middlewares.use('/__atmos_assets', (_req, res) => {
251
+ const allEntries = [];
252
+ for (const dir of include) {
253
+ const absDir = path.resolve(root, dir);
254
+ if (fs.existsSync(absDir)) {
255
+ allEntries.push(...scanDirectory(absDir, root, exclude));
256
+ }
257
+ }
258
+ res.setHeader('Content-Type', 'application/json');
259
+ res.end(JSON.stringify({ root, entries: allEntries }));
260
+ });
261
+
262
+ // ── Project filesystem endpoints ──────────────────
263
+
264
+ server.middlewares.use((req, res, next) => {
265
+ if (!req.url || !req.url.startsWith('/__atmos_fs/')) { next(); return; }
266
+
267
+ const url = new URL(req.url, 'http://localhost');
268
+ const action = url.pathname.slice('/__atmos_fs'.length); // e.g. /read, /info
269
+ const filePath = url.searchParams.get('path') ?? '';
270
+
271
+ // /info and /tree don't need a file path
272
+ if (action === '/info') {
273
+ res.setHeader('Content-Type', 'application/json');
274
+ res.end(JSON.stringify({ name: path.basename(root), root }));
275
+ return;
276
+ }
277
+ if (action === '/tree') {
278
+ const allEntries = scanDirectory(root, root, exclude);
279
+ res.setHeader('Content-Type', 'application/json');
280
+ res.end(JSON.stringify(allEntries));
281
+ return;
282
+ }
283
+
284
+ // Prevent path traversal for file operations
285
+ const absPath = path.resolve(root, filePath);
286
+ if (!absPath.startsWith(root)) {
287
+ res.statusCode = 403;
288
+ res.end('Forbidden');
289
+ return;
290
+ }
291
+
292
+ // Handle async actions (write needs body collection)
293
+ handleFsAction(action, absPath, filePath, root, exclude, req, res, server).catch((err) => {
294
+ res.statusCode = 500;
295
+ res.end(String(err));
296
+ });
297
+ });
298
+
299
+ // Watch for file changes and push HMR custom events
300
+ server.watcher.on('all', (event, filePath) => {
301
+ if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
302
+ const relPath = path.relative(root, filePath).replace(/\\/g, '/');
303
+ const inScope = include.some((dir) => relPath.startsWith(dir));
304
+ if (inScope) {
305
+ server.hot.send('atmos:asset-change', { kind: event, path: relPath });
306
+ }
307
+ // Also notify for project files (materials, scenes, etc.)
308
+ if (relPath.startsWith('materials/') || relPath.startsWith('scenes/') || relPath.startsWith('textures/')) {
309
+ server.hot.send('atmos:project-change', { kind, path: relPath });
310
+ }
311
+ });
312
+ },
313
+ };
314
+ }
315
+
316
+ async function handleFsAction(action, absPath, filePath, root, exclude, req, res, server) {
317
+ switch (action) {
318
+ case '/read': {
319
+ const data = fs.readFileSync(absPath);
320
+ res.setHeader('Content-Type', 'application/octet-stream');
321
+ res.end(data);
322
+ break;
323
+ }
324
+ case '/read-text': {
325
+ const text = fs.readFileSync(absPath, 'utf-8');
326
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
327
+ res.end(text);
328
+ break;
329
+ }
330
+ case '/write': {
331
+ const body = await collectBody(req);
332
+ const dir = path.dirname(absPath);
333
+ fs.mkdirSync(dir, { recursive: true });
334
+ fs.writeFileSync(absPath, body);
335
+ res.end('ok');
336
+ notifyFileChange(server, root, filePath, 'change');
337
+ break;
338
+ }
339
+ case '/delete': {
340
+ fs.unlinkSync(absPath);
341
+ res.end('ok');
342
+ notifyFileChange(server, root, filePath, 'unlink');
343
+ break;
344
+ }
345
+ case '/exists': {
346
+ res.setHeader('Content-Type', 'application/json');
347
+ res.end(JSON.stringify(fs.existsSync(absPath)));
348
+ break;
349
+ }
350
+ case '/mkdir': {
351
+ fs.mkdirSync(absPath, { recursive: true });
352
+ res.end('ok');
353
+ break;
354
+ }
355
+ case '/list': {
356
+ const dir = filePath || '.';
357
+ const absDir = path.resolve(root, dir);
358
+ const files = listRecursive(absDir, filePath ? `${filePath}/` : '');
359
+ res.setHeader('Content-Type', 'application/json');
360
+ res.end(JSON.stringify(files));
361
+ break;
362
+ }
363
+ default:
364
+ res.statusCode = 404;
365
+ res.end('Unknown action');
366
+ }
367
+ }
368
+
369
+ function notifyFileChange(server, root, filePath, kind) {
370
+ // Send HMR event immediately (don't wait for chokidar watcher)
371
+ server.hot.send('atmos:project-change', { kind, path: filePath });
372
+ }
373
+
374
+ function listRecursive(dirPath, prefix) {
375
+ const results = [];
376
+ let items;
377
+ try {
378
+ items = fs.readdirSync(dirPath, { withFileTypes: true });
379
+ } catch {
380
+ return results;
381
+ }
382
+ for (const item of items) {
383
+ if (item.name.startsWith('.')) continue;
384
+ const full = path.join(dirPath, item.name);
385
+ if (item.isFile()) {
386
+ results.push(prefix + item.name);
387
+ } else if (item.isDirectory()) {
388
+ results.push(...listRecursive(full, `${prefix}${item.name}/`));
389
+ }
390
+ }
391
+ return results.sort();
392
+ }
393
+
394
+ /** @deprecated Use atmosPlugin() instead */
395
+ const atmosAssetsPlugin = atmosPlugin;
396
+
397
+ module.exports = { atmosPlugin, atmosAssetsPlugin };
package/src/index.ts DELETED
@@ -1,48 +0,0 @@
1
- export { EditorState } from './editor-state.js';
2
- export type { EditorEvent } from './editor-state.js';
3
- export { mountEditor } from './editor-mount.js';
4
- export type { MountEditorOptions, MountEditorResult, PrimitiveType } from './editor-mount.js';
5
- export { getProperty, setProperty } from './property-setters.js';
6
- export { OrbitCamera } from './orbit-camera.js';
7
- export { ObjectPicker } from './object-picker.js';
8
- export type { PickResult } from './object-picker.js';
9
- export { computeSelectionCenter } from './selection-utils.js';
10
- export { GizmoState } from './gizmo-state.js';
11
- export type { GizmoMode, GizmoAxis } from './gizmo-state.js';
12
- export { GizmoRenderer } from './gizmo-renderer.js';
13
- export { OverlayRenderer } from './overlay-renderer.js';
14
- export { CAMERA_PRESETS, applyCameraPreset } from './camera-presets.js';
15
- export type { CameraPreset } from './camera-presets.js';
16
- export {
17
- findObjectById,
18
- duplicateGameObject,
19
- deleteGameObject,
20
- canReparent,
21
- reparentGameObject,
22
- setReparentValidator,
23
- setOnReparent,
24
- setOnDuplicate,
25
- } from './scene-operations.js';
26
- export type { ReparentValidator, ReparentCallback, DuplicateCallback } from './scene-operations.js';
27
- export { takeSnapshot, restoreSnapshot } from './scene-snapshot.js';
28
- export type { SceneSnapshot } from './scene-snapshot.js';
29
- export { AssetBrowserClient } from './asset-browser-client.js';
30
- export type { AssetEntry, AssetListResponse, AssetChangeEvent, ScriptAsset } from './asset-types.js';
31
- export { discoverScripts, autoDiscoverScripts } from './script-discovery.js';
32
- export { ProjectFileSystem } from './project-fs.js';
33
- export { MaterialManager } from './material-manager.js';
34
- export { ProjectSettingsManager } from './project-settings.js';
35
- export type { PhysicsSettings, ProjectSettings } from './project-settings.js';
36
- export { startEditor } from './bootstrap/start-editor.js';
37
- export { startPlayer } from './bootstrap/start-player.js';
38
- export type { PlayerConfig, PlayerApp } from './bootstrap/start-player.js';
39
- export { createEditorPhysics } from './bootstrap/editor-physics.js';
40
- export { SimpleMaterialLoader } from './simple-material-loader.js';
41
- export type {
42
- EditorConfig,
43
- EditorApp,
44
- EditorPhysicsPlugin,
45
- SceneSetupContext,
46
- MeshLike,
47
- PhysicsInitContext,
48
- } from './bootstrap/types.js';