import { Object3D } from "three"; import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'; import { type GLTF, type GLTFLoaderPlugin, GLTFParser } from "three/examples/jsm/loaders/GLTFLoader.js"; import { isDevEnvironment } from "../debug/debug.js"; import { builtinComponentKeyName } from "../engine_constants.js"; import { debugExtension } from "../engine_default_parameters.js"; import { getLoader } from "../engine_gltf.js"; import { type NodeToObjectMap, type ObjectToNodeMap, SerializationContext } from "../engine_serialization_core.js"; import { apply } from "../js-extensions/index.js"; import { maskGltfAssociation, resolveReferences } from "./extension_utils.js"; export const debug = debugExtension const componentsArrayExportKey = "$___Export_Components"; export const EXTENSION_NAME = "NEEDLE_components"; class ExtensionData { [builtinComponentKeyName]?: Array | null> } class ExportData { node: Object3D; nodeIndex: number; nodeDef: any; constructor(node: Object3D, nodeIndex: number, nodeDef: any) { this.node = node; this.nodeIndex = nodeIndex; this.nodeDef = nodeDef; } } export class NEEDLE_components implements GLTFLoaderPlugin { get name(): string { return EXTENSION_NAME; } // #region export exportContext!: { [nodeIndex: number]: ExportData }; objectToNodeMap: ObjectToNodeMap = {}; context!: SerializationContext; writer?: any; registerExport(exp: GLTFExporter) { //@ts-ignore exp.register(writer => { // we want to hook into BEFORE user data is written // because we want to remove the components list (circular references) // and replace them with the serialized data // the write node callback is called after user data is serialized // we could also traverse everything before export and remove components // but doing it like that we avoid traversing multiple times if ("serializeUserData" in writer) { //@ts-ignore const originalFunction = writer.serializeUserData.bind(writer); this.writer = writer; //@ts-ignore writer.serializeUserData = (o, def) => { try { const hadUserData = this.serializeUserData(o, def); if (hadUserData) //@ts-ignore writer.extensionsUsed[this.name] = true; originalFunction(o, def); } finally { this.afterSerializeUserData(o, def); } } } return this; }); } beforeParse() { this.exportContext = {}; this.objectToNodeMap = {}; } // https://github.com/mrdoob/three.js/blob/efbfc67edc7f65cfcc61a389ffc5fd43ea702bc6/examples/jsm/exporters/GLTFExporter.js#L532 serializeUserData(node: Object3D, _nodeDef: any): boolean { const components = node.userData?.components; if (!components || components.length <= 0) return false; // delete components before serializing user data to avoid circular references delete node.userData.components; node[componentsArrayExportKey] = components; return true; } afterSerializeUserData(node: Object3D, _nodeDef) { if (node.type === "Scene") { if (debug) console.log("DONE", JSON.stringify(_nodeDef)); } // reset userdata if (node[componentsArrayExportKey] === undefined) return; const components = node[componentsArrayExportKey]; delete node[componentsArrayExportKey]; if (components !== null) { node.userData.components = components; } // console.log(_nodeDef, _nodeDef.mesh); } writeNode(node: Object3D, nodeDef) { // NOTE: node INDICES are not known at this point — the exporter pushes nodeDefs // (and fills writer.nodeMap) after the plugin callbacks ran. Using // json.nodes.length here produced COLLIDING indices for nested nodes: entries // overwrote each other (components silently dropped for all but one node) and // objectToNodeMap held wrong indices (corrupted reference pointers). Indices are // resolved from writer.nodeMap in afterParse instead — key by uuid here. if (debug) console.log(node.name, node.uuid); const context = new ExportData(node, -1, nodeDef); this.exportContext[node.uuid] = context; }; afterParse(input) { if (debug) console.log("AFTER", input); // the exporter's nodeMap is the authoritative Object3D -> node index mapping. // Build the full object->node map from it: component references may point at // ANY exported node, not only nodes that have components themselves. const nodeMap: Map | undefined = this.writer?.nodeMap; if (nodeMap) { for (const [obj, index] of nodeMap) { this.objectToNodeMap[obj.uuid] = index; } } for (const i in this.exportContext) { const context = this.exportContext[i]; const node = context.node; const nodeDef = context.nodeDef; const nodeIndex = nodeMap?.get(node) ?? context.nodeIndex; if (nodeIndex === undefined || nodeIndex < 0) { // node was not exported (e.g. excluded) — nothing to write continue; } this.writeComponentsIntoDef(node, nodeDef, nodeIndex); } // Scene roots are NOT nodes: writeNode never fires for a Scene, so components on // the scene object itself (a supported pattern — e.g. an editor scene holding // scene-level components) were silently dropped from exports. The exporter emits // one scene def per Scene input IN INPUT ORDER (non-Scene inputs are batched into // a trailing AuxScene), which maps each Scene input to its json.scenes index. // The import side mirrors this in afterRoot. const sceneDefs = this.writer?.json?.scenes; if (sceneDefs) { const inputs: Object3D[] = Array.isArray(input) ? input : [input]; let sceneIndex = 0; for (const obj of inputs) { if ((obj as unknown as { isScene?: boolean })?.isScene !== true) continue; if (sceneIndex >= sceneDefs.length) break; this.writeComponentsIntoDef(obj, sceneDefs[sceneIndex]); sceneIndex++; } } } /** Serializes an object's components into a glTF def (node def or scene def). * The extension is only attached when at least one component produced data. */ private writeComponentsIntoDef(obj: Object3D, def: { extensions?: Record }, nodeIndex?: number) { const components = obj.userData?.components; if (!components || components.length <= 0) return; this.context.object = obj; this.context.nodeId = nodeIndex; this.context.objectToNode = this.objectToNodeMap; const serializedComponentData: Array = []; for (const comp of components) { this.context.target = comp; const res = getLoader().writeBuiltinComponentData(comp, this.context); if (res !== null) { serializedComponentData.push(res); // (comp as unknown as ISerializationCallbackReceiver)?.onAfterSerialize?.call(comp); } } if (serializedComponentData.length > 0) { const data: ExtensionData = new ExtensionData(); data[builtinComponentKeyName] = serializedComponentData; def.extensions = def.extensions || {}; def.extensions[this.name] = data; if (debug) console.log("DID WRITE", obj, nodeIndex !== undefined ? `nodeIndex ${nodeIndex}` : "(scene)", serializedComponentData); } } // ------------------------------------- // #region import parser?: GLTFParser; nodeToObjectMap: NodeToObjectMap = {}; /** The loaded gltf */ gltf: GLTF | null = null; beforeRoot() { if (debug) console.log("BEGIN LOAD"); this.nodeToObjectMap = {}; return null; } async afterRoot(result: GLTF): Promise { this.gltf = result; const parser = result.parser; const ext = parser?.extensions; if (!ext) return; const hasExtension = ext[this.name]; if (debug) console.log("After root", result, this.parser, ext); const loadComponents: Array> = []; if (hasExtension === true) { const nodes = parser.json.nodes; if (nodes) { for (let i = 0; i < nodes.length; i++) { const obj = await parser.getDependency('node', i); this.nodeToObjectMap[i] = obj; } for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const index = i;// node.mesh; const ext = node.extensions; if (!ext) continue; const data = ext[this.name]; if (!data) continue; if (debug) console.log("NODE", node); const obj = this.nodeToObjectMap[index]; if (!obj) { console.error("Could not find object for node index: " + index, node, parser); continue; } apply(obj); loadComponents.push(this.createComponents(result, node, obj, data)); } } // mirror of the export side: scene-level components live on the SCENE def // (a Scene root is not a node) — attach them to the loaded scene object so // the builtin-components pass (which starts its traversal AT the scene) // creates them like any node component const sceneDefs = parser.json.scenes; if (sceneDefs) { for (let i = 0; i < sceneDefs.length; i++) { const sceneDef = sceneDefs[i]; const data = sceneDef?.extensions?.[this.name]; if (!data) continue; const obj = await parser.getDependency("scene", i); if (!obj) { console.error("Could not find scene for index: " + i, sceneDef, parser); continue; } apply(obj); loadComponents.push(this.createComponents(result, sceneDef as unknown as Node, obj, data as ExtensionData)); } } } await Promise.all(loadComponents); for (const instance of parser.associations.keys()) { const value = parser.associations.get(instance); if (value?.materials != undefined) { const key = "/materials/" + value.materials; maskGltfAssociation(instance, key); } } } private async createComponents(result: GLTF, node: Node, obj: Object3D, data: ExtensionData) { if (!data) return; const componentData = data[builtinComponentKeyName]; if (componentData) { const tasks = new Array>(); if (debug) console.log(obj.name, componentData); for (const i in componentData) { const data = componentData[i]; if (debug) console.log("Serialized data", JSON.parse(JSON.stringify(data))); // Fix for https://linear.app/needle/issue/NE-6779/blender-export-has-missing-sharedmaterials if (data?.name === "MeshRenderer" || data?.name === "SkinnedMeshRenderer") { if (!data.sharedMaterials) { let success = false; if ("mesh" in node) { const meshIndex = node.mesh; if (typeof meshIndex === "number" && result.parser) { const meshDef = result.parser.json.meshes?.[meshIndex]; if (meshDef?.primitives) { data.sharedMaterials = meshDef.primitives.map(prim => { return "/materials/" + (prim.material ?? 0); }); success = true; } } } if(!success && (debug || isDevEnvironment())) { console.warn(`[NEEDLE_components] Component '${data.name}' on object '${obj.name}' is not added to a mesh or failed to retrieve materials from glTF.`); } } } if (data && this.parser) { tasks.push( resolveReferences(this.parser, data) .catch(e => console.error(`Error while resolving references (see console for details)\n`, e, obj, data)) ); } obj.userData = obj.userData || {}; obj.userData[builtinComponentKeyName] = obj.userData[builtinComponentKeyName] || []; obj.userData[builtinComponentKeyName].push(data); } await Promise.all(tasks).catch((e) => { console.error("Error while loading components", e); }); } } // parse function https://github.com/mrdoob/three.js/blob/efbfc67edc7f65cfcc61a389ffc5fd43ea702bc6/examples/jsm/loaders/GLTFLoader.js#L2290 // createNodeAttachment(nodeIndex: number): null { // // if(!this.parser){ // // console.error("Parser not set, call registerLoad with on this"); // // return null; // // } // // const node = this.parser.json.nodes[nodeIndex]; // // const extenstions = node.extensions; // // const data = extenstions && extenstions[this.name]; // // if (!data) return null; // // const components = data[builtinComponentKeyName]; // // if (!components) return null; // // console.log(components); // return null; // } }