spec-to-scene.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { v4 as uuidv4 } from "uuid";
  2. import type { Spec } from "@json-render/core";
  3. import type {
  4. SceneObject,
  5. ObjectType,
  6. LightType,
  7. Material,
  8. Physics,
  9. ColliderType,
  10. } from "./types";
  11. const specTypeToObjectType: Record<string, ObjectType> = {
  12. GameBox: "box",
  13. GameSphere: "sphere",
  14. GameCylinder: "cylinder",
  15. GameCone: "cone",
  16. GameTorus: "torus",
  17. GamePlane: "plane",
  18. GameCapsule: "capsule",
  19. GameKnot: "knot",
  20. GameTetrahedron: "tetrahedron",
  21. GameOctahedron: "octahedron",
  22. GameDodecahedron: "dodecahedron",
  23. GameIcosahedron: "icosahedron",
  24. GameExtrude: "extrude",
  25. GameTube: "tube",
  26. GameShape: "shape",
  27. GameMesh: "mesh",
  28. GameLight: "light",
  29. Player: "player",
  30. GameCharacter: "character",
  31. GameModel: "model",
  32. SoundEmitter: "sound",
  33. MediaPlane: "image",
  34. GroundPlane: "plane",
  35. };
  36. const DEFAULT_MATERIAL: Material = {
  37. color: "#888888",
  38. metalness: 0,
  39. roughness: 0.5,
  40. emissive: "#000000",
  41. emissiveIntensity: 0,
  42. };
  43. const DEFAULT_PHYSICS: Physics = {
  44. mass: 1,
  45. isStatic: false,
  46. restitution: 0.3,
  47. friction: 0.5,
  48. colliderType: "none",
  49. };
  50. function parseMaterial(raw: unknown): Material {
  51. if (!raw || typeof raw !== "object") return { ...DEFAULT_MATERIAL };
  52. const m = raw as Record<string, unknown>;
  53. return {
  54. color: (m.color as string) || DEFAULT_MATERIAL.color,
  55. metalness: (m.metalness as number) ?? DEFAULT_MATERIAL.metalness,
  56. roughness: (m.roughness as number) ?? DEFAULT_MATERIAL.roughness,
  57. emissive: (m.emissive as string) || DEFAULT_MATERIAL.emissive,
  58. emissiveIntensity:
  59. (m.emissiveIntensity as number) ?? DEFAULT_MATERIAL.emissiveIntensity,
  60. };
  61. }
  62. function parsePhysics(raw: unknown): Physics {
  63. if (!raw || typeof raw !== "object") return { ...DEFAULT_PHYSICS };
  64. const p = raw as Record<string, unknown>;
  65. return {
  66. mass: (p.mass as number) ?? DEFAULT_PHYSICS.mass,
  67. isStatic: (p.isStatic as boolean) ?? DEFAULT_PHYSICS.isStatic,
  68. restitution: (p.restitution as number) ?? DEFAULT_PHYSICS.restitution,
  69. friction: (p.friction as number) ?? DEFAULT_PHYSICS.friction,
  70. colliderType:
  71. (p.colliderType as ColliderType) || DEFAULT_PHYSICS.colliderType,
  72. };
  73. }
  74. function specElementToSceneObject(
  75. key: string,
  76. element: {
  77. type: string;
  78. props: Record<string, unknown>;
  79. children?: string[];
  80. },
  81. ): SceneObject | null {
  82. const objectType = specTypeToObjectType[element.type];
  83. if (!objectType) return null;
  84. const props = element.props;
  85. const id = (props.objectId as string) || key.replace(/^obj-/, "") || uuidv4();
  86. const obj: SceneObject = {
  87. id,
  88. name: (props.name as string) || element.type.replace(/^Game/, ""),
  89. type: objectType,
  90. position: (props.position as [number, number, number]) || [0, 0, 0],
  91. rotation: (props.rotation as [number, number, number]) || [0, 0, 0],
  92. scale: (props.scale as [number, number, number]) || [1, 1, 1],
  93. material: parseMaterial(props.material),
  94. physics: parsePhysics(props.physics),
  95. visible: true,
  96. };
  97. if (props.damage && typeof props.damage === "object") {
  98. const d = props.damage as Record<string, unknown>;
  99. obj.damage = {
  100. amount: (d.amount as number) ?? 0,
  101. enabled: (d.enabled as boolean) ?? false,
  102. };
  103. }
  104. if (element.type === "GameLight") {
  105. obj.lightType = (props.lightType as LightType) || "point";
  106. obj.intensity = (props.intensity as number) ?? 1;
  107. if (props.color) obj.material.color = props.color as string;
  108. if (props.distance != null) obj.distance = props.distance as number;
  109. if (props.decay != null) obj.decay = props.decay as number;
  110. if (props.angle != null) obj.angle = props.angle as number;
  111. if (props.penumbra != null) obj.penumbra = props.penumbra as number;
  112. obj.physics.colliderType = "none";
  113. }
  114. if (element.type === "Player") {
  115. obj.isPlayer = true;
  116. obj.physics.colliderType = "none";
  117. }
  118. if (element.type === "GameCharacter") {
  119. obj.modelUrl = (props.modelUrl as string) || "";
  120. obj.character = { role: (props.role as string) || "villager" };
  121. }
  122. if (element.type === "GameModel") {
  123. obj.modelUrl = (props.url as string) || "";
  124. }
  125. if (element.type === "SoundEmitter") {
  126. obj.sound = {
  127. url: (props.url as string) || "",
  128. loop: (props.loop as boolean) ?? false,
  129. volume: (props.volume as number) ?? 1,
  130. positional: (props.positional as boolean) ?? true,
  131. distance: (props.distance as number) ?? 10,
  132. };
  133. obj.physics.colliderType = "none";
  134. }
  135. if (element.type === "MediaPlane") {
  136. const mediaType = (props.mediaType as string) || "image";
  137. obj.type = mediaType === "video" ? "video" : "image";
  138. obj.media = {
  139. url: (props.url as string) || "",
  140. type: mediaType,
  141. loop: props.loop as boolean | undefined,
  142. autoplay: props.autoplay as boolean | undefined,
  143. muted: props.muted as boolean | undefined,
  144. };
  145. obj.physics.colliderType = "none";
  146. }
  147. // Geometry props
  148. if (props.width != null) obj.width = props.width as number;
  149. if (props.height != null) obj.height = props.height as number;
  150. if (props.depth != null) obj.depth = props.depth as number;
  151. if (props.radius != null) obj.radius = props.radius as number;
  152. if (props.radiusTop != null) obj.radiusTop = props.radiusTop as number;
  153. if (props.radiusBottom != null)
  154. obj.radiusBottom = props.radiusBottom as number;
  155. if (props.radialSegments != null)
  156. obj.radialSegments = props.radialSegments as number;
  157. if (props.tube != null) obj.tube = props.tube as number;
  158. if (props.tubularSegments != null)
  159. obj.tubularSegments = props.tubularSegments as number;
  160. if (props.widthSegments != null)
  161. obj.widthSegments = props.widthSegments as number;
  162. if (props.heightSegments != null)
  163. obj.heightSegments = props.heightSegments as number;
  164. if (props.length != null) obj.length = props.length as number;
  165. if (props.p != null) obj.p = props.p as number;
  166. if (props.q != null) obj.q = props.q as number;
  167. if (props.shapeData && typeof props.shapeData === "object") {
  168. obj.shapeData = props.shapeData as SceneObject["shapeData"];
  169. }
  170. if (props.meshData && typeof props.meshData === "object") {
  171. obj.meshData = props.meshData as SceneObject["meshData"];
  172. }
  173. if (element.type === "GroundPlane") {
  174. obj.material = parseMaterial(props.material);
  175. obj.rotation = [-Math.PI / 2, 0, 0];
  176. const size = (props.size as number) ?? 5000;
  177. obj.scale = [size, size, 1];
  178. obj.physics = {
  179. mass: 0,
  180. isStatic: true,
  181. restitution: 0.2,
  182. friction: 0.8,
  183. colliderType: "cuboid",
  184. };
  185. }
  186. return obj;
  187. }
  188. /**
  189. * Convert a json-render spec back into SceneObject[].
  190. * Extracts all elements whose keys start with "obj-".
  191. * Uses the scene root's children for ordering, then appends any
  192. * obj-* elements not yet in children (handles streaming where
  193. * elements arrive before the scene children array is updated).
  194. */
  195. export function specToSceneObjects(spec: Spec): SceneObject[] {
  196. const sceneElement = spec.elements["scene"] || spec.elements[spec.root];
  197. const objects: SceneObject[] = [];
  198. const seen = new Set<string>();
  199. const childKeys = sceneElement?.children || [];
  200. for (const key of childKeys) {
  201. if (!key.startsWith("obj-")) continue;
  202. const element = spec.elements[key];
  203. if (!element) continue;
  204. seen.add(key);
  205. const obj = specElementToSceneObject(key, element);
  206. if (obj) objects.push(obj);
  207. }
  208. for (const key of Object.keys(spec.elements)) {
  209. if (!key.startsWith("obj-") || seen.has(key)) continue;
  210. const element = spec.elements[key];
  211. if (!element) continue;
  212. const obj = specElementToSceneObject(key, element);
  213. if (obj) objects.push(obj);
  214. }
  215. return objects;
  216. }