Skip to content

Extract Geometry from 3D Models#13330

Open
alarkbentley wants to merge 52 commits intoCesiumGS:mainfrom
alarkbentley:alark/model_geometry_extract
Open

Extract Geometry from 3D Models#13330
alarkbentley wants to merge 52 commits intoCesiumGS:mainfrom
alarkbentley:alark/model_geometry_extract

Conversation

@alarkbentley
Copy link
Copy Markdown
Contributor

@alarkbentley alarkbentley commented Mar 25, 2026

Add getGeometry API for extracting vertex data from Models

Summary

Adds a new public Model.prototype.getGeometry() method that extracts per-primitive vertex attributes (positions, normals, colors, feature IDs, etc.) from a loaded Model. This is backed by a new ModelGeometryExtractor utility, a generalized GeometryResult class, and significant refactoring of ModelReader to consolidate scattered vertex-reading logic into a single, reusable module.

Despite the fact that the current implementation is synchronous the introduced API endpoint is exposed with an async handle to enable future non-breaking improvements such as WebWorkers or async GPU readback.

Motivation

There was no public API to access the underlying vertex information of 3D Models in Cesium. Use cases such as geometric analysis, custom visualizations, and spatial queries on picked features required workarounds or access to private internals.

Key driver for this was the ongoing PoC to extract 2D footprints based on model geometry in runtime: #13244

Usage example

const feature = scene.pick(movement.endPosition);
const model = feature.primitive;
const results = await model.getGeometry({
  attributes: ["POSITION", "COLOR_0", "_FEATURE_ID"],
  extractIndices: true,
});
for (const entry of results) {
  console.log(`Positions: ${entry.getPositions()?.length}`);  // Cartesian3[]
  console.log(`Colors: ${entry.getColors()?.length}`);         // Color[]
  console.log(`Feature IDs: ${entry.getFeatureIds()?.length}`); // number[]
  console.log(`Indices: ${entry.indices?.length}`);
  console.log(`Primitive type: ${entry.primitiveType}`);
  console.log(`Vertices: ${entry.count}, Instances: ${entry.instances}`);
}

Screenshot

image

Changes

New API

  • Model.prototype.getGeometry(options) — Public async method on the Model class. Returns a Promise<GeometryResult[]> with one entry per primitive.
    • options.featureIdLabel — Label of the feature ID set to match (default "featureId_0").
    • options.attributes — Array of semantic strings to extract (e.g. "POSITION", "NORMAL", "COLOR_0", "_FEATURE_ID", "TEXCOORD_1"). Defaults to ["POSITION"].
    • options.extractIndices — Whether to extract vertex indices (default false).
  • Cesium.GeometryResult — Generalized result class holding extracted per-primitive data. Properties: attributeNames, attributeValues (Map), attributeTypes (Map), indices, primitiveType, count, instances. Convenience methods: getPositions(), getNormals(), getColors(), getFeatureIds(), getAttributeValues(name), getAttributeType(name).

New modules

  • ModelGeometryExtractor — Extracts requested vertex attributes from a loaded Model. Performs a single scene-graph traversal with per-instance transform support, and reuses ModelReader for typed-array access and GPU readback.

Bug fixes

  • ModelReader
    • octDecode bug fix — Fixed incorrect argument order in AttributeCompression.octDecodeInRange call (was passing a Cartesian3, now correctly passes c.x, c.y) and fixed swapped arguments in the subsequent Cartesian3.pack call. (previously in pickModel) Link
    • getInstanceTransforms bug fix — Fixed incorrect matrix multiplication order for meshes with EXT_mesh_gpu_instancing. (previously in pickModel) Link

Refactoring

  • ModelReader
    • readAttributeAsTypedArray now supports in-memory typedArray fast path — returns the attribute's existing typed array directly when available, skipping GPU readback entirely. Previously only handled GPU buffer reads.
    • forEachPrimitive(model, options, callback) — New static method that traverses all runtime nodes and primitives in a model's scene graph. Accepts an optional options object with mapProjection (for 2D projection) and perInstanceFeatureIds (boolean, default false, to control whether per-instance feature IDs are fetched). Computes per-node transforms, then invokes the callback with (runtimePrimitive, primitive, instances, computedModelMatrix).
  • pickModel — Significantly simplified (~295 lines removed, ~86 added) by delegating node traversal and vertex unpacking to ModelReader instead of inlining its own scene-graph walk and vertex-reading logic.

Sandcastle demo

  • Added 3d-tiles-geometry-extraction-dev demo showing how to pick a feature, extract its geometry, and visualize extracted vertex positions as point entities.

Tests

  • GeometryResultSpec — 205 lines, 19 tests covering initialization defaults, all convenience getters (getPositions, getNormals, getColors, getFeatureIds), fallback key resolution (e.g. COLOR vs COLOR_0), generic getAttributeValues/getAttributeType, and property storage.
  • ModelGeometryExtractorSpec — ~991 lines, 24 tests covering positions extraction, feature ID extraction (by label, positionalLabel), attribute filtering, index extraction, node transform application, instance transform duplication (positions, colors, feature IDs), per-vertex vs per-instance feature ID precedence, multi-set color extraction (COLOR_0/COLOR_1), normal extraction, primitiveType propagation, and edge cases.
  • ModelReaderSpec (forEachPrimitive block) — ~512 lines, 19+ tests covering scene-graph traversal, transform computation (MAT4 typed array, MAT4 buffer, VEC3 attributes, interleaved buffers, translation+rotation+scale), 2D projection, instance feature ID fetching with opt-in perInstanceFeatureIds, and edge cases.
  • ModelSpec — 4 new tests for getGeometry covering default behavior, options passthrough, and undefined options handling.

Testing plan

  • All new and updated specs pass.
  • Verified with the included Sandcastle demo (3d-tiles-geometry-extraction-dev) against a batched 3D Tiles tileset.
  • Verified that the demo 3d-tiles-picking-dev that is utilizing the refactored pickModel still is working.

Author checklist

  • I have submitted a Contributor License Agreement
  • I have added my name to CONTRIBUTORS.md
  • I have updated CHANGES.md with a short summary of my change
  • I have added or updated unit tests to ensure consistent code coverage
  • I have updated the inline documentation, and included code examples where relevant
  • I have performed a self-review of my code

AI acknowledgment

  • I used AI to generate content in this PR
  • If yes, I have reviewed the AI-generated content before submitting

If yes, I used the following Tools(s) and/or Service(s):

GitHub Copilot

If yes, I used the following Model(s):

Claude 4.6 Opus

@github-actions
Copy link
Copy Markdown
Contributor

Thank you for the pull request, @alarkbentley!

✅ We can confirm we have a CLA on file for you.

@mzschwartz5
Copy link
Copy Markdown
Contributor

I know this is just a draft PR but - before you spend too much (more) time on this - is there an github issue this stems from? I haven't evaluated the usefulness of this feature, but it's good to discuss new features before so we don't waste your time if it's not a good fit for Cesium.

@javagl
Copy link
Copy Markdown
Contributor

javagl commented Mar 25, 2026

Until alarkbentley chimes in: This looks like it is supposed to become sort of a building block of #13244

In general, the functionality to "access geometry data" is required at many, many places. And it is implemented at many, many places, which is not ideal. Some places that come to my mind are extracting the vertex positions for picking, handling "outline rendering", or extracting the vertex positions for draping.
(In the context of the latter, I already started a ModelReader, but... when something is not implemented thoroughly and properly, and from the ground up (!), then everybody will implement solutions for "the part that is currently needed", duplicating work and causing churn in the long run. It is what it is)

However, the specific context here appears to aim at the possiblity to extract "something like a clipping polygon" from a tileset. To be confirmed.

@alarkbentley
Copy link
Copy Markdown
Contributor Author

@mzschwartz5 As @javagl mention this is a building block for #13244. I decided to split this up into two separate PRs to keep the complexity down. Also this capability itself can have some value outside of generating footprints in the case the user want's to do calculations based on the tile geometry.

If we decide to not expose this in the public API in the end that is fine, we could keep it as an internal utility. But I do think it could provide end user value.

I will try to complete this by the end of week and add a proper PR description for you to review.

@javagl
Copy link
Copy Markdown
Contributor

javagl commented Mar 26, 2026

Also this capability itself can have some value outside of generating footprints in the case the user want's to do calculations based on the tile geometry.

But I do think it could provide end user value.

Yes!!!

I already mentioned #13052 , outlining some of the use cases that are currently prevented by the bufffer-vs-typedArray handling, and which boil down to that pretty fundamental task of "accessing geometry data".

Given that this is a draft, and it's not yet clear where exactly this is going, it may be too early (and maybe not even my "job") to provide first feedback here. But I've already been pondering with ~"some of this", so maybe it's OK:

I had a short look at ModelMeshUtility.

There are readPositionData, readColorData, and readFeatureIdData. They have quite some overlap. All of them are doing the same twiddling of byteStide/byteOffset/IsInterleaved. And I wonder whether there will be readNormalData, readTexCoordData, readJointsData, readWeightsData, readScaleData, and readRotationData in the future (all doing essentially the same thing).

There is decodeAndTransformPosition. And I tend to apply some scrutiny when I see an and in a function name. The instanceTransform is just passed on for that Matrix4.multiplyByPoint at the end. It could make sense to split this up. There will certainly be a place and time when someone wants to only dequantize (and not transform). Not the positions, though - at least, not in this form: It does not make much sense to apply "oct-encoding" to positions. This is only applied to normals. Right now, there is no readNormalData function, though.

However... even if it's once more a shameless self-promotion: Iff anything that is done in the ModelMeshUtility can not be done with the ModelReader, then I'd (be curious what that is, and) extend the ModelReader accordingly. Yes, it's certainly not complete and not perfect, because when I had to implement that, as part of another feature, I did (of course) not have enough time to iron out the kinks that result from 10 years of simply not having such a class. But I tried to keep it generic, with the goal of having reusable building blocks. We already have sooo much redundancy, and redundancy is redundant. Maybe we can avoid that.

(One thing that the ModelReader does not offer is some of the instance-transforms handling and computeNodeTransforms. This was apparently extracted from pickModel. And it should be a dedicated function. But when it is a dedicated function, maybe it could also be used (i.e. just called) in pickModel?)

@alarkbentley
Copy link
Copy Markdown
Contributor Author

@javagl I'm currently working on merging the capabilities of ModelMeshUtility and ModelReader (see my commit a few minutes ago). My initial mistake was to base my implementation on the pickModel (which I assume was written before we had the ModelReader).
Currently working my way backwards with the goal of reusing as much of existing ModelReader as possible and potentially adding to it if there are missing pieces.

Would be nice to port pickModel to use the ModelReader... but I'm not sure I want to open that bucket right now.

Current state is very much a draft still, but I appreciate the early feedback.

@javagl
Copy link
Copy Markdown
Contributor

javagl commented Mar 26, 2026

I'm currently working on merging the capabilities of ModelMeshUtility and ModelReader (see my commit a few minutes ago). My initial mistake was to base my implementation on the pickModel (which I assume was written before we had the ModelReader).

It's not necessary to call this a "mistake". I certainly took ... inspiration... from pickModel as well at some places.

(If there is a "mistake", then it is that pickModel is a 320-line-function that calls a 60-line-function that has 10 parameters and zero documentation, and overall is doing ~10 things at once. I could summarize that. It would sound even more dismissive. But to emphasize that: I'm not blaming whoever wrote pickModel originally. It may also have been gathered by copy-pasting a bunch of snippets, under time pressure, to get a feature done. Good software needs time, that's all I'm saying)

Currently working my way backwards with the goal of reusing as much of existing ModelReader as possible and potentially adding to it if there are missing pieces.

I certainly don't want to "urge" you to do that in any way (if it causes too much effort or difficulties). But the goal of the ModelReader was to make something like the geometry extraction easier. And if it does not achieve that goal, I'd like to know how it could be improved. (I'm aware of some points that are 'not ideal', but may not have all usage scenarios on the radar). I hope that this generic readAttributeAsTypedArray covers most needs. In the ModelMeshUtility, eventually, you would need to add readNormalData and readTexCoordData, then already having to handle VEC2, VEC3, VEC4 with byte/short normalizations and different quantization methods and whatnot.

Would be nice to port pickModel to use the ModelReader... but I'm not sure I want to open that bucket right now.

One important point for "aligning" things and avoiding redundancies: I've seen that some of your implementations checked for 'WebGL2'. This was related to reading data from the GPU to the CPU, and that whole buffer-vs-typedArray topic. As far as I had seen in earlier PRs, you had some ~ allowGeometryExtraction flag that kept the data on the CPU. So I think that this WebGL2-check is not strictly necessary. (If this was intended to be future-proofing for cases where the data is on the GPU, we'd have to talk about details).


Even if this check for "WebGL2" is necessary:

The methods received the FrameState. Have a look at the FrameState ""class"". It has the following properties:

  this.context = context;
  this.commandList = [];
  this.panoramaCommandList = [];
  this.shadowMaps = [];
  this.brdfLutGenerator = undefined;
  this.environmentMap = undefined;
  this.sphericalHarmonicCoefficients = undefined;
  this.specularEnvironmentMaps = undefined;
  this.specularEnvironmentMapsMaximumLOD = undefined;
  this.mode = SceneMode.SCENE3D;
  this.morphTime = SceneMode.getMorphTime(SceneMode.SCENE3D);
  this.frameNumber = 0;
  this.newFrame = false;
  this.time = undefined;
  this.jobScheduler = jobScheduler;
  this.mapProjection = undefined;
  this.camera = undefined;
  this.cameraUnderground = false;
  this.globeTranslucencyState = undefined;
  this.cullingVolume = undefined;
  this.occluder = undefined;
  this.maximumScreenSpaceError = undefined;
  this.pixelRatio = 1.0;
  this.passes = {
  this.creditDisplay = creditDisplay;
  this.afterRender = [];
  this.scene3DOnly = false;
  this.fog = {
  this.atmosphere = undefined;
  this.verticalExaggeration = 1.0;
  this.verticalExaggerationRelativeHeight = 0.0;
  this.shadowState = {
  this.splitPosition = 0.0;
  this.frustumSplits = [];
  this.backgroundColor = undefined;
  this.light = undefined;
  this.minimumDisableDepthTestDistance = undefined;
  this.invertClassification = false;
  this.invertClassificationColor = undefined;
  this.useLogDepth = false;
  this.tilesetPassState = undefined;
  this.minimumTerrainHeight = 0.0;
  this.pickingMetadata = false;
  this.pickedMetadataInfo = undefined;
  this.edgeVisibilityRequested = false;

I think that the name of a class should reflect the purpose and usage of that class. So FrameState should be renamed to RandomStuffThatIsNeededSomewhere. These are 45(!) completely unrelated properties, some of them pretty complex data structures. And they include highly problematic ones, like the Context. The Context ""class"" has the following properties:

  this._canvas = canvas;
  this._originalGLContext = glContext;
  this._gl = glContext;
  this._webgl2 = webgl2;
  this._id = createGuid();
  this.validateFramebuffer = false;
  this.validateShaderProgram = false;
  this.logShaderCompilation = false;
  this._throwOnWebGLError = false;
  this._shaderCache = new ShaderCache(this);
  this._textureCache = new TextureCache();
  this._stencilBits = gl.getParameter(gl.STENCIL_BITS);
  this._antialias = gl.getContextAttributes().antialias;
  this._standardDerivatives = !!getExtension(gl, ["OES_standard_derivatives"]);
  this._blendMinmax = !!getExtension(gl, ["EXT_blend_minmax"]);
  this._elementIndexUint = !!getExtension(gl, ["OES_element_index_uint"]);
  this._depthTexture = !!getExtension(gl, [
  this._fragDepth = !!getExtension(gl, ["EXT_frag_depth"]);
  this._debugShaders = getExtension(gl, ["WEBGL_debug_shaders"]);
  this._textureFloat = !!getExtension(gl, ["OES_texture_float"]);
  this._textureHalfFloat = !!getExtension(gl, ["OES_texture_half_float"]);
  this._textureFloatLinear = !!getExtension(gl, ["OES_texture_float_linear"]);
  this._textureHalfFloatLinear = !!getExtension(gl, [
  this._supportsTextureLod = !!getExtension(gl, ["EXT_shader_texture_lod"]);
  this._colorBufferFloat = !!getExtension(gl, [
  this._floatBlend = !!getExtension(gl, ["EXT_float_blend"]);
  this._colorBufferHalfFloat = !!getExtension(gl, [
  this._s3tc = !!getExtension(gl, [
  this._pvrtc = !!getExtension(gl, [
  this._astc = !!getExtension(gl, ["WEBGL_compressed_texture_astc"]);
  this._etc = !!getExtension(gl, ["WEBG_compressed_texture_etc"]);
  this._etc1 = !!getExtension(gl, ["WEBGL_compressed_texture_etc1"]);
  this._bc7 = !!getExtension(gl, ["EXT_texture_compression_bptc"]);
  this._textureFilterAnisotropic = textureFilterAnisotropic;
  this.glCreateVertexArray = glCreateVertexArray;
  this.glBindVertexArray = glBindVertexArray;
  this.glDeleteVertexArray = glDeleteVertexArray;
  this.glDrawElementsInstanced = glDrawElementsInstanced;
  this.glDrawArraysInstanced = glDrawArraysInstanced;
  this.glVertexAttribDivisor = glVertexAttribDivisor;
  this.glDrawBuffers = glDrawBuffers;
  this._vertexArrayObject = !!vertexArrayObject;
  this._instancedArrays = !!instancedArrays;
  this._drawBuffers = !!drawBuffers;
  this._clearColor = new Color(0.0, 0.0, 0.0, 0.0);
  this._clearDepth = 1.0;
  this._clearStencil = 0;
  this._defaultPassState = ps;
  this._defaultRenderState = rs;
  this._defaultTexture = undefined;
  this._defaultEmissiveTexture = undefined;
  this._defaultNormalTexture = undefined;
  this._defaultCubeMap = undefined;
  this._us = us;
  this._currentRenderState = rs;
  this._currentPassState = ps;
  this._currentFramebuffer = undefined;
  this._maxFrameTextureUnitIndex = 0;
  this._vertexAttribDivisors = [];
  this._previousDrawInstanced = false;
  this._pickObjects = new Map();
  this._nextPickColor = new Uint32Array(1);
  this.options = {
  this.cache = {};

Yup, that's 64 unrelated, complex, largely undocumented properties, and only one of them is the gl (i.e. the actual context) that cannot be created without the Canvas. Nothing of this can sensibly be documented, used, maintained, tested, and certainly not be mocked for tests in CI. (I recently tried it, but had to give up...)

The point is: The methods did not need a FrameState. They only need a single, simple, boolean parameter - namely, webgl2.

(And as I mentioned above: I hope that they don't even need that. If they need "something like that", we could try to find a better solution...)

@donmccurdy
Copy link
Copy Markdown
Member

I'm not familiar enough to have an opinion on most of the above, but — especially in a performance-oriented API, it might be ideal to return Float64Array rather than Cartesian[] for positions, and perhaps Uint8Array for colors. Then the caller can avoid those allocations if they aren't needed.

@javagl
Copy link
Copy Markdown
Contributor

javagl commented Mar 26, 2026

Most of the ModelReader already operates on plain, flat typed arrays. (The main method is even called readAttributeAsTypedArray, because there are just too many types and shapes that "attribute data" could have).

And again: It certainly has to be improved and extended, to be that universal tool that I would have liked it to be some years ago, and imagined it to be in a (hopefully not so distant) future.

About avoiding allocations: Some functions already accept an optional result parameter, but not all of them. In many cases, the caller doesn't have a clue what the result type is. And if there's a call like readIndices(intIndices, byteTypedArray) then there has to be some error handling or casting or whatnot. And if the caller feels the urge to do some if (components.indices.componentType === ...) ... else cascade to create the "right" array at each call site, that would defeat much of the purpose.

Float64Array rather than Cartesian[] for positions, and perhaps Uint8Array

Right now, there's a bit of a mix of types and requirements. At some places, "things" (tuples) are stored or expected as CartesianX objects or CartesianX[] arrays. At other places, there already are flat number[] arrays (for single tuples or arrays of tuples).

The different CartesianX types may require some special handling, but that's not suuuch a big deal (there are (and always will be) only three of them).

There already are conversion functions, back and forth, with the respective (un)packArray functions. Further generalizations could be added, but may be adding some not-so-trivial abstractions, possible API complexity, or other allocation overheads.

One thing - of which I'm aware that it can be 'dangerous'! - is ... what you might call a "flyweight pattern". For example, when something just needs a bunch of Cartesian3 objects to iterate over (once), and there only is a Float32Array, then one can do things like createIterableCartesian3FromTypedArray, with zero allocations. But when a client does store such an object, bad things can happen. There's no ReadOnlyCartesian3, unfortunately...

All these things are design considerations that one could think about, deeply and thoroughly (even if it started on a 'green field'). Retrofitting generalizations or abstractions to existing structures can be even more challenging.

Copy link
Copy Markdown
Contributor

@javagl javagl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some inlined comments. Some of them may become obsolete, depending on whether/how the 'High-level' comments are addressed.

High-level:


Despite the fact that the current implementation is synchronous the introduced API endpoint is exposed with an async handle to enable future non-breaking improvements such as WebWorkers or async GPU readback.

Big +1 for that. The only way to make a future-proof API is to make everything async. This is obscure and inconvenient, but the harsh truth for JavaScript.


I think that the current API is very constrained. That's not necessarily a bad thing. The API should do exactly what it is supposed to do, and not be over-generalized to accidentally offer half-baked functionalities. But in its current form, the API can hardly evolve. Adding new extractions is hardly possible without considerable changes of the structures.

Extracted properties:

The GeometryResult currently has the positions and colors. It is very reasonable to assume that people will want to extract more - mainly, the normals. (A very recent case: https://community.cesium.com/t/how-to-align-glb-models-to-a-gltf-tileset-surface-picking-height-and-surface-normals/45632 - someone wanted to pick something, and align a model to the picked point, based on its surface normal).

There are some options that one could consider. One could add a normals property to that output. And one could consider adding a texCoords property to that output. Eventually, it could be very desirable to have the possibility to access arbitrary EXT_structural_metadata property attributes.

At this point, one could consider a return type like this

class GeometryResult {
  attributeNames: string[]; // contains strings like `POSITION` and `NORMAL` or `_MY_PROPERTY`
  attributeValues: Map<string, Array>; // Contains the values - see notes below!
  attributeTypes: Map<string, ...>; // See notes below
}

Of course, the attribute values would be untyped. They could contain Cartesian3[] or Color[] or Matrix2[]. In the spirit of the API design mantra that "simple things should be easy, and complex things should be possible", one could consider typed convenience functions for the common attributes:

class GeometryResult {
  ...
  
  getPositions() : Cartesian3[] | undefined {
    const a = this.attributeValues("POSITION");
    if (defined(a)) { return undefined; }
    return a as Cartesian3[];
  }
}

For other attributes, the relevant information could be stored in the attributeTypes, which would include the type (like VEC3) and componentType (like UINT16) in one form or the other.

The limitation of the return type is pretty much tied to the options and the extractPositions and extractColors flags. A pragmatic generalization would be to offer some
options.extractedProperties = ["POSITION", "_MY_PROPERTY" ];
to handle that. (The caller can not know beforehand which properties are there, but that's a different topic - the caller doesn't know about the presence of the colors either...)

Extracted structure:

I think that I do understand the intention behind grouping the extracted geometry by the feature ID. But I don't understand the intention behind doing that by default. I'll have to check some details, but think that it should be relatively easy to make this an optional, second step. Very roughly (somewhat brainstorming):

The extractAttributesFromPrimitive receives the primitive, the feature ID label, and the Map to store the grouped results. It should be fairly easy to split this up into two functions:

  • extractAttributesFromPrimitive (which, I think, could actually return a GeometryResult!)
  • partitionGeometryResult

The latter could receive a GeometryResult and the feature ID data, and return a Map<number, GeometryResult> that contains the clustered/grouped geometry. This would allow differentiating between the case where the user wants the geometry of one feature, and the case where the user just wants the whole geometry of the picked object. The latter would be useful for the cases where the input data is not even clustered by a feature ID (e.g. point clouds without batch tables).

Tests

I tested the new Sandcastle, and it works nicely, as intended.


Loading a non-batched point cloud

const tileset = await Cesium.Cesium3DTileset.fromUrl(
  "../../SampleData/Cesium3DTiles/PointCloud/PointCloudRGB/tileset.json",
);

omitting the feature ID handling

  //if (!(pickedFeature instanceof Cesium.Cesium3DTileFeature)) {
  //  return;
  //}

  // Highlight the selected feature
  //selectedFeature = pickedFeature;
  //selectedFeature.color = highlightColor;

throws

Error
    at new DeveloperError (http://localhost:8081/packages/engine/Build/Unminified/index.js:7460:11)
    at Check.defined (http://localhost:8081/packages/engine/Build/Unminified/index.js:7496:11)
    at AttributeCompression.dequantize (http://localhost:8081/packages/engine/Build/Unminified/index.js:29414:17)
    at _ModelReader.readAttributeAsTypedArray (http://localhost:8081/packages/engine/Build/Unminified/index.js:105275:59)
    at extractAttributesFromPrimitive (http://localhost:8081/packages/engine/Build/Unminified/index.js:125155:39)
    at http://localhost:8081/packages/engine/Build/Unminified/index.js:125084:7
    at _ModelReader.forEachPrimitive (http://localhost:8081/packages/engine/Build/Unminified/index.js:105947:9)
    at ModelGeometryExtractor.getGeometryForModel (http://localhost:8081/packages/engine/Build/Unminified/index.js:125080:23)
    at Model3DTileContent.getGeometry (http://localhost:8081/packages/engine/Build/Unminified/index.js:125352:41)
    at <anonymous>:73:37

This may not be an issue of this PR. It may be caused by the fact that the PntsLoader does not assign the count property to attributes, except for the "POSITION" attribute, as of

attribute.count = parsedContent.pointsLength;

I think that it could and should assign this property, iff it can be verified that this count is always present and correct.


After pragmatically inserting the proper count values:

For a point cloud, there are no indices, and that will crash

ModelReader.js:684 Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'typedArray')
    at _ModelReader.readIndicesAsTypedArray (ModelReader.js:684:46)
    at extractAttributesFromPrimitive (ModelGeometryExtractor.js:217:33)
    at ModelGeometryExtractor.js:76:7
    at _ModelReader.forEachPrimitive (ModelReader.js:898:9)
    at ModelGeometryExtractor.getGeometryForModel (ModelGeometryExtractor.js:72:15)
    at Model3DTileContent.getGeometry (Model3DTileContent.js:233:33)
    at <anonymous>:86:37
    at cancelMouseEvent (ScreenSpaceEventHandler.js:290:9)
    at handleMouseUp (ScreenSpaceEventHandler.js:312:5)
    at handlePointerUp (ScreenSpaceEventHandler.js:863:5)

When loading composite data...

const tileset = await Cesium.Cesium3DTileset.fromUrl(
  "../../SampleData/Cesium3DTiles/Composite/Composite/tileset.json",
);

then vertices will be shown only when clicking the lower left box (not for other boxes). I haven't looked at how to resolve this. Maybe what is currently done with the "feature ID" will have to be done with some "instance ID/index" instead. This may become obsolete, depending on possible changes of the feature ID handling.


When loading vector data...

const viewer = new Cesium.Viewer("cesiumContainer", {
  terrain: Cesium.Terrain.fromWorldTerrain(),
});
const geocoder = viewer.geocoder.viewModel;
geocoder.searchText = "Vienna, Austria";
geocoder.flightDuration = 0.0;
geocoder.search();

const tileset = await Cesium.Cesium3DTileset.fromIonAssetId(5737);

the clicking will cause

Uncaught (in promise) TypeError: content.getGeometry is not a function
    at <anonymous>:86:37
    at cancelMouseEvent (ScreenSpaceEventHandler.js:290:9)
    at handleMouseUp (ScreenSpaceEventHandler.js:312:5)
    at handlePointerUp (ScreenSpaceEventHandler.js:863:5)
    at HTMLCanvasElement.listener (ScreenSpaceEventHandler.js:53:5)

This could be avoided with some instanceof Model3DTileContent check at the client side, but that's probably not intended or desired.

I think whatever was supposed to be accomplished with the DeveloperError.throwInstantiationError() simply does not work, and that all ""implementations"" of the Cesium3DTileContent ""interface"" will have to explicitly implement this function (making the whole ""interface"" obsolete after all...)

/**
* Groups unique vertex indices by feature ID from indices or vertex count.
* This is shared across all attribute extraction to avoid duplicate work.
* @private
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Some specific aspects of this may become obsolete, depending on whether or how the API is changed for non-featureId-cases.


This should include proper documentation of the cases that the input parameters are undefined (as JDoc text, or part of @param JSDocs).

...
If the indices are undefined, then nothing will be done, so you wouldn't have to call this method in the first place.
If the featureIdData is undefined, then the map will map the value `0` to the set of all indices, in a very inefficient way.

@param {TypedArray|undefined} indices The indices
@param {TypedArray|undefined} featureIdData The feature ID data

The "very inefficient" part refers to the fact that it is checking for defined(featureIdData) for each index. This does not change. There could be some

if (!defined(featureIdData) {
  map.set(0, [...indices]);
  return map;
}

watchdog at the beginning. (Again: depending on how relevant 'featureIdData' will be in the future!)

const extractColors = options.extractColors ?? false;
const result = new Map();

if (!model._ready) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is "fail silent". Users will receive an empty result and have no chance of figuring out why. It should at least be VERY boldly documented, or print a warning. Things like Model.boundingSphere even go so far to throw

      if (!this._ready) {
        throw new DeveloperError(
          "The model is not loaded. Use Model.readyEvent or wait for Model.ready to be true.",
        );
      }

But I would hesitate to recommend that. It's quirky, and the concept of "ready" as a whole doesn't make sense. (Some more RAII-like approach would be nice, but ... here we are, related to #13052 ...)

// Positions need per-instance-transform duplication
if (defined(posData)) {
for (let t = 0; t < instanceTransforms.length; t++) {
readPosition(posData, vertexIndex, numPosComponents, scratchPosition);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this readPosition can be pulled out of the for loop, if the multiplyByPoint below is changed to receive a dedicated result, as in

... originalScratchPosition, transformedScratchPosition

so that the original position only has to be read once from the position data (and the same position is then transformed with all transforms)

*
* @private
*/
function computeNodeTransforms(runtimeNode, sceneGraph, model, result) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a cross-link: This is basically the same of what I did in https://github.com/CesiumGS/cesium/pull/11830/changes#diff-c25ab54b614865d4ba17f0fbc27717f5cbaa07931f69deb0b83b7d0daa1d3505R235

(Unfortunately, that PR had to be abandoned, and the "revival" at #12658 probably counts as "abandoned" as well in the meantime. The model picking is still highly inefficient. Maybe we can "revive the revival" after this PR is merged, given that it now does contain building blocks like this computeNodeTransforms, which should have existed right from the beginning...)

So a 👍 for that.

const instances = node.instances;

if (defined(instances)) {
const transformsCount = instances.attributes[0].count;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ModelReader was only a stub when I created it, and will require further iterations to be "complete". Specifically: It currently still carries a comment
NOTE: All this does not properly handle MATn types.

And I think that these 'transforms' that are extracted here could/should eventually be handled with the readAttributeAsTypedArray as well, when (not 'if', but 'when') it supports MAT4 attributes.

offset,
elementStride,
quantization,
readPosition(vertices, i0, numPosComponents, scratchV0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I have not (yet) thoroughly reviewed or explicitly tested the changed in pickModel.js. The diff-view here on GitHub is scattered. I looked at the resulting code, ad it looks much simpler and cleaner than before. So it should be possible to review (only) the new state of the code with reasonable effort.

But there are some aspects of pickModel that are the reason why I'd like to spend a bit more time for a review here. These aspects are related to #12658 . It looks like this refactoring was essentially done here as well, but I'd like to check the details. Specifically: The handling of the 'vertical exaggeration' is a bit tricky, and I'll have to explicitly and dedicatedly review and test this.

In the meantime and for now:

I think that the three readPosition calls can be pulled out of the loop, storing the results in some scratchVX_input, and then change the calls below to
`multiplyByPoint(..., scratchVX_input, scratchVX_output)
so that the positions only have to be read once, and are then transformed with all matrices.

@mzschwartz5
Copy link
Copy Markdown
Contributor

mzschwartz5 commented Apr 15, 2026

It was brought to my attention that I'm working on something somewhat similar, here.

You can read in that PR's description what the overall goal is there, but that specific link is to a general interface I'm introducing to access geometry data. The idea would then be to implement version of that interface for various geometry-containing classes (Primitive, Model, or - in your case - Cesium3DTile). I think what I'm building solves a general problem in one place rather than specific problems in multiple, and could maybe be reused in your work.

Note: my PR is still in draft. That interface I'm building will have more methods added to it beyond just getting/setting vertices. E.g. I could see it containing general methods for accessing generic vertex attributes.

@alarkbentley alarkbentley changed the title Extract Geometry from 3DTiles Extract Geometry from 3D Models Apr 15, 2026
@alarkbentley
Copy link
Copy Markdown
Contributor Author

alarkbentley commented Apr 15, 2026

@javagl Could you take another round at this when you get some time to spare? I did some major refactoring based on your previous comments which all made sense.

Summary of the updates:

ModelReader

  • forEachPrimitive now support readback of VEC3 instance transforms (TRANSLATION, ROTATION, SCALE)
  • Added forEachPrimitive options.perInstanceFeatureIds to toggle instance featureid extraction

pickModel

  • Cache readPosition results in scratch
  • Fixed invalid unit-test "returns position of intersection with instanced model"
    --> Previouly did not read VEC3 transforms

ModelGeometryExtractor

  • Remove default featureid grouping (getGeometry now return a list instead of a map)
  • Support Instanced Geometry (w. InstanceAttributeSemantic.FEATURE_ID)
    --> Fixes Cesium3DTiles/Composite/Composite/tileset.json
  • Support Non Indexed Geometry (eg. Point Cloud)
    --> Fixes Cesium3DTiles/PointCloud/PointCloudRGB/tileset.json
  • Opt-in for extract featureid
  • Support extracting Indices (extractIndices=true)
  • Replaced extractPositions, extractColors flags with a generalized attributes: ["POSITION", "COLOR", ...]

GeometryResult

  • Generalized GeometryResult (attributeNames, attributeValues, attributeTypes w. helpers eg. getPositions)

Cesium3DTileContent

  • Moved the getGeometry API to Cesium.Model to reduce API surface area
    --> Vector data no longer relevant
composite instanced_orientation point_cloud

@alarkbentley
Copy link
Copy Markdown
Contributor Author

It was brought to my attention that I'm working on something somewhat similar, here.

You can read in that PR's description what the overall goal is there, but that specific link is to a general interface I'm introducing to access geometry data. The idea would then be to implement version of that interface for various geometry-containing classes (Primitive, Model, or - in your case - Cesium3DTile). I think what I'm building solves a general problem in one place rather than specific problems in multiple, and could maybe be reused in your work.

Note: my PR is still in draft. That interface I'm building will have more methods added to it beyond just getting/setting vertices. E.g. I could see it containing general methods for accessing generic vertex attributes.

@mzschwartz5 Thanks for bringing this to my attention. This work certainly has some potential overlap.
Could you please review my latest changes in the PR to see how we could align? I moved the API surface from Cesium3DTile into the Model. Both the ModelReader (general utilities for reading primitive data) and the ModelGeometryExtractor (which is basically a GeometryReader for Model if I understand your PR correctly) could probably be generalized for your use-case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants