phibelle-kit 1.0.30 → 1.0.32
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/dist/commands/clone-scene.js +4 -14
- package/dist/commands/watch-scene.js +5 -31
- package/dist/index.js +1 -6
- package/dist/lib/constants.d.ts +12 -0
- package/dist/lib/constants.js +12 -0
- package/dist/lib/manifest.d.ts +5 -0
- package/dist/lib/manifest.js +31 -0
- package/dist/lib/scene/file-system.d.ts +2 -1
- package/dist/lib/scene/file-system.js +3 -2
- package/dist/lib/scene/scene-packaging.d.ts +6 -10
- package/dist/lib/scene/scene-packaging.js +472 -249
- package/dist/lib/scene/script-watcher.js +31 -29
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +10 -0
- package/dist/scene-setup/setup.d.ts +0 -1
- package/dist/scene-setup/setup.js +0 -15
- package/package.json +3 -2
|
@@ -4,11 +4,10 @@ import * as fs from "node:fs/promises";
|
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
|
+
import { WS_PORT, CLONE_REQUEST_TYPE, CLONE_RESPONSE_TYPE, SCENE_FILE_NAME, } from "../lib/constants.js";
|
|
8
|
+
import { getExecErrorText } from "../lib/utils.js";
|
|
7
9
|
import { parseSceneJson, unpackageScene } from "../lib/scene/scene-packaging.js";
|
|
8
10
|
import { setupSceneDirectory } from "../scene-setup/setup.js";
|
|
9
|
-
const WS_PORT = 31113;
|
|
10
|
-
const CLONE_REQUEST_TYPE = "phibelle-kit-clone-request";
|
|
11
|
-
const CLONE_RESPONSE_TYPE = "phibelle-kit-clone-response";
|
|
12
11
|
const CLONE_TIMEOUT_MS = 30_000;
|
|
13
12
|
const INITIAL_COMMIT_MESSAGE = "Initial scene clone from Phibelle";
|
|
14
13
|
const execFileAsync = promisify(execFile);
|
|
@@ -26,7 +25,7 @@ export async function cloneSceneCommand() {
|
|
|
26
25
|
await ensureDirectoryDoesNotExist(sceneDir);
|
|
27
26
|
await fs.mkdir(sceneDir, { recursive: false });
|
|
28
27
|
setupSceneDirectory(sceneDir);
|
|
29
|
-
await fs.writeFile(path.join(sceneDir,
|
|
28
|
+
await fs.writeFile(path.join(sceneDir, SCENE_FILE_NAME), payload.sceneData, "utf8");
|
|
30
29
|
await unpackageScene(payload.sceneData, sceneDir, payload.sceneId);
|
|
31
30
|
await initializeGitRepository(sceneDir);
|
|
32
31
|
console.log(chalk.green(" ✓ Scene cloned successfully"));
|
|
@@ -148,7 +147,7 @@ async function initializeGitRepository(sceneDir) {
|
|
|
148
147
|
}
|
|
149
148
|
catch (error) {
|
|
150
149
|
console.log(chalk.yellow(" ! Git initialization or first commit was skipped"));
|
|
151
|
-
const details =
|
|
150
|
+
const details = getExecErrorText(error);
|
|
152
151
|
if (details) {
|
|
153
152
|
console.log(chalk.gray(` ${details}`));
|
|
154
153
|
}
|
|
@@ -158,12 +157,3 @@ async function initializeGitRepository(sceneDir) {
|
|
|
158
157
|
async function runGit(args, cwd) {
|
|
159
158
|
await execFileAsync("git", args, { cwd });
|
|
160
159
|
}
|
|
161
|
-
function getErrorText(error) {
|
|
162
|
-
if (!error || typeof error !== "object")
|
|
163
|
-
return "";
|
|
164
|
-
const err = error;
|
|
165
|
-
const stderr = err.stderr?.trim();
|
|
166
|
-
if (stderr)
|
|
167
|
-
return stderr.split("\n").pop() ?? stderr;
|
|
168
|
-
return err.message?.trim() ?? "";
|
|
169
|
-
}
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import * as fs from "fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import readline from "node:readline";
|
|
4
5
|
import { WebSocketServer } from "ws";
|
|
6
|
+
import { WS_PORT, SCENE_SYNC_TYPE, SCENE_FILE_NAME } from "../lib/constants.js";
|
|
7
|
+
import { getRequiredManifestSceneId } from "../lib/manifest.js";
|
|
5
8
|
import { unpackageScene, parseSceneJson, packageSceneFromFilesystem } from "../lib/scene/scene-packaging.js";
|
|
6
9
|
import { createScriptWatcher } from "../lib/scene/script-watcher.js";
|
|
7
10
|
import { toErrorMessage } from "../lib/utils.js";
|
|
8
|
-
import readline from "readline";
|
|
9
11
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
10
|
-
const WS_PORT = 31113;
|
|
11
|
-
const SCENE_SYNC_TYPE = "phibelle-kit-scene-sync";
|
|
12
|
-
const SCENE_FILE_NAME = "scene.phibelle";
|
|
13
|
-
const MANIFEST_FILE_NAME = "manifest.json";
|
|
14
12
|
export const BASE_URL = process.env.NODE_ENV === "development" ? "http://localhost:3131" : "https://phibelle.studio";
|
|
15
13
|
export async function watchSceneCommand() {
|
|
16
14
|
const sceneDir = process.cwd();
|
|
@@ -186,27 +184,3 @@ function askQuestion(prompt) {
|
|
|
186
184
|
rl.question(prompt, resolve);
|
|
187
185
|
});
|
|
188
186
|
}
|
|
189
|
-
function getRequiredManifestSceneId(sceneDir) {
|
|
190
|
-
const manifestPath = path.join(sceneDir, MANIFEST_FILE_NAME);
|
|
191
|
-
if (!fs.existsSync(manifestPath)) {
|
|
192
|
-
throw new Error(`Missing ${MANIFEST_FILE_NAME} in ${sceneDir}. Run "phibelle-kit clone" first or watch from a cloned scene folder.`);
|
|
193
|
-
}
|
|
194
|
-
let manifestRaw;
|
|
195
|
-
try {
|
|
196
|
-
manifestRaw = fs.readFileSync(manifestPath, "utf8");
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
throw new Error(`Failed to read ${MANIFEST_FILE_NAME} in ${sceneDir}.`);
|
|
200
|
-
}
|
|
201
|
-
let manifest;
|
|
202
|
-
try {
|
|
203
|
-
manifest = JSON.parse(manifestRaw);
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
throw new Error(`Invalid ${MANIFEST_FILE_NAME}: expected valid JSON.`);
|
|
207
|
-
}
|
|
208
|
-
if (typeof manifest.sceneId !== "string" || !manifest.sceneId.trim()) {
|
|
209
|
-
throw new Error(`Invalid ${MANIFEST_FILE_NAME}: missing required "sceneId".`);
|
|
210
|
-
}
|
|
211
|
-
return manifest.sceneId.trim();
|
|
212
|
-
}
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { watchSceneCommand } from "./commands/watch-scene.js";
|
|
4
|
-
import { initSceneCommand } from "./commands/init-scene.js";
|
|
5
4
|
import { cloneSceneCommand } from "./commands/clone-scene.js";
|
|
6
|
-
const USAGE = " Usage: phibelle-kit
|
|
5
|
+
const USAGE = " Usage: phibelle-kit watch | phibelle-kit clone";
|
|
7
6
|
async function main() {
|
|
8
7
|
const command = process.argv[2];
|
|
9
|
-
if (command === "init") {
|
|
10
|
-
initSceneCommand();
|
|
11
|
-
return;
|
|
12
|
-
}
|
|
13
8
|
if (command === "watch") {
|
|
14
9
|
await watchSceneCommand();
|
|
15
10
|
return;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** WebSocket port for phibelle-kit CLI ↔ web app communication. Must match app's use-cli-websocket.ts */
|
|
2
|
+
export declare const WS_PORT = 31113;
|
|
3
|
+
/** Scene JSON file in cloned scene directory */
|
|
4
|
+
export declare const SCENE_FILE_NAME = "scene.phibelle";
|
|
5
|
+
/** Manifest file mapping entity IDs to filesystem paths */
|
|
6
|
+
export declare const MANIFEST_FILE_NAME = "manifest.json";
|
|
7
|
+
/** Message type for scene sync over WebSocket */
|
|
8
|
+
export declare const SCENE_SYNC_TYPE = "phibelle-kit-scene-sync";
|
|
9
|
+
/** Message type for clone request from kit to app */
|
|
10
|
+
export declare const CLONE_REQUEST_TYPE = "phibelle-kit-clone-request";
|
|
11
|
+
/** Message type for clone response from app to kit */
|
|
12
|
+
export declare const CLONE_RESPONSE_TYPE = "phibelle-kit-clone-response";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** WebSocket port for phibelle-kit CLI ↔ web app communication. Must match app's use-cli-websocket.ts */
|
|
2
|
+
export const WS_PORT = 31113;
|
|
3
|
+
/** Scene JSON file in cloned scene directory */
|
|
4
|
+
export const SCENE_FILE_NAME = "scene.phibelle";
|
|
5
|
+
/** Manifest file mapping entity IDs to filesystem paths */
|
|
6
|
+
export const MANIFEST_FILE_NAME = "manifest.json";
|
|
7
|
+
/** Message type for scene sync over WebSocket */
|
|
8
|
+
export const SCENE_SYNC_TYPE = "phibelle-kit-scene-sync";
|
|
9
|
+
/** Message type for clone request from kit to app */
|
|
10
|
+
export const CLONE_REQUEST_TYPE = "phibelle-kit-clone-request";
|
|
11
|
+
/** Message type for clone response from app to kit */
|
|
12
|
+
export const CLONE_RESPONSE_TYPE = "phibelle-kit-clone-response";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { MANIFEST_FILE_NAME } from "./constants.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read manifest.json from sceneDir and return the required sceneId.
|
|
6
|
+
* Throws with user-facing errors if manifest is missing, unreadable, invalid JSON, or missing sceneId.
|
|
7
|
+
*/
|
|
8
|
+
export function getRequiredManifestSceneId(sceneDir) {
|
|
9
|
+
const manifestPath = path.join(sceneDir, MANIFEST_FILE_NAME);
|
|
10
|
+
if (!fs.existsSync(manifestPath)) {
|
|
11
|
+
throw new Error(`Missing ${MANIFEST_FILE_NAME} in ${sceneDir}. Run "phibelle-kit clone" first or watch from a cloned scene folder.`);
|
|
12
|
+
}
|
|
13
|
+
let manifestRaw;
|
|
14
|
+
try {
|
|
15
|
+
manifestRaw = fs.readFileSync(manifestPath, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`Failed to read ${MANIFEST_FILE_NAME} in ${sceneDir}.`);
|
|
19
|
+
}
|
|
20
|
+
let manifest;
|
|
21
|
+
try {
|
|
22
|
+
manifest = JSON.parse(manifestRaw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`Invalid ${MANIFEST_FILE_NAME}: expected valid JSON.`);
|
|
26
|
+
}
|
|
27
|
+
if (typeof manifest.sceneId !== "string" || !manifest.sceneId.trim()) {
|
|
28
|
+
throw new Error(`Invalid ${MANIFEST_FILE_NAME}: missing required "sceneId".`);
|
|
29
|
+
}
|
|
30
|
+
return manifest.sceneId.trim();
|
|
31
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { SCENE_FILE_NAME } from "../constants.js";
|
|
1
2
|
export declare const SCENE_APP_DIR = "app";
|
|
2
|
-
export
|
|
3
|
+
export { SCENE_FILE_NAME };
|
|
3
4
|
export declare function getSceneAppDir(sceneDir: string): string;
|
|
4
5
|
export declare function folderExists(folderPath: string): boolean;
|
|
5
6
|
export declare function createFolder(folderPath: string): void;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import * as fs from "fs";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { SCENE_FILE_NAME } from "../constants.js";
|
|
3
4
|
export const SCENE_APP_DIR = "app";
|
|
4
|
-
export
|
|
5
|
+
export { SCENE_FILE_NAME };
|
|
5
6
|
export function getSceneAppDir(sceneDir) {
|
|
6
7
|
return path.join(sceneDir, SCENE_APP_DIR);
|
|
7
8
|
}
|
|
@@ -2,20 +2,16 @@ import type { PhibelleScene } from "../types.js";
|
|
|
2
2
|
/** Scene-level file names in app/. */
|
|
3
3
|
export declare const SCENE_SCRIPT_FILE = "scene-script.tsx";
|
|
4
4
|
export declare const SCENE_PROPERTIES_FILE = "scene-properties.json";
|
|
5
|
-
export declare function parseSceneJson(content: string): PhibelleScene;
|
|
6
|
-
/** Convert entity display name to slug: "Main Enemy" → "main-enemy". */
|
|
7
|
-
export declare function entityNameToSlug(name: string): string;
|
|
8
|
-
/** Convert slug to display name: "main-enemy" → "Main Enemy". */
|
|
9
|
-
export declare function slugToEntityName(slug: string): string;
|
|
10
5
|
/** Entity filenames inside an entity folder. */
|
|
11
6
|
export declare const ENTITY_SCRIPT_FILE = "script.tsx";
|
|
12
7
|
export declare const ENTITY_PROPERTIES_FILE = "properties.json";
|
|
13
8
|
export declare const ENTITY_TRANSFORMS_FILE = "transforms.json";
|
|
9
|
+
export declare function parseSceneJson(content: string): PhibelleScene;
|
|
10
|
+
/** Convert entity display name to slug: "Main Enemy" -> "main-enemy". */
|
|
11
|
+
export declare function entityNameToSlug(name: string): string;
|
|
12
|
+
/** Convert slug to display name: "main-enemy" -> "Main Enemy". */
|
|
13
|
+
export declare function slugToEntityName(slug: string): string;
|
|
14
14
|
/** Unpackage scene JSON to filesystem. No DB; accepts full scene JSON string. */
|
|
15
15
|
export declare function unpackageScene(sceneJson: string, sceneDir: string, sceneId?: string): Promise<void>;
|
|
16
|
-
|
|
17
|
-
* Apply a single file change to the in-memory scene and return updated JSON.
|
|
18
|
-
* Used by script watcher to produce scene snapshot to send to FE.
|
|
19
|
-
*/
|
|
20
|
-
export declare function patchSceneFromFile(sceneJson: string, sceneDir: string, relativePath: string, eventType: "change" | "add"): Promise<string>;
|
|
16
|
+
export declare function patchSceneFromFile(sceneJson: string, sceneDir: string, _relativePath: string, _eventType: "change" | "add" | "unlink" | "addDir" | "unlinkDir"): Promise<string>;
|
|
21
17
|
export declare function packageSceneFromFilesystem(sceneJson: string, sceneDir: string): Promise<string>;
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import * as fs from "fs/promises";
|
|
2
|
-
import * as path from "path";
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
3
|
import { getSceneAppDir } from "./file-system.js";
|
|
4
|
+
import { MANIFEST_FILE_NAME } from "../constants.js";
|
|
5
|
+
const LEGACY_SCENE_FILE = "scene.tsx";
|
|
6
|
+
const CHILDREN_DIR_NAME = "children";
|
|
4
7
|
/** Scene-level file names in app/. */
|
|
5
8
|
export const SCENE_SCRIPT_FILE = "scene-script.tsx";
|
|
6
9
|
export const SCENE_PROPERTIES_FILE = "scene-properties.json";
|
|
10
|
+
/** Entity filenames inside an entity folder. */
|
|
11
|
+
export const ENTITY_SCRIPT_FILE = "script.tsx";
|
|
12
|
+
export const ENTITY_PROPERTIES_FILE = "properties.json";
|
|
13
|
+
export const ENTITY_TRANSFORMS_FILE = "transforms.json";
|
|
14
|
+
const DEFAULT_POSITION = '{"x":0,"y":0,"z":0}';
|
|
15
|
+
const DEFAULT_ROTATION = '{"isEuler":true,"_x":0,"_y":0,"_z":0,"_order":"XYZ"}';
|
|
16
|
+
const DEFAULT_SCALE = '{"x":1,"y":1,"z":1}';
|
|
7
17
|
export function parseSceneJson(content) {
|
|
8
18
|
try {
|
|
9
19
|
return JSON.parse(content);
|
|
@@ -12,7 +22,7 @@ export function parseSceneJson(content) {
|
|
|
12
22
|
throw new Error("Invalid scene file: not valid JSON");
|
|
13
23
|
}
|
|
14
24
|
}
|
|
15
|
-
/** Convert entity display name to slug: "Main Enemy"
|
|
25
|
+
/** Convert entity display name to slug: "Main Enemy" -> "main-enemy". */
|
|
16
26
|
export function entityNameToSlug(name) {
|
|
17
27
|
const slug = name
|
|
18
28
|
.toLowerCase()
|
|
@@ -20,7 +30,7 @@ export function entityNameToSlug(name) {
|
|
|
20
30
|
.replace(/^-|-$/g, "");
|
|
21
31
|
return slug || "entity";
|
|
22
32
|
}
|
|
23
|
-
/** Convert slug to display name: "main-enemy"
|
|
33
|
+
/** Convert slug to display name: "main-enemy" -> "Main Enemy". */
|
|
24
34
|
export function slugToEntityName(slug) {
|
|
25
35
|
if (!slug || slug === "entity")
|
|
26
36
|
return "Entity";
|
|
@@ -29,13 +39,6 @@ export function slugToEntityName(slug) {
|
|
|
29
39
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
30
40
|
.join(" ");
|
|
31
41
|
}
|
|
32
|
-
/** Entity filenames inside an entity folder. */
|
|
33
|
-
export const ENTITY_SCRIPT_FILE = "script.tsx";
|
|
34
|
-
export const ENTITY_PROPERTIES_FILE = "properties.json";
|
|
35
|
-
export const ENTITY_TRANSFORMS_FILE = "transforms.json";
|
|
36
|
-
const DEFAULT_POSITION = '{"x":0,"y":0,"z":0}';
|
|
37
|
-
const DEFAULT_ROTATION = '{"isEuler":true,"_x":0,"_y":0,"_z":0,"_order":"XYZ"}';
|
|
38
|
-
const DEFAULT_SCALE = '{"x":1,"y":1,"z":1}';
|
|
39
42
|
/** Unpackage scene JSON to filesystem. No DB; accepts full scene JSON string. */
|
|
40
43
|
export async function unpackageScene(sceneJson, sceneDir, sceneId) {
|
|
41
44
|
const parsed = parseSceneJson(sceneJson);
|
|
@@ -43,288 +46,508 @@ export async function unpackageScene(sceneJson, sceneDir, sceneId) {
|
|
|
43
46
|
? parsed._id
|
|
44
47
|
: "";
|
|
45
48
|
const appDir = getSceneAppDir(sceneDir);
|
|
49
|
+
const previousManifest = await readManifest(sceneDir);
|
|
46
50
|
await fs.mkdir(appDir, { recursive: true });
|
|
51
|
+
await cleanupKnownEntityDirectories(appDir, previousManifest);
|
|
52
|
+
await cleanupLegacySceneFiles(appDir);
|
|
53
|
+
const manifest = {
|
|
54
|
+
sceneId: sceneId?.trim() || parsedSceneId,
|
|
55
|
+
sceneScript: SCENE_SCRIPT_FILE,
|
|
56
|
+
sceneProperties: SCENE_PROPERTIES_FILE,
|
|
57
|
+
entities: {},
|
|
58
|
+
};
|
|
59
|
+
await writeSceneFiles(parsed, appDir);
|
|
60
|
+
const store = ensureEntityStore(parsed);
|
|
61
|
+
const entities = store.entities ?? {};
|
|
62
|
+
const usedFolderNamesByParent = new Map();
|
|
63
|
+
const rootIds = getRootEntityIds(store);
|
|
64
|
+
for (const rootId of rootIds) {
|
|
65
|
+
await writeEntityTree({
|
|
66
|
+
appDir,
|
|
67
|
+
entities,
|
|
68
|
+
entityId: rootId,
|
|
69
|
+
manifest,
|
|
70
|
+
usedFolderNamesByParent,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
await fs.mkdir(sceneDir, { recursive: true });
|
|
74
|
+
await writeManifest(sceneDir, manifest);
|
|
75
|
+
}
|
|
76
|
+
export async function patchSceneFromFile(sceneJson, sceneDir, _relativePath, _eventType) {
|
|
77
|
+
return packageSceneFromFilesystem(sceneJson, sceneDir);
|
|
78
|
+
}
|
|
79
|
+
export async function packageSceneFromFilesystem(sceneJson, sceneDir) {
|
|
80
|
+
const manifest = await readManifest(sceneDir);
|
|
81
|
+
const parsed = parseSceneJson(sceneJson);
|
|
82
|
+
const appDir = getSceneAppDir(sceneDir);
|
|
83
|
+
await applySceneFileChanges(parsed, appDir, manifest);
|
|
84
|
+
await reconcileEntitiesFromFilesystem(parsed, manifest, appDir);
|
|
85
|
+
await writeManifest(sceneDir, manifest);
|
|
86
|
+
return JSON.stringify(parsed);
|
|
87
|
+
}
|
|
88
|
+
function normalizeRelativePath(relativePath) {
|
|
89
|
+
return relativePath.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, "");
|
|
90
|
+
}
|
|
91
|
+
function getEntityFolderFromRelativePath(relativePath) {
|
|
92
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
93
|
+
const fileName = path.posix.basename(normalized);
|
|
94
|
+
if (fileName === ENTITY_SCRIPT_FILE ||
|
|
95
|
+
fileName === ENTITY_PROPERTIES_FILE ||
|
|
96
|
+
fileName === ENTITY_TRANSFORMS_FILE) {
|
|
97
|
+
const entityDir = path.posix.dirname(normalized);
|
|
98
|
+
return entityDir === "." ? null : entityDir;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function getEntityIdFromRelativePath(manifest, relativePath) {
|
|
103
|
+
const entityDir = getEntityFolderFromRelativePath(relativePath);
|
|
104
|
+
if (!entityDir)
|
|
105
|
+
return null;
|
|
106
|
+
return Object.entries(manifest.entities).find(([, folderPath]) => folderPath === entityDir)?.[0] ?? null;
|
|
107
|
+
}
|
|
108
|
+
function ensureEntityStore(parsed) {
|
|
109
|
+
if (!parsed.entityStore) {
|
|
110
|
+
parsed.entityStore = {
|
|
111
|
+
_currId: 0,
|
|
112
|
+
rootEntities: [],
|
|
113
|
+
entities: {},
|
|
114
|
+
rootRenderVersion: 0,
|
|
115
|
+
lastRenderedVersion: 0,
|
|
116
|
+
selectedEntityIds: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!parsed.entityStore.entities) {
|
|
120
|
+
parsed.entityStore.entities = {};
|
|
121
|
+
}
|
|
122
|
+
if (!Array.isArray(parsed.entityStore.rootEntities)) {
|
|
123
|
+
parsed.entityStore.rootEntities = [];
|
|
124
|
+
}
|
|
125
|
+
return parsed.entityStore;
|
|
126
|
+
}
|
|
127
|
+
function getRootEntityIds(store) {
|
|
128
|
+
if (Array.isArray(store.rootEntities) && store.rootEntities.length > 0) {
|
|
129
|
+
return store.rootEntities;
|
|
130
|
+
}
|
|
131
|
+
return Object.values(store.entities ?? {})
|
|
132
|
+
.filter((entity) => entity.parentId === undefined)
|
|
133
|
+
.map((entity) => entity.engineId);
|
|
134
|
+
}
|
|
135
|
+
async function readManifest(sceneDir) {
|
|
136
|
+
try {
|
|
137
|
+
const manifestRaw = await fs.readFile(path.join(sceneDir, MANIFEST_FILE_NAME), "utf8");
|
|
138
|
+
const parsed = JSON.parse(manifestRaw);
|
|
139
|
+
return {
|
|
140
|
+
sceneId: typeof parsed.sceneId === "string" ? parsed.sceneId : "",
|
|
141
|
+
sceneScript: typeof parsed.sceneScript === "string" ? parsed.sceneScript : SCENE_SCRIPT_FILE,
|
|
142
|
+
sceneProperties: typeof parsed.sceneProperties === "string" ? parsed.sceneProperties : SCENE_PROPERTIES_FILE,
|
|
143
|
+
entities: parsed.entities ?? {},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return {
|
|
148
|
+
sceneId: "",
|
|
149
|
+
sceneScript: SCENE_SCRIPT_FILE,
|
|
150
|
+
sceneProperties: SCENE_PROPERTIES_FILE,
|
|
151
|
+
entities: {},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function writeManifest(sceneDir, manifest) {
|
|
156
|
+
await fs.writeFile(path.join(sceneDir, MANIFEST_FILE_NAME), JSON.stringify(manifest, null, 2), "utf8");
|
|
157
|
+
}
|
|
158
|
+
async function cleanupLegacySceneFiles(appDir) {
|
|
47
159
|
try {
|
|
48
160
|
const entries = await fs.readdir(appDir, { withFileTypes: true });
|
|
49
|
-
for (const
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await fs.unlink(path.join(appDir, ent.name));
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (!entry.isFile())
|
|
163
|
+
continue;
|
|
164
|
+
if (entry.name === LEGACY_SCENE_FILE) {
|
|
165
|
+
await fs.rm(path.join(appDir, entry.name), { force: true });
|
|
55
166
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
await fs.access(scriptPath);
|
|
60
|
-
await fs.rm(path.join(appDir, ent.name), { recursive: true });
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
/* no script.tsx, leave dir alone */
|
|
64
|
-
}
|
|
167
|
+
else if (entry.name.endsWith(".tsx") && entry.name !== SCENE_SCRIPT_FILE) {
|
|
168
|
+
await fs.rm(path.join(appDir, entry.name), { force: true });
|
|
65
169
|
}
|
|
66
170
|
}
|
|
67
171
|
}
|
|
68
172
|
catch {
|
|
69
|
-
|
|
173
|
+
// app directory may not exist yet
|
|
70
174
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
175
|
+
}
|
|
176
|
+
async function cleanupKnownEntityDirectories(appDir, manifest) {
|
|
177
|
+
const knownPaths = Array.from(new Set(Object.values(manifest.entities)
|
|
178
|
+
.map((relativePath) => normalizeRelativePath(relativePath))
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.sort((a, b) => b.split("/").length - a.split("/").length)));
|
|
181
|
+
for (const relativePath of knownPaths) {
|
|
182
|
+
await fs.rm(path.join(appDir, relativePath), { recursive: true, force: true });
|
|
183
|
+
await removeEmptyAncestorChildrenDirs(appDir, path.posix.dirname(relativePath));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async function removeEmptyAncestorChildrenDirs(appDir, relativeDir) {
|
|
187
|
+
let current = relativeDir;
|
|
188
|
+
while (current && current !== ".") {
|
|
189
|
+
const basename = path.posix.basename(current);
|
|
190
|
+
if (basename !== CHILDREN_DIR_NAME) {
|
|
191
|
+
current = path.posix.dirname(current);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const absoluteDir = path.join(appDir, current);
|
|
195
|
+
try {
|
|
196
|
+
const entries = await fs.readdir(absoluteDir);
|
|
197
|
+
if (entries.length > 0)
|
|
198
|
+
break;
|
|
199
|
+
await fs.rmdir(absoluteDir);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
current = path.posix.dirname(current);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function writeSceneFiles(parsed, appDir) {
|
|
78
208
|
await fs.writeFile(path.join(appDir, SCENE_SCRIPT_FILE), parsed.sceneScriptStore?.sceneScript ?? "", "utf8");
|
|
79
209
|
const scenePropertiesJson = JSON.stringify(parsed.sceneScriptStore?.sceneProperties ?? [], null, 2);
|
|
80
210
|
await fs.writeFile(path.join(appDir, SCENE_PROPERTIES_FILE), scenePropertiesJson, "utf8");
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
211
|
+
}
|
|
212
|
+
async function writeEntityTree(args) {
|
|
213
|
+
const { appDir, entities, entityId, manifest, parentRelativePath, usedFolderNamesByParent } = args;
|
|
214
|
+
const entity = entities[String(entityId)];
|
|
215
|
+
if (!entity || typeof entity.script !== "string")
|
|
216
|
+
return;
|
|
217
|
+
const parentKey = parentRelativePath ?? "__root__";
|
|
218
|
+
const siblingNames = usedFolderNamesByParent.get(parentKey) ?? new Set();
|
|
219
|
+
usedFolderNamesByParent.set(parentKey, siblingNames);
|
|
220
|
+
const engineId = String(entity.engineId ?? entityId);
|
|
221
|
+
const baseName = (entity.name ?? "Entity").trim() || "Entity";
|
|
222
|
+
let folderName = baseName;
|
|
223
|
+
if (siblingNames.has(folderName))
|
|
224
|
+
folderName = `${baseName}-${engineId}`;
|
|
225
|
+
siblingNames.add(folderName);
|
|
226
|
+
const relativePath = parentRelativePath
|
|
227
|
+
? path.posix.join(parentRelativePath, CHILDREN_DIR_NAME, folderName)
|
|
228
|
+
: folderName;
|
|
229
|
+
const entityDir = path.join(appDir, relativePath);
|
|
230
|
+
await fs.mkdir(entityDir, { recursive: true });
|
|
231
|
+
manifest.entities[engineId] = relativePath;
|
|
232
|
+
await writeEntityFiles(entityDir, entity);
|
|
233
|
+
for (const childId of entity.childrenIds ?? []) {
|
|
234
|
+
await writeEntityTree({
|
|
235
|
+
appDir,
|
|
236
|
+
entities,
|
|
237
|
+
entityId: childId,
|
|
238
|
+
manifest,
|
|
239
|
+
parentRelativePath: relativePath,
|
|
240
|
+
usedFolderNamesByParent,
|
|
241
|
+
});
|
|
109
242
|
}
|
|
110
|
-
await fs.mkdir(sceneDir, { recursive: true });
|
|
111
|
-
await fs.writeFile(path.join(sceneDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
|
112
243
|
}
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
parsed.sceneScriptStore
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
parsed.sceneScriptStore = { sceneScript: "", sceneProperties: [], lastModifiedSceneScript: 0 };
|
|
147
|
-
const nextSceneProperties = Array.isArray(sceneProperties) ? sceneProperties : [];
|
|
148
|
-
if (JSON.stringify(parsed.sceneScriptStore.sceneProperties ?? []) === JSON.stringify(nextSceneProperties)) {
|
|
149
|
-
return sceneJson;
|
|
244
|
+
async function writeEntityFiles(entityDir, entity) {
|
|
245
|
+
await fs.writeFile(path.join(entityDir, ENTITY_SCRIPT_FILE), entity.script, "utf8");
|
|
246
|
+
await fs.writeFile(path.join(entityDir, ENTITY_PROPERTIES_FILE), JSON.stringify(entity.properties ?? [], null, 2), "utf8");
|
|
247
|
+
const transform = entity.transform ?? {
|
|
248
|
+
position: DEFAULT_POSITION,
|
|
249
|
+
rotation: DEFAULT_ROTATION,
|
|
250
|
+
scale: DEFAULT_SCALE,
|
|
251
|
+
};
|
|
252
|
+
const transformsData = {
|
|
253
|
+
position: safeJsonParse(transform.position, JSON.parse(DEFAULT_POSITION)),
|
|
254
|
+
rotation: safeJsonParse(transform.rotation, JSON.parse(DEFAULT_ROTATION)),
|
|
255
|
+
scale: safeJsonParse(transform.scale, JSON.parse(DEFAULT_SCALE)),
|
|
256
|
+
};
|
|
257
|
+
await fs.writeFile(path.join(entityDir, ENTITY_TRANSFORMS_FILE), JSON.stringify(transformsData, null, 2), "utf8");
|
|
258
|
+
}
|
|
259
|
+
function safeJsonParse(value, fallback) {
|
|
260
|
+
try {
|
|
261
|
+
return JSON.parse(value);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return fallback;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function applySceneFileChanges(parsed, appDir, manifest) {
|
|
268
|
+
if (!parsed.sceneScriptStore) {
|
|
269
|
+
parsed.sceneScriptStore = { sceneScript: "", sceneProperties: [], lastModifiedSceneScript: 0 };
|
|
270
|
+
}
|
|
271
|
+
const sceneScriptPath = path.join(appDir, manifest.sceneScript ?? SCENE_SCRIPT_FILE);
|
|
272
|
+
try {
|
|
273
|
+
const sceneScript = await fs.readFile(sceneScriptPath, "utf8");
|
|
274
|
+
if (parsed.sceneScriptStore.sceneScript !== sceneScript) {
|
|
275
|
+
parsed.sceneScriptStore.sceneScript = sceneScript;
|
|
276
|
+
parsed.sceneScriptStore.lastModifiedSceneScript = Date.now();
|
|
150
277
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// keep existing script if file is missing
|
|
281
|
+
}
|
|
282
|
+
const scenePropertiesPath = path.join(appDir, manifest.sceneProperties ?? SCENE_PROPERTIES_FILE);
|
|
283
|
+
try {
|
|
284
|
+
const rawSceneProperties = await fs.readFile(scenePropertiesPath, "utf8");
|
|
285
|
+
const sceneProperties = JSON.parse(rawSceneProperties);
|
|
286
|
+
parsed.sceneScriptStore.sceneProperties = Array.isArray(sceneProperties) ? sceneProperties : [];
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// keep existing properties if file is missing or invalid
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function reconcileEntitiesFromFilesystem(parsed, manifest, appDir) {
|
|
293
|
+
const store = ensureEntityStore(parsed);
|
|
294
|
+
const entities = store.entities;
|
|
295
|
+
const previousRootEntities = [...store.rootEntities];
|
|
296
|
+
const previousChildrenById = new Map();
|
|
297
|
+
for (const [entityId, entity] of Object.entries(entities)) {
|
|
298
|
+
previousChildrenById.set(entityId, [...(entity.childrenIds ?? [])]);
|
|
299
|
+
}
|
|
300
|
+
const discoveredEntities = await scanFilesystemEntities(appDir);
|
|
301
|
+
const discoveredByPath = new Map(discoveredEntities.map((entity) => [entity.relativePath, entity]));
|
|
302
|
+
const currentIds = Object.keys(entities);
|
|
303
|
+
const assignedEntityIds = new Set();
|
|
304
|
+
const discoveryIdByPath = new Map();
|
|
305
|
+
for (const [entityId, entityPath] of Object.entries(manifest.entities)) {
|
|
306
|
+
if (!entities[entityId])
|
|
307
|
+
continue;
|
|
308
|
+
const normalizedPath = normalizeRelativePath(entityPath);
|
|
309
|
+
if (!discoveredByPath.has(normalizedPath))
|
|
310
|
+
continue;
|
|
311
|
+
assignedEntityIds.add(entityId);
|
|
312
|
+
discoveryIdByPath.set(normalizedPath, entityId);
|
|
313
|
+
}
|
|
314
|
+
const unmatchedEntityIds = currentIds.filter((entityId) => !assignedEntityIds.has(entityId));
|
|
315
|
+
const unmatchedDiscoveries = discoveredEntities.filter((entity) => !discoveryIdByPath.has(entity.relativePath));
|
|
316
|
+
matchDiscoveriesByFolderName(unmatchedDiscoveries, unmatchedEntityIds, entities, manifest, assignedEntityIds, discoveryIdByPath);
|
|
317
|
+
matchDiscoveriesByContent(unmatchedDiscoveries, unmatchedEntityIds, entities, assignedEntityIds, discoveryIdByPath);
|
|
318
|
+
let nextEntityId = Math.max(store._currId, ...currentIds.map(Number).filter(Number.isFinite), 0);
|
|
319
|
+
for (const discovery of unmatchedDiscoveries) {
|
|
320
|
+
if (discoveryIdByPath.has(discovery.relativePath))
|
|
321
|
+
continue;
|
|
322
|
+
nextEntityId += 1;
|
|
323
|
+
const entityId = String(nextEntityId);
|
|
324
|
+
assignedEntityIds.add(entityId);
|
|
325
|
+
discoveryIdByPath.set(discovery.relativePath, entityId);
|
|
326
|
+
entities[entityId] = createEntityFromDiscovery(Number(entityId), discovery);
|
|
327
|
+
}
|
|
328
|
+
store._currId = nextEntityId;
|
|
329
|
+
for (const discovery of discoveredEntities) {
|
|
330
|
+
const entityId = discoveryIdByPath.get(discovery.relativePath);
|
|
331
|
+
if (!entityId)
|
|
332
|
+
continue;
|
|
333
|
+
const entity = entities[entityId] ?? createEntityFromDiscovery(Number(entityId), discovery);
|
|
334
|
+
entity.engineId = Number(entityId);
|
|
335
|
+
entity.name = discovery.folderName;
|
|
336
|
+
if (entity.script !== discovery.script) {
|
|
167
337
|
entity.scriptVersion = (entity.scriptVersion ?? 0) + 1;
|
|
168
|
-
|
|
338
|
+
entity.script = discovery.script;
|
|
169
339
|
}
|
|
170
|
-
|
|
171
|
-
|
|
340
|
+
entity.properties = discovery.properties;
|
|
341
|
+
entity.transform = discovery.transform;
|
|
342
|
+
entity.childrenIds = [];
|
|
343
|
+
entity.childrenIdsSet = [];
|
|
344
|
+
entities[entityId] = entity;
|
|
345
|
+
}
|
|
346
|
+
const desiredParentById = new Map();
|
|
347
|
+
const desiredChildrenByParent = new Map();
|
|
348
|
+
for (const discovery of discoveredEntities) {
|
|
349
|
+
const entityId = discoveryIdByPath.get(discovery.relativePath);
|
|
350
|
+
if (!entityId)
|
|
351
|
+
continue;
|
|
352
|
+
const parentId = discovery.parentRelativePath != null
|
|
353
|
+
? discoveryIdByPath.get(discovery.parentRelativePath)
|
|
354
|
+
: undefined;
|
|
355
|
+
desiredParentById.set(entityId, parentId);
|
|
356
|
+
const siblings = desiredChildrenByParent.get(parentId) ?? [];
|
|
357
|
+
siblings.push(entityId);
|
|
358
|
+
desiredChildrenByParent.set(parentId, siblings);
|
|
359
|
+
}
|
|
360
|
+
const finalEntityIds = new Set(discoveryIdByPath.values());
|
|
361
|
+
for (const entityId of Object.keys(entities)) {
|
|
362
|
+
if (!finalEntityIds.has(entityId)) {
|
|
363
|
+
delete entities[entityId];
|
|
172
364
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const propsMatch = relativePath.match(new RegExp(`^([^/]+)\\/${ENTITY_PROPERTIES_FILE.replace(".", "\\.")}$`));
|
|
176
|
-
if (propsMatch) {
|
|
177
|
-
const entityId = getEntityIdFromRelativePath(manifest, relativePath);
|
|
178
|
-
if (entityId == null)
|
|
179
|
-
return sceneJson;
|
|
180
|
-
const fileContent = await fs.readFile(filePath, "utf8");
|
|
181
|
-
const properties = JSON.parse(fileContent);
|
|
182
|
-
const entities = parsed.entityStore?.entities ?? {};
|
|
365
|
+
}
|
|
366
|
+
for (const entityId of finalEntityIds) {
|
|
183
367
|
const entity = entities[entityId];
|
|
184
368
|
if (!entity)
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
const transformsMatch = relativePath.match(new RegExp(`^([^/]+)\\/${ENTITY_TRANSFORMS_FILE.replace(".", "\\.")}$`));
|
|
193
|
-
if (transformsMatch) {
|
|
194
|
-
const entityId = getEntityIdFromRelativePath(manifest, relativePath);
|
|
195
|
-
if (entityId == null)
|
|
196
|
-
return sceneJson;
|
|
197
|
-
const fileContent = await fs.readFile(filePath, "utf8");
|
|
198
|
-
const data = JSON.parse(fileContent);
|
|
199
|
-
const entities = parsed.entityStore?.entities ?? {};
|
|
369
|
+
continue;
|
|
370
|
+
const desiredParentId = desiredParentById.get(entityId);
|
|
371
|
+
entity.parentId = desiredParentId != null ? Number(desiredParentId) : undefined;
|
|
372
|
+
}
|
|
373
|
+
store.rootEntities = mergeSiblingOrder(previousRootEntities.map(String), desiredChildrenByParent.get(undefined) ?? [], desiredParentById, undefined).map(Number);
|
|
374
|
+
for (const entityId of finalEntityIds) {
|
|
200
375
|
const entity = entities[entityId];
|
|
201
376
|
if (!entity)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (entity.transform.position === nextPosition &&
|
|
209
|
-
entity.transform.rotation === nextRotation &&
|
|
210
|
-
entity.transform.scale === nextScale) {
|
|
211
|
-
return sceneJson;
|
|
212
|
-
}
|
|
213
|
-
entity.transform.position = nextPosition;
|
|
214
|
-
entity.transform.rotation = nextRotation;
|
|
215
|
-
entity.transform.scale = nextScale;
|
|
216
|
-
return JSON.stringify(parsed);
|
|
377
|
+
continue;
|
|
378
|
+
const currentChildren = (previousChildrenById.get(entityId) ?? []).map(String);
|
|
379
|
+
const desiredChildren = desiredChildrenByParent.get(entityId) ?? [];
|
|
380
|
+
const mergedChildren = mergeSiblingOrder(currentChildren, desiredChildren, desiredParentById, entityId);
|
|
381
|
+
entity.childrenIds = mergedChildren.map(Number);
|
|
382
|
+
entity.childrenIdsSet = [...entity.childrenIds];
|
|
217
383
|
}
|
|
218
|
-
|
|
384
|
+
manifest.entities = buildManifestEntityMap(store, entities, discoveryIdByPath);
|
|
219
385
|
}
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
386
|
+
function matchDiscoveriesByFolderName(discoveries, unmatchedEntityIds, entities, manifest, assignedEntityIds, discoveryIdByPath) {
|
|
387
|
+
for (const discovery of discoveries) {
|
|
388
|
+
if (discoveryIdByPath.has(discovery.relativePath))
|
|
389
|
+
continue;
|
|
390
|
+
const candidates = unmatchedEntityIds.filter((entityId) => {
|
|
391
|
+
if (assignedEntityIds.has(entityId))
|
|
392
|
+
return false;
|
|
393
|
+
const manifestPath = manifest.entities[entityId];
|
|
394
|
+
return manifestPath != null && path.posix.basename(normalizeRelativePath(manifestPath)) === discovery.folderName;
|
|
395
|
+
});
|
|
396
|
+
if (candidates.length !== 1)
|
|
397
|
+
continue;
|
|
398
|
+
const entityId = candidates[0];
|
|
399
|
+
if (!entities[entityId])
|
|
400
|
+
continue;
|
|
401
|
+
assignedEntityIds.add(entityId);
|
|
402
|
+
discoveryIdByPath.set(discovery.relativePath, entityId);
|
|
235
403
|
}
|
|
236
|
-
|
|
237
|
-
|
|
404
|
+
}
|
|
405
|
+
function matchDiscoveriesByContent(discoveries, unmatchedEntityIds, entities, assignedEntityIds, discoveryIdByPath) {
|
|
406
|
+
const signatureToEntityIds = new Map();
|
|
407
|
+
for (const entityId of unmatchedEntityIds) {
|
|
408
|
+
if (assignedEntityIds.has(entityId) || !entities[entityId])
|
|
409
|
+
continue;
|
|
410
|
+
const signature = getEntityContentSignature(entities[entityId]);
|
|
411
|
+
const entityIds = signatureToEntityIds.get(signature) ?? [];
|
|
412
|
+
entityIds.push(entityId);
|
|
413
|
+
signatureToEntityIds.set(signature, entityIds);
|
|
238
414
|
}
|
|
239
|
-
for (const
|
|
240
|
-
if (
|
|
415
|
+
for (const discovery of discoveries) {
|
|
416
|
+
if (discoveryIdByPath.has(discovery.relativePath))
|
|
241
417
|
continue;
|
|
242
|
-
|
|
418
|
+
const candidateIds = (signatureToEntityIds.get(getDiscoveryContentSignature(discovery)) ?? []).filter((entityId) => !assignedEntityIds.has(entityId));
|
|
419
|
+
if (candidateIds.length !== 1)
|
|
243
420
|
continue;
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
packagedSceneJson = await applyIfExists(packagedSceneJson, sceneDir, path.join(entry.name, ENTITY_TRANSFORMS_FILE).replace(/\\/g, "/"));
|
|
421
|
+
const entityId = candidateIds[0];
|
|
422
|
+
assignedEntityIds.add(entityId);
|
|
423
|
+
discoveryIdByPath.set(discovery.relativePath, entityId);
|
|
248
424
|
}
|
|
249
|
-
return packagedSceneJson;
|
|
250
425
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
426
|
+
function getEntityContentSignature(entity) {
|
|
427
|
+
return JSON.stringify({
|
|
428
|
+
script: entity.script,
|
|
429
|
+
properties: entity.properties ?? [],
|
|
430
|
+
transform: entity.transform ?? {
|
|
431
|
+
position: DEFAULT_POSITION,
|
|
432
|
+
rotation: DEFAULT_ROTATION,
|
|
433
|
+
scale: DEFAULT_SCALE,
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
function getDiscoveryContentSignature(discovery) {
|
|
438
|
+
return JSON.stringify({
|
|
439
|
+
script: discovery.script,
|
|
440
|
+
properties: discovery.properties,
|
|
441
|
+
transform: discovery.transform,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
function createEntityFromDiscovery(engineId, discovery) {
|
|
445
|
+
return {
|
|
446
|
+
name: discovery.folderName,
|
|
447
|
+
engineId,
|
|
448
|
+
parentId: undefined,
|
|
449
|
+
childrenIdsSet: [],
|
|
450
|
+
childrenIds: [],
|
|
451
|
+
isNodeOpen: false,
|
|
452
|
+
script: discovery.script,
|
|
453
|
+
scriptVersion: 0,
|
|
454
|
+
transform: discovery.transform,
|
|
455
|
+
renderVersion: 0,
|
|
456
|
+
properties: discovery.properties,
|
|
457
|
+
threeId: 0,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
function mergeSiblingOrder(previousOrder, desiredOrder, desiredParentById, parentId) {
|
|
461
|
+
const desiredSet = new Set(desiredOrder);
|
|
462
|
+
const preserved = previousOrder.filter((entityId) => desiredSet.has(entityId) && desiredParentById.get(entityId) === parentId);
|
|
463
|
+
const appended = desiredOrder.filter((entityId) => !preserved.includes(entityId));
|
|
464
|
+
return [...preserved, ...appended];
|
|
465
|
+
}
|
|
466
|
+
function buildManifestEntityMap(store, entities, discoveryIdByPath) {
|
|
467
|
+
const manifestEntities = {};
|
|
468
|
+
const pathByEntityId = new Map();
|
|
469
|
+
for (const [relativePath, entityId] of discoveryIdByPath.entries()) {
|
|
470
|
+
pathByEntityId.set(entityId, relativePath);
|
|
256
471
|
}
|
|
257
|
-
|
|
258
|
-
|
|
472
|
+
function visit(entityId) {
|
|
473
|
+
const entity = entities[String(entityId)];
|
|
474
|
+
if (!entity)
|
|
475
|
+
return;
|
|
476
|
+
const relativePath = pathByEntityId.get(String(entityId));
|
|
477
|
+
if (!relativePath)
|
|
478
|
+
return;
|
|
479
|
+
manifestEntities[String(entityId)] = relativePath;
|
|
480
|
+
for (const childId of entity.childrenIds ?? []) {
|
|
481
|
+
visit(childId);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const rootId of store.rootEntities ?? []) {
|
|
485
|
+
visit(rootId);
|
|
259
486
|
}
|
|
260
|
-
return
|
|
487
|
+
return manifestEntities;
|
|
261
488
|
}
|
|
262
|
-
async function
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
489
|
+
async function scanFilesystemEntities(appDir) {
|
|
490
|
+
const discovered = [];
|
|
491
|
+
await scanEntityCollection(appDir, appDir, discovered);
|
|
492
|
+
return discovered;
|
|
493
|
+
}
|
|
494
|
+
async function scanEntityCollection(collectionDir, appDir, discovered, parentRelativePath) {
|
|
495
|
+
let entries = [];
|
|
267
496
|
try {
|
|
268
|
-
|
|
497
|
+
entries = await fs.readdir(collectionDir, { withFileTypes: true });
|
|
269
498
|
}
|
|
270
499
|
catch {
|
|
271
|
-
return
|
|
500
|
+
return;
|
|
272
501
|
}
|
|
273
|
-
|
|
502
|
+
entries = entries.filter((entry) => entry.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
|
|
503
|
+
for (const entry of entries) {
|
|
504
|
+
const entityDir = path.join(collectionDir, entry.name);
|
|
505
|
+
const relativePath = normalizeRelativePath(path.relative(appDir, entityDir));
|
|
506
|
+
const scriptPath = path.join(entityDir, ENTITY_SCRIPT_FILE);
|
|
507
|
+
let script;
|
|
508
|
+
try {
|
|
509
|
+
script = await fs.readFile(scriptPath, "utf8");
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const discovery = {
|
|
515
|
+
relativePath,
|
|
516
|
+
folderName: entry.name,
|
|
517
|
+
parentRelativePath,
|
|
518
|
+
script,
|
|
519
|
+
properties: await readPropertiesFile(entityDir),
|
|
520
|
+
transform: await readTransformFile(entityDir),
|
|
521
|
+
};
|
|
522
|
+
discovered.push(discovery);
|
|
523
|
+
await scanEntityCollection(path.join(entityDir, CHILDREN_DIR_NAME), appDir, discovered, relativePath);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function readPropertiesFile(entityDir) {
|
|
274
527
|
try {
|
|
275
|
-
const raw = await fs.readFile(path.join(
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
properties = parsedProps;
|
|
528
|
+
const raw = await fs.readFile(path.join(entityDir, ENTITY_PROPERTIES_FILE), "utf8");
|
|
529
|
+
const parsed = JSON.parse(raw);
|
|
530
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
279
531
|
}
|
|
280
532
|
catch {
|
|
281
|
-
|
|
533
|
+
return [];
|
|
282
534
|
}
|
|
283
|
-
|
|
535
|
+
}
|
|
536
|
+
async function readTransformFile(entityDir) {
|
|
284
537
|
try {
|
|
285
|
-
const raw = await fs.readFile(path.join(
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
transform.scale = JSON.stringify(t.scale);
|
|
538
|
+
const raw = await fs.readFile(path.join(entityDir, ENTITY_TRANSFORMS_FILE), "utf8");
|
|
539
|
+
const parsed = JSON.parse(raw);
|
|
540
|
+
return {
|
|
541
|
+
position: parsed.position !== undefined ? JSON.stringify(parsed.position) : DEFAULT_POSITION,
|
|
542
|
+
rotation: parsed.rotation !== undefined ? JSON.stringify(parsed.rotation) : DEFAULT_ROTATION,
|
|
543
|
+
scale: parsed.scale !== undefined ? JSON.stringify(parsed.scale) : DEFAULT_SCALE,
|
|
544
|
+
};
|
|
293
545
|
}
|
|
294
546
|
catch {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
_currId: 0,
|
|
300
|
-
rootEntities: [],
|
|
301
|
-
entities: {},
|
|
302
|
-
rootRenderVersion: 0,
|
|
303
|
-
lastRenderedVersion: 0,
|
|
304
|
-
selectedEntityIds: [],
|
|
547
|
+
return {
|
|
548
|
+
position: DEFAULT_POSITION,
|
|
549
|
+
rotation: DEFAULT_ROTATION,
|
|
550
|
+
scale: DEFAULT_SCALE,
|
|
305
551
|
};
|
|
306
552
|
}
|
|
307
|
-
const store = parsed.entityStore;
|
|
308
|
-
const existingIds = [...Object.keys(manifest.entities).map(Number), store._currId];
|
|
309
|
-
const engineId = existingIds.length ? Math.max(...existingIds) + 1 : 1;
|
|
310
|
-
const idStr = String(engineId);
|
|
311
|
-
store._currId = Math.max(store._currId, engineId);
|
|
312
|
-
if (!store.rootEntities.includes(engineId))
|
|
313
|
-
store.rootEntities.push(engineId);
|
|
314
|
-
store.entities[idStr] = {
|
|
315
|
-
name: folderName,
|
|
316
|
-
engineId,
|
|
317
|
-
childrenIdsSet: [],
|
|
318
|
-
childrenIds: [],
|
|
319
|
-
isNodeOpen: false,
|
|
320
|
-
script,
|
|
321
|
-
scriptVersion: 0,
|
|
322
|
-
transform,
|
|
323
|
-
renderVersion: 0,
|
|
324
|
-
properties,
|
|
325
|
-
threeId: 0,
|
|
326
|
-
};
|
|
327
|
-
manifest.entities[idStr] = folderName;
|
|
328
|
-
await fs.writeFile(path.join(sceneDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
|
|
329
|
-
return JSON.stringify(parsed);
|
|
330
553
|
}
|
|
@@ -1,39 +1,43 @@
|
|
|
1
|
-
import * as path from "path";
|
|
1
|
+
import * as path from "node:path";
|
|
2
2
|
import chokidar from "chokidar";
|
|
3
|
+
import { MANIFEST_FILE_NAME } from "../constants.js";
|
|
3
4
|
import { getSceneAppDir } from "./file-system.js";
|
|
4
|
-
import {
|
|
5
|
+
import { packageSceneFromFilesystem, ENTITY_SCRIPT_FILE, ENTITY_PROPERTIES_FILE, ENTITY_TRANSFORMS_FILE, SCENE_SCRIPT_FILE, SCENE_PROPERTIES_FILE, } from "./scene-packaging.js";
|
|
5
6
|
const DEBOUNCE_MS = 100;
|
|
6
7
|
export function createScriptWatcher(sceneDir, getLatestSceneJson, onSceneUpdated, onError) {
|
|
7
8
|
const appDir = getSceneAppDir(sceneDir);
|
|
8
9
|
function toRelativePath(filePath) {
|
|
9
10
|
return path.relative(appDir, filePath).replace(/\\/g, "/");
|
|
10
11
|
}
|
|
11
|
-
function
|
|
12
|
+
function isRelevantPath(relativePath) {
|
|
12
13
|
if (relativePath === SCENE_SCRIPT_FILE || relativePath === SCENE_PROPERTIES_FILE)
|
|
13
14
|
return true;
|
|
14
|
-
|
|
15
|
-
if (!match)
|
|
15
|
+
if (!relativePath || relativePath === ".")
|
|
16
16
|
return false;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const fileName = path.posix.basename(relativePath);
|
|
18
|
+
if (fileName === ENTITY_SCRIPT_FILE || fileName === ENTITY_PROPERTIES_FILE || fileName === ENTITY_TRANSFORMS_FILE) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
// Directory adds/removals can represent entity creates, deletes, or moves.
|
|
22
|
+
if (!path.posix.extname(fileName)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
21
26
|
}
|
|
22
27
|
let debounceTimer = null;
|
|
23
|
-
let
|
|
24
|
-
function schedule(
|
|
25
|
-
|
|
28
|
+
let lastRelativePath = null;
|
|
29
|
+
function schedule(relativePath) {
|
|
30
|
+
lastRelativePath = relativePath;
|
|
26
31
|
if (debounceTimer)
|
|
27
32
|
clearTimeout(debounceTimer);
|
|
28
33
|
debounceTimer = setTimeout(async () => {
|
|
29
34
|
debounceTimer = null;
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (!
|
|
35
|
+
const rp = lastRelativePath;
|
|
36
|
+
lastRelativePath = null;
|
|
37
|
+
if (!rp)
|
|
33
38
|
return;
|
|
34
|
-
const rp = ev.relativePath;
|
|
35
39
|
try {
|
|
36
|
-
const updated = await
|
|
40
|
+
const updated = await packageSceneFromFilesystem(getLatestSceneJson(), sceneDir);
|
|
37
41
|
onSceneUpdated(updated);
|
|
38
42
|
}
|
|
39
43
|
catch (err) {
|
|
@@ -43,20 +47,18 @@ export function createScriptWatcher(sceneDir, getLatestSceneJson, onSceneUpdated
|
|
|
43
47
|
}, DEBOUNCE_MS);
|
|
44
48
|
}
|
|
45
49
|
const watcher = chokidar.watch(appDir, { ignoreInitial: true });
|
|
46
|
-
|
|
50
|
+
function handlePathEvent(filePath) {
|
|
47
51
|
const relativePath = toRelativePath(filePath);
|
|
48
|
-
if (relativePath ===
|
|
52
|
+
if (relativePath === MANIFEST_FILE_NAME)
|
|
49
53
|
return;
|
|
50
|
-
if (
|
|
51
|
-
schedule(
|
|
52
|
-
}
|
|
53
|
-
watcher.on("
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
schedule("add", relativePath);
|
|
59
|
-
});
|
|
54
|
+
if (isRelevantPath(relativePath))
|
|
55
|
+
schedule(relativePath);
|
|
56
|
+
}
|
|
57
|
+
watcher.on("change", handlePathEvent);
|
|
58
|
+
watcher.on("add", handlePathEvent);
|
|
59
|
+
watcher.on("unlink", handlePathEvent);
|
|
60
|
+
watcher.on("addDir", handlePathEvent);
|
|
61
|
+
watcher.on("unlinkDir", handlePathEvent);
|
|
60
62
|
return {
|
|
61
63
|
close: () => {
|
|
62
64
|
if (debounceTimer)
|
package/dist/lib/types.d.ts
CHANGED
package/dist/lib/utils.d.ts
CHANGED
package/dist/lib/utils.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
1
|
export function toErrorMessage(err) {
|
|
2
2
|
return err instanceof Error ? err.message : String(err);
|
|
3
3
|
}
|
|
4
|
+
/** For exec/child_process errors: prefer stderr (last line), otherwise message. */
|
|
5
|
+
export function getExecErrorText(error) {
|
|
6
|
+
if (!error || typeof error !== "object")
|
|
7
|
+
return "";
|
|
8
|
+
const err = error;
|
|
9
|
+
const stderr = err.stderr?.trim();
|
|
10
|
+
if (stderr)
|
|
11
|
+
return stderr.split("\n").pop() ?? stderr;
|
|
12
|
+
return err.message?.trim() ?? "";
|
|
13
|
+
}
|
|
@@ -39,18 +39,3 @@ export function setupSceneDirectory(sceneDir) {
|
|
|
39
39
|
}
|
|
40
40
|
return { shouldPromptInstall };
|
|
41
41
|
}
|
|
42
|
-
export function getInstallHint(sceneDir) {
|
|
43
|
-
const hasPnpm = fs.existsSync(path.join(sceneDir, "pnpm-lock.yaml"));
|
|
44
|
-
const hasYarn = fs.existsSync(path.join(sceneDir, "yarn.lock"));
|
|
45
|
-
const hasBun = fs.existsSync(path.join(sceneDir, "bun.lockb"));
|
|
46
|
-
const hasNpm = fs.existsSync(path.join(sceneDir, "package-lock.json"));
|
|
47
|
-
if (hasPnpm)
|
|
48
|
-
return "pnpm install";
|
|
49
|
-
if (hasBun)
|
|
50
|
-
return "bun install";
|
|
51
|
-
if (hasYarn)
|
|
52
|
-
return "yarn install";
|
|
53
|
-
if (hasNpm)
|
|
54
|
-
return "npm install";
|
|
55
|
-
return "npm install (or pnpm / bun / yarn install)";
|
|
56
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phibelle-kit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.32",
|
|
4
4
|
"description": "CLI tool for interacting with the Phibelle engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc",
|
|
11
|
-
"start": "node dist/index.js"
|
|
11
|
+
"start": "node dist/index.js",
|
|
12
|
+
"test": "npm run build && node --test tests/*.test.mjs"
|
|
12
13
|
},
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=20"
|