engine_components icon indicating copy to clipboard operation
engine_components copied to clipboard

Exporting with GLTFExporter produces invalid glTF

Open wlinna opened this issue 1 year ago • 16 comments

Describe the bug 📝

I am trying to export the fragments to glTF, but the exported glb-file is invalid. glTF validator produces lots of errors and the file doesn't open in Blender

Whether it's because the fragments are somehow special or because the GLTFExporter of Three.js 152 specifically is broken I know not. However, in other contexts I have used GLTFExporter without problems for years. Either way, something should be done on the openbim-components side.

Reproduction ▶️

https://657dfca31ade9164da46e2de--bim-building-simplification.netlify.app/

Steps to reproduce 🔢

~(Sorry for not being able to give a reproduction site at the moment. My repro site seems to break when built with Vite and deployed to Netlify. Maybe later)~

EDIT: Reproduction steps with the reproduction site

  1. Import IFC
  2. Export GLB
  3. Validate or try opening in Blender

Old steps (the reproduction steps explained in general)

  1. Follow this tutorial to generate fragments (except that I use the browser's file selector for IFC loading) https://docs.thatopen.com/Tutorials/FragmentIfcLoader
  2. Export with the following method
import { GLTFExporter, GLTFExporterOptions } from 'three/examples/jsm/exporters/GLTFExporter';

async function produceGlb() {
    if (!fragments.groups.length) return;

    const exporter = new GLTFExporter();
    const opts: GLTFExporterOptions = {
        trs: true,
        binary: true,
        onlyVisible: true,
    };
    
    const group = fragments.groups[0];
    const glb = await exporter.parseAsync(group, opts);
    return glb;
}

async function exportGlb() {
    if (!fragments.groups.length) return;

    const glb = (await produceGlb()) as ArrayBuffer;

    const blob = new Blob([glb]);
    const file = new File([blob], 'example.glb');

// I changed download to take name separately to make TypeScript happy
    download(file, 'example.glb');
}
  1. Validate or try opening in Blender

System Info 💻

System:
    OS: Linux 6.5 Pop!_OS 22.04 LTS
    CPU: (20) x64 13th Gen Intel(R) Core(TM) i5-13600KF
    Memory: 13.81 GB / 31.18 GB
    Container: Yes
    Shell: 5.8.1 - /usr/bin/zsh
  Binaries:
    Node: 20.10.0 - /usr/bin/node
    npm: 10.2.3 - /usr/bin/npm
  Browsers:
    Chromium: 120.0.6099.71 (I'm actually using Firefox)
  npmPackages:
    openbim-components: ^1.2.0 => 1.2.0

Used Package Manager 📦

npm

Error Trace/Logs 📃

glTF validator output
    "uri": "example.glb",
    "mimeType": "model/gltf-binary",
    "validatorVersion": "2.0.0-dev.3.8",
    "validatedAt": "2023-12-16T16:13:37.458Z",
    "issues": {
        "numErrors": 630,
        "numWarnings": 0,
        "numInfos": 0,
        "numHints": 0,
        "messages": [
            {
                "code": "VALUE_NOT_IN_RANGE",
                "message": "Value 2 is out of range.",
                "severity": 0,
                "pointer": "/bufferViews/2/byteStride"
            },
            {
                "code": "VALUE_NOT_IN_RANGE",
                "message": "Value 2 is out of range.",
                "severity": 0,
                "pointer": "/bufferViews/6/byteStride"
            },
            {
                "code": "VALUE_NOT_IN_RANGE",
                "message": "Value 2 is out of range.",
                "severity": 0,
                "pointer": "/bufferViews/10/byteStride"
            },

         ...
            {
                "code": "MESH_PRIMITIVE_ACCESSOR_UNALIGNED",
                "message": "Vertex attribute data must be aligned to 4-byte boundaries.",
                "severity": 0,
                "pointer": "/meshes/0/primitives/0/attributes/_BLOCKID"
            },
            {
                "code": "MESH_PRIMITIVE_ACCESSOR_UNALIGNED",
                "message": "Vertex attribute data must be aligned to 4-byte boundaries.",
                "severity": 0,
                "pointer": "/meshes/1/primitives/0/attributes/_BLOCKID"
            },

           ...
  
        ],
        "truncated": false
    },
    "info": {
        "version": "2.0",
        "generator": "THREE.GLTFExporter",
        "resources": [
            {
                "pointer": "/buffers/0",
                "mimeType": "application/gltf-buffer",
                "storage": "glb",
                "byteLength": 2062856
            }
        ],

    }
}

Validations ✅

  • [X] Read the docs.
  • [X] Check that there isn't already an issue that reports the same bug to avoid creating a duplicate.
  • [X] Make sure this is a repository issue and not a framework-specific issue. For example, if it's a THREE.js related bug, it should likely be reported to mrdoob/threejs instead.
  • [X] Check that this is a concrete bug. For Q&A join our Community.
  • [X] The provided reproduction is a minimal reproducible example of the bug.

wlinna avatar Dec 16 '23 18:12 wlinna

It seems that all the errors are related to the _BLOCKID attribute

wlinna avatar Dec 17 '23 18:12 wlinna

Hey @wlinna, the geometric representation we use is not necessarily glTF compatible due to the blockID attribute. If you remove that attribute from the geometry before exporting, would it work for you?

agviegas avatar Dec 22 '23 11:12 agviegas

Hello

If you remove that attribute from the geometry before exporting, would it work for you?

That's what I'm wondering myself. However, I don't know what blockID represents and thus I don't know what I'd be losing. However, that's what I'm going to try next.

Is blockID documented somewhere?

the geometric representation we use is not necessarily glTF compatible due to the blockID attribute.

But having a custom attribute shouldn't be a problem in itself. At my day job our meshes have custom attributes and GLTFExporter has no trouble dealing with them. Because of this, I'm inclined to think that something is wrong with the way blockID is stored / expressed.

wlinna avatar Dec 22 '23 11:12 wlinna

Hm, blockID is just another Three.js BufferAttribute, so I'm not sure what can be wrong with it. 🤔

We use fragments to express geometry, which is a simple wrapper we created around Three.js instancedMeshes to work efficiently with BIM data. The concept is simple: use instancing for geometries that repeat, and merge geometries that are unique (like walls, slabs, etc) to reduce draw calls.

We use the blockID attribute to distinguish meshes with unique geometries that are merged to reduce the draw calls (e.g. walls or slabs) at the vertex level. It's not ideal having an int per vertex, but it's the easiest way to make this work with three-mesh-bvh, which necessary for things like real time clipping planes.

agviegas avatar Dec 22 '23 11:12 agviegas

Thanks for the response

We use the blockID attribute to distinguish meshes with unique geometries that are merged to reduce the draw calls (e.g. walls or slabs) at the vertex level. It's not ideal having an int per vertex, but it's the easiest way to make this work with three-mesh-bvh, which necessary for things like real time clipping planes.

Good to know. My application needs the meshes to be separated, so I need to use blockID to undo the batching.

Hm, blockID is just another Three.js BufferAttribute, so I'm not sure what can be wrong with it. 🤔

The problem is related to the byteStride of bufferview. It has to be divisible by 4, but in the export it is 2. GLTFExporter computes it like this:

	case WEBGL_CONSTANTS.SHORT:
	case WEBGL_CONSTANTS.UNSIGNED_SHORT:

		componentSize = 2;
		break;

	...

	bufferViewDef.byteStride = attribute.itemSize * componentSize;

Here's what the glTF spec says:

For performance and compatibility reasons, each element of a vertex attribute MUST be aligned to 4-byte boundaries inside a bufferView (i.e., accessor.byteOffset and bufferView.byteStride MUST be multiples of 4).

Perhaps it would be for the best to change blockID from Uint16 to Uint32 since apparently using Uint16 might have undesired performance/compatibility implications.

Whether you do it or not doesn't concern me anymore, however, because I need to undo the batching anyway and thus remove blockID anyway.

wlinna avatar Dec 22 '23 12:12 wlinna

Turns out that blockID isn't the only problem. I tried removing them and then exporting like before, but the results are not good

    group.traverse(obj => {

        console.log(obj.constructor.name);
        if (!(obj instanceof THREE.Mesh)) {
            return;   
        }

        const geom = obj.geometry as THREE.BufferGeometry;
        geom.deleteAttribute('blockID');

}

OutOfWhack

When I zoom out, it looks like the objects that are likely instanced are 1000 times larger and stacked together. I don't know how the instances are handled in fragments / components, so any pointers would be much appreciated

wlinna avatar Dec 22 '23 16:12 wlinna

It turns out that the GLTFExporter supplied by components does not include (GLTFMeshGpuInstancing)[ https://github.com/mrdoob/three.js/blob/d04539a76736ff500cae883d6a38b3dd8643c548/examples/jsm/exporters/GLTFExporter.js#L146C15-L146C36].

So part of the problem should be fixed by upgrading the Three.js dependency. I'm probably going to try make separate meshes though.

I ended up turning all the instances to separate Mesh objects (that just happen to share the geometry) to avoid any compatibility issues, and that works fine.

wlinna avatar Dec 22 '23 18:12 wlinna

Hi @wlinna I am curious to see how you solved this issue. I am also trying to export glbs or gltfs from certain IFC classes (walls, slabs, roofs, I want to create a lite or shell mesh that I can load on top of mapbox) but I am not being successful at doing so. @agviegas any advice would be greatly appreciated. Thanks!

nicoarellano avatar Feb 17 '24 19:02 nicoarellano

Hello, the process of exporting is surprisingly complicated, although I haven't tested with the latest version because I can't install it without errors. So I use "openbim-components": "1.2.0"

However, I managed to implement a my own solution that has served me well since. Feel free to modify my code. My code includes only uses two very basic materials (because my application doesn't need material information), so you'll likely need to write better material generation code (if you can share your improvement, I'd appreciate it)

Here's the relevant TypeScript code. Feel free to ask about the code if you have trouble using / understanding it.

Note that if you don't need to separate the components, you might be able to get away with something much simpler. In that case, removing blockID attribute from each geometry might be a sufficient alternative to the whole separateByBlockId operaiton.

async function exportGlb() {
    if (!fragments.groups.length) {
         return;
   }

    const group = fragments.groups[0];
    const clone = cloneAndUnravel(group);
   // You only need this if you use fragmentIfcLoader.settings.webIfc.COORDINATE_TO_ORIGIN = true;
    group.coordinationMatrix.invert().decompose(clone.position, clone.quaternion, clone.scale);

    const exporter = new GLTFExporter();
    // Options copied from examples. They don't have to be like this I think
    const opts = {
        trs: false,
        onlyVisible: false,
        truncateDrawRange: true,
        binary: true,
        maxTextureSize: 0
    };

    const glb = await exporter.parseAsync(clone, opts) as ArrayBuffer;

    const blob = new Blob([glb]);
    const file = new File([blob], name + '.glb');

    const objUrl = URL.createObjectURL(file);
    ...
}
class ItemData {
    position: number[] = [];
    index: number[] = [];
    oldToNew = new Map<number, number>();

    addNewPos(oldIdx: number, x: number, y: number, z: number) {
        this.position.push(x, y, z);
        this.oldToNew.set(oldIdx, (this.position.length / 3) - 1);
    }

    addIndex(oldIdx: number) {
        const newIdx = this.oldToNew.get(oldIdx);
        if (newIdx == null) {
            throw new Error('Index not found error');
        }

        this.index.push(newIdx);
    }
}

function cloneAndUnravel(group: FragmentsGroup) {
    const serializer = new Serializer();
    const exported = serializer.export(group);
    const clone = serializer.import(exported);

    const properties = group.properties!;
    const itemMap = new Map<string, ItemData>();

    clone.traverse(obj => {
        if (!(obj instanceof THREE.Mesh)) {
            return;
        }

        const geom = obj.geometry as THREE.BufferGeometry;

        if (obj instanceof FragmentMesh) {
            const matrix = new THREE.Matrix4;
            for (let instI = 0; instI < obj.count; ++instI) {
                obj.getMatrixAt(instI, matrix);

                separateByBlockId(obj.fragment, instI, geom, matrix, itemMap);
            }

            return;
        }

        throw new Error('This never happens');
    });

    clone.clear();

    const outGeoms = new Map<string, THREE.BufferGeometry>();

    for (const [key, itemData] of itemMap.entries()) {
        const newGeom = new THREE.BufferGeometry();
        newGeom.setIndex(itemData.index);
        newGeom.setAttribute('position', new THREE.Float32BufferAttribute(itemData.position, 3));

        const cleaned = BufferGeometryUtils.mergeVertices(newGeom);

        outGeoms.set(key, cleaned);
    }

    const myMat = new THREE.MeshStandardMaterial({ color: 0x666666 });
    const windowMat = new THREE.MeshStandardMaterial({ color: 0x0101FE, opacity: 0.7 });

    for (const [expressId, geom] of outGeoms.entries()) {
        const props = properties[Number(expressId)];
        const ifcType = props.type;
        let mat = myMat;
        if (ifcType === WEBIFC.IFCWINDOW || ifcType === WEBIFC.IFCWINDOWSTANDARDCASE) {
            mat = windowMat;
        }

        let newMesh = new THREE.Mesh(geom, mat);

        newMesh.name = props.GlobalId?.value ?? expressId;
        newMesh.userData.expressId = expressId;

        newMesh.updateMatrix();
        clone.add(newMesh);
    }

    return clone;
}
function separateByBlockId(fragment: Fragment, instanceId: number, bufGeom: THREE.BufferGeometry, matrix: THREE.Matrix4, itemMap: Map<string, ItemData>) {
    const origVerts = bufGeom.getAttribute('position');
    const blockIdBuf = bufGeom.getAttribute('blockID');
    const origIndex = bufGeom.index as THREE.Uint32BufferAttribute;

    const vec = new THREE.Vector3;

    for (let i = 0; i < origVerts.count; ++i) {
        let blockId = blockIdBuf.getX(i);
        let expressId = fragment.getItemID(instanceId, blockId);

        if (expressId.includes('.')) {
            // const test = fragment.getItemID(instanceId, blockId);
            console.warn("Investigate: weird expressId: " + expressId);
            expressId = expressId.substring(0, expressId.lastIndexOf('.'));
        }

        let blockData = itemMap.get(expressId) ?? new ItemData;
        itemMap.set(expressId, blockData);

        vec.set(origVerts.getX(i), origVerts.getY(i), origVerts.getZ(i)).applyMatrix4(matrix);

        blockData.addNewPos(i, vec.x, vec.y, vec.z);
    }

    let currentBlockId = -1;

    for (let iIndex = 0; iIndex < origIndex.count; ++iIndex) {
        let idx = origIndex.getX(iIndex);
        let blockId = blockIdBuf.getX(idx);

        if (iIndex % 3 === 0) {
            currentBlockId = blockId;
        } else if (blockId != currentBlockId) {
            throw new Error('The blockId should not change mid-triangle!');
        }

        let expressId = fragment.getItemID(instanceId, blockId);
        if (expressId.includes('.')) {
            expressId = expressId.substring(0, expressId.lastIndexOf('.'));
        }

        let blockData = itemMap.get(expressId) ?? new ItemData;
        itemMap.set(expressId, blockData);

        blockData.addIndex(idx);
    }
}

wlinna avatar Feb 17 '24 20:02 wlinna

Hi, this week we will release a new version of components/fragments, maybe it helps. We have gotten rid of the BlockID attribute, so now the mesh attribute of a component is a mere three.js InstancedMesh, so exporting it to GLTF should be a pure Three.js matter.

If after the update of components & fragments to 1.5.0 the issue remains, let me know and we'll see what we can do. Cheers!

agviegas avatar Feb 19 '24 21:02 agviegas

@agviegas Interesting! But how are the different meshes with identical materials batched then, if at all? Won't the additional draw calls become an issue? Or do I have to figure out a new way to split merged meshes (since I need to have them separated before processing)?

wlinna avatar Feb 19 '24 21:02 wlinna

Merged meshes don't exist anymore. Now, everything is instanced. So each unique element remains its own mesh. We have created a streaming system that can open multi gigabyte models on any devices in seconds at 60 fps. The draw calls are always under control because items that are not visible for the camera are removed from the scene, and later from memory (until seen again).

Needless to say, this system is also compatible with the rest of components and part of this library. This is what we are releasing this week.

Getting rid of merged meshes has made everything much cleaner and easier. Also, this year we are creating a BIM modeller, and this approach will make everything much easier to work with and to maintain.

agviegas avatar Feb 19 '24 22:02 agviegas

Thanks, @wlinna I'll give it a try. Looking forward to the new release.

nicoarellano avatar Feb 20 '24 11:02 nicoarellano

Very interesting. I have a couple of questions if you don't mind me asking:

  • Do I have to change the way I import the IFC model to use the streaming system?
  • Will there be an event to tell when the streaming is complete?
  • If the occluded items are removed from the scene, how will they be exported?

Btw, I wonder how well will occlusion culling work when the model has a limited number of good occluders. Industrial facility models often seem to lack them. Not that it matters in my use case, just curious.

wlinna avatar Feb 21 '24 10:02 wlinna

Sure! This will be in the docs as a step-by-step example once we release it next week.

  • Yes. You can convert the IFC to a set of small binary files that you have to make available to the frontend. The frontend will request the files as needed.
  • The streaming is never complete. When you move around, you discover new objects (loading them from the backend) and un-discover others (which are removed from memory after some time). Luckily, this whole system relies on simple HTTP GET calls, so it's pretty simple and cheap. You could also make this frontend-only if you have a frontend DB, but IMO this system shines more with a backend. The time for an asset to be disposed is a parameter that can be tweaked.
  • Well, if what you want is to take an IFC and export all the items to another format, my suggestion is not to load a 3D scene, but to use a function to traverse all IFC objects, get their geometry data and export it. In the library will be examples showing how to do this from next week, so maybe you can check them. If you still have questions with it, we'll help you out, it's quite simple.
  • Occlusion works well even for industrial /MEP models, because it's not just about occluding. It has a certain pixel tolerance, so that items that are very far away (occupiying a number of pixels below the tolerance) are not loaded. This tolerance is a parameter that can be tweaked. So even when you have a model full of tiny pieces. only the ones closer to you will be loaded.

agviegas avatar Feb 22 '24 12:02 agviegas

Thanks for explaining!

What I actually need is to load the model to the scene, manipulate it a little bit such as by hiding certain components, and then convert it to GLB so that my WASM module can run some operations on it.

Thankfully 1.4 series still allows using IfcFragmentLoader. I really hope the ability to load the whole model to the scene will never be removed or deprecated.

1.4.11 has this rather nasty bug though :/ https://github.com/ThatOpen/engine_components/issues/314

wlinna avatar Mar 05 '24 09:03 wlinna

Hey @wlinna thanks for your patience. I think it's solved. Using the fragmentsManager tutorial as a starting point, and modifying the load function so that it exports the result with your config:

async function loadFragments() {
  if (fragments.groups.size) {
    return;
  }
  const file = await fetch(
    "https://thatopen.github.io/engine_components/resources/small.frag",
  );
  const data = await file.arrayBuffer();
  const buffer = new Uint8Array(data);
  const group = fragments.load(buffer);
  world.scene.three.add(group);
  uuid = group.uuid;

  const exporter = new GLTFExporter();
  const opts: GLTFExporterOptions = {
    trs: true,
    binary: true,
    onlyVisible: true,
  };

  const glb = (await exporter.parseAsync(group, opts)) as ArrayBuffer;

  const blob = new Blob([glb]);
  const exportedFile = new File([blob], "example.glb");

  // I changed download to take name separately to make TypeScript happy
  const a = document.createElement("a");
  a.download = "exported.glb";
  a.href = URL.createObjectURL(exportedFile);
  a.click();
  a.remove();
}

This is the result:

https://github.com/ThatOpen/engine_components/assets/56475338/3f4757f0-8af4-4ace-b8c4-adb1a046f7e5

Let me know if you have any issues. Cheers!

agviegas avatar Jul 04 '24 11:07 agviegas