Skip to content

Chapter 09 — 3D

Goal: turn the OBJ you uploaded in Chapter 04 into a real, browsable 3D revision, and map its named nodes onto your assets — via notebook first, then a packaged Function, on a DMS-only project where the classic Toolkit 3dmodels/ resource does not apply.

📚 [DOCS] https://docs.cognite.com/cdf/3d/index · https://docs.cognite.com/cdf/3d/guides/3dmodels_intro · https://docs.cognite.com/cdf/3d/guides/api_asset_centric/upload_cad · https://docs.cognite.com/cdf/3d/guides/3dmodels_contextualize


9.1 [INFO] Why this capability, and why it's raw HTTP, not Toolkit YAML

This training project is data-modeling-only — there is no classic asset hierarchy for the Toolkit's classic 3dmodels/ resource type to attach to, and the classic 3D loaders expect fields (like assetMappingCount) that a DMS-only project doesn't have. So instead of a .3DModel.yaml file, the 3D model and revision shells are created at runtime, by your Function, directly against the 3D API. This is a case where "what deploy creates vs. what the Function creates" genuinely splits differently than every previous chapter:

🚧 [LIMITS] The /3d/models create payload differs between projects, and you cannot ask which you are on. A DMS-enabled 3D project requires {"name", "space", "type"}. A classic 3D project rejects space outright:

CogniteAPIError: space is not supported | code: 400

There is no capability flag that tells you in advance, so the only portable approach is to try the DMS shape and fall back to classic — which is exactly what the handler below does. Publishing a revision splits the same way: a DMS model needs the instanceId of the auto-created cog_3d_revision_{id} node; a classic model takes id alone.

⚠️ [COMMON MISTAKE] Hard-coding whichever shape worked on the project you developed against. It will fail on the next one, with an error that reads like your data is wrong rather than your payload.

Created by cdf deploy (Chapter 04) Created at runtime by Load3DRevision
The classic FileMetadata shell for the OBJ (no bytes yet referenced by $FILEPATH — see Ch. 04 section 4.5) The 3D model shell, the revision, the CAD-node/object3D/asset mapping nodes

9.2 [INFO] The revision processing lifecycle

Queued → Processing → Done | Failed

Conversion time depends on model complexity — this lab's OBJ is small, but real CAD models can take much longer. Never fail the whole lab pipeline just because a 3D revision hasn't finished converting — this is why, once you reach Chapter 12, the workflow task for this Function is configured with onFailure: skipTask: 3D is allowed to still be queued when the rest of the pipeline finishes.

[OPTIMIZE]resume, don't restart. The handler you'll deploy checks for an existing revision before creating a new one, and re-checks published before publishing again. Calling the Function a second time while a revision is mid-convert should cheaply report its current status, not create a duplicate revision or error.


9.3 [WRITE] + [ACTION] Notebook: 03_load_3d_revision.ipynb

📝 [WRITE] Recreate docs/notebooks/03_load_3d_revision.ipynb. Cell order: auth → find/create the model shell → find or create the revision from the classic OBJ file → poll Queued/Processing with a timeout → inspect nodes → map a handful of node names to assets → bridge to the Function.

🟢 [ACTION] The 3D model/revision APIs here are not exposed as typed SDK methods on this DMS-only project shape — you call them as raw HTTP through the SDK's client.get / client.post, exactly like you eventually will for the Document Parser API in Chapter 10. This is good practice for that pattern:

base = f"/api/v1/projects/{client.config.project}/3d/models"
resp = client.get(base, params={"limit": 1000}).json()

[VERIFY] notebook results in CDF: Fusion → 3D → your model (trd_<YOURNAME>_TRN_CAD) → a published revision you can rotate/pan.


9.4 [WRITE] The Function: Load3DRevision

What this Function does

Four jobs, in order:

  1. Ensure a 3D model container exists (create it only if absent)
  2. Upload the .obj as a revision and wait — minutes — for CDF to convert CAD geometry into a streamable format
  3. Publish the finished revision so viewers can load it
  4. Wire each CAD part to the CogniteAsset it represents, so clicking a pump in the 3D viewer selects the same pump you have been building all day

Why this one is longer than the others

Because it is the only Function here that waits on a long-running external process it does not control. Conversion can take 20 minutes. Everything unusual in this file — the raw HTTP, the cursor loops, the resume flag — comes from that one fact.

Why raw HTTP instead of the SDK's 3D helpers

The classic SDK loaders expect asset-centric fields such as assetMappingCount, which a Data-Modeling-only project does not return. Calling them here fails on a missing key. So this Function calls client.get / client.post directly against the 3D endpoints — the same authenticated client, just without the classic convenience wrapper on top.

This is a genuinely useful pattern to recognise: when an SDK helper assumes a data shape your project does not have, dropping to the REST call underneath is normal, not a hack.

The five states a revision moves through

(none) ──create──► Queued ──► Processing ──► Done ──publish──► published:true
                                    └──► Failed          (terminal — do not retry blindly)

Queued and Processing are the only states worth waiting on. The Function polls for up to 20 minutes, then returns resume: true rather than hanging — you re-run it later and it picks up where it left off. That is why step 1 is ensure, not create: the whole Function is designed to be safely re-entrant.

📝 [WRITE] training/modules/participants/<YOURNAME>/functions/fnc_<YOURNAME>_Training_Load3DRevision/handler.py

"""Upload a 3D revision and map CAD node names to CogniteAsset.object3D.

Uses raw HTTP for DMS 3D model/revision/node APIs. The classic SDK loaders
expect fields like ``assetMappingCount`` that Data-Modeling-only projects omit.
"""

from __future__ import annotations

import os
import time
from types import SimpleNamespace

from cognite.client.data_classes.data_modeling import (
    DirectRelationReference, NodeApply, NodeOrEdgeData, ViewId,
)

TAG_MAP = {
    "21-VG-2001": "21-VG-2001", "21-PA-2001A": "21-PA-2001A", "21-PA-2001B": "21-PA-2001B",
    "21-HA-2001": "21-HA-2001", "21-XV-2001": "21-XV-2001", "DECK": "TRN-21-SEP",
}


def _project(client) -> str:
    return client.config.project


def _ensure_dms_cad_model(client, model_name: str, space: str) -> SimpleNamespace:
    base = f"/api/v1/projects/{_project(client)}/3d/models"
    cursor = None
    while True:
        params: dict = {"limit": 1000}
        if cursor:
            params["cursor"] = cursor
        payload = client.get(base, params=params).json()
        for item in payload.get("items") or []:
            if item.get("name") == model_name:
                return SimpleNamespace(id=item["id"], name=item.get("name"), raw=item)
        cursor = payload.get("nextCursor")
        if not cursor:
            break
    # PROJECT-DEPENDENT payload -- see the [LIMITS] box in section 9.1.
    for item in ({"name": model_name, "space": space, "type": "CAD"},
                 {"name": model_name}):
        try:
            response = client.post(base, json={"items": [item]})
            break
        except Exception as exc:
            if "space is not supported" not in str(exc):
                raise
    model_id = response.json()["items"][0]["id"]
    return SimpleNamespace(id=model_id, name=model_name, raw=response.json()["items"][0])


def _list_revisions(client, model_id: int) -> list[dict]:
    base = f"/api/v1/projects/{_project(client)}/3d/models/{model_id}/revisions"
    items: list[dict] = []
    cursor = None
    while True:
        params: dict = {"limit": 100}
        if cursor:
            params["cursor"] = cursor
        payload = client.get(base, params=params).json()
        items.extend(payload.get("items") or [])
        cursor = payload.get("nextCursor")
        if not cursor:
            break
    return items


def _get_revision(client, model_id: int, revision_id: int) -> dict:
    return client.get(f"/api/v1/projects/{_project(client)}/3d/models/{model_id}/revisions/{revision_id}").json()


def _publish_revision(client, model_id: int, revision_id: int, space: str) -> dict:
    body = {"items": [{
        "id": revision_id,
        "instanceId": {"space": space, "externalId": f"cog_3d_revision_{revision_id}"},
        "update": {"published": {"set": True}},
    }]}
    response = client.post(f"/api/v1/projects/{_project(client)}/3d/models/{model_id}/revisions/update", json=body)
    return response.json()["items"][0]


def _create_revision(client, model_id: int, file_id: int) -> dict:
    session = client.iam.sessions.create()
    response = client.post(
        f"/api/v1/projects/{_project(client)}/3d/models/{model_id}/revisions",
        json={"items": [{"fileId": file_id, "published": True, "nonce": session.nonce}]},
    )
    return response.json()["items"][0]


def _list_nodes(client, model_id: int, revision_id: int) -> list[dict]:
    base = f"/api/v1/projects/{_project(client)}/3d/models/{model_id}/revisions/{revision_id}/nodes"
    items: list[dict] = []
    cursor = None
    while True:
        params: dict = {"limit": 1000}
        if cursor:
            params["cursor"] = cursor
        payload = client.get(base, params=params).json()
        items.extend(payload.get("items") or [])
        cursor = payload.get("nextCursor")
        if not cursor:
            break
    return items


def _bbox_props(node: dict) -> dict[str, float]:
    bbox = node.get("boundingBox") or {}
    mins = bbox.get("min") or [0.0, 0.0, 0.0]
    maxs = bbox.get("max") or [1.0, 1.0, 1.0]
    return {"xMin": float(mins[0]), "yMin": float(mins[1]), "zMin": float(mins[2]),
            "xMax": float(maxs[0]), "yMax": float(maxs[1]), "zMax": float(maxs[2])}


def handle(client, data=None, secrets=None, function_call_info=None) -> dict:
    participant = os.environ["PARTICIPANT"]
    space = os.environ["INSTANCE_SPACE"]
    model_name = f"trd_{participant}_TRN_CAD"
    file_xid = f"file_{participant}_TRN_3D_21_SEP"

    model = _ensure_dms_cad_model(client, model_name, space)
    revisions = _list_revisions(client, model.id)
    revision = revisions[0] if revisions else None
    if revision is None:
        src = client.files.retrieve(external_id=file_xid)
        if src is None or not src.uploaded:
            return {"error": "OBJ classic file missing or not uploaded", "file": file_xid}
        revision = _create_revision(client, model.id, src.id)

    revision_id = revision["id"]
    deadline = time.time() + 20 * 60
    status = revision.get("status")
    while status in ("Queued", "Processing") and time.time() < deadline:
        time.sleep(15)
        revision = _get_revision(client, model.id, revision_id)
        status = revision.get("status")

    if status == "Failed":
        return {"status": "Failed", "model_id": model.id, "revision_id": revision_id, "resume": False}
    if status != "Done":
        return {"status": status, "model_id": model.id, "revision_id": revision_id, "resume": True}

    if not revision.get("published"):
        try:
            revision = _publish_revision(client, model.id, revision_id, space)
        except Exception as exc:
            return {"status": status, "model_id": model.id, "revision_id": revision_id,
                    "published": False, "publish_error": str(exc), "resume": False}

    nodes = _list_nodes(client, model.id, revision_id)
    by_name: dict[str, dict] = {}
    for n in nodes:
        name = n.get("name")
        if not name:
            continue
        prev = by_name.get(name)
        if prev is None or int(n.get("subtreeSize") or 1) < int(prev.get("subtreeSize") or 1):
            by_name[name] = n

    cad_model_xid = f"cog_3d_model_{model.id}"
    cad_rev_xid = f"cog_3d_revision_{revision_id}"
    v_model = ViewId("cdf_cdm", "Cognite3DModel", "v1")
    v_rev3d = ViewId("cdf_cdm", "Cognite3DRevision", "v1")
    v_rev = ViewId("cdf_cdm", "CogniteCADRevision", "v1")
    v_obj = ViewId("cdf_cdm", "Cognite3DObject", "v1")
    v_node = ViewId("cdf_cdm", "CogniteCADNode", "v1")
    v_asset = ViewId("cdf_cdm", "CogniteAsset", "v1")

    applies = [
        NodeApply(space=space, external_id=cad_model_xid,
                  sources=[NodeOrEdgeData(source=v_model, properties={"name": model_name, "type": "CAD"})]),
        NodeApply(space=space, external_id=cad_rev_xid, sources=[
            NodeOrEdgeData(source=v_model, properties={"name": model_name, "type": "CAD"}),
            NodeOrEdgeData(source=v_rev3d, properties={
                "model3D": DirectRelationReference(space, cad_model_xid), "status": status,
                "published": True, "type": "CAD"}),
            NodeOrEdgeData(source=v_rev, properties={"revisionId": revision_id}),
        ]),
    ]

    mapped: dict[str, str] = {}
    unmapped: list[str] = []
    for cad_name, asset_xid in TAG_MAP.items():
        node = by_name.get(cad_name)
        if node is None:
            unmapped.append(cad_name)
            continue
        obj_xid, cad_xid = f"obj3d_{cad_name}", f"cadnode_{cad_name}"
        bbox = _bbox_props(node)
        applies.append(NodeApply(space=space, external_id=obj_xid,
                                  sources=[NodeOrEdgeData(source=v_obj, properties={"name": cad_name, **bbox})]))
        applies.append(NodeApply(space=space, external_id=cad_xid, sources=[NodeOrEdgeData(source=v_node, properties={
            "name": cad_name, "object3D": DirectRelationReference(space, obj_xid),
            "model3D": DirectRelationReference(space, cad_model_xid),
            "revisions": [DirectRelationReference(space, cad_rev_xid)],
            "treeIndexes": [int(node.get("treeIndex") or 0)],
            "subTreeSizes": [int(node.get("subtreeSize") or 1)],
        })]))
        applies.append(NodeApply(space=space, external_id=asset_xid, sources=[
            NodeOrEdgeData(source=v_asset, properties={"object3D": DirectRelationReference(space, obj_xid)})]))
        mapped[cad_name] = asset_xid

    client.data_modeling.instances.apply(nodes=applies)
    return {"model_id": model.id, "revision_id": revision_id, "status": status,
            "published": bool(revision.get("published")), "mapped": mapped, "unmapped": unmapped,
            "cad_model": cad_model_xid, "cad_revision": cad_rev_xid}

Line-by-line walkthrough

Helpers

Code What it does Why it is written this way
TAG_MAP = {...} Maps a CAD part name → the asset externalId it represents The CAD author and the tagging engineer never agree. "DECK": "TRN-21-SEP" is the interesting row: the geometry is called DECK, the asset is the separation area. This dictionary is the translation layer, and in a real project it is the deliverable people argue about
_project(client) Reads the project name off the client The raw URLs need /projects/<name>/. Taken from the client rather than an env var so it can never disagree with the credentials in use
_ensure_dms_cad_model Finds the model by name, creates it only if absent Ensure, not create — this Function must survive being re-run. Note it pages with a cursor: matching only the first 1000 models would silently create a duplicate
SimpleNamespace(id=..., name=...) Tiny stand-in object Lets the create and found paths return the same shape, so the caller does not branch on which happened
_list_revisions / _list_nodes Cursor-paged fetch of every item while True … nextCursor is the standard CDF paging idiom. A CAD tree easily exceeds one page; stopping at the first would silently drop parts
_get_revision Fetches one revision's current status Called on every poll iteration to see whether conversion has finished
_publish_revision Sets published: true and attaches a DMS instanceId Unpublished revisions are invisible to viewers. The instanceId is what links the classic 3D revision to its data-modeling node
_create_revision Uploads the file as a new revision client.iam.sessions.create() mints a short-lived nonce so the 3D service can act on your behalf during a conversion that outlives the request
_bbox_props(node) Flattens boundingBox.min/max into six floats The API nests it; the Cognite3DObject view wants flat xMinzMax. Defaults 0,0,01,1,1 keep a node with no geometry from crashing the write

handle

Code What it does Why it is written this way
revisions[0] if revisions else None Reuse an existing revision if there is one Re-running must not upload a second copy of the same geometry
if src is None or not src.uploaded Verifies the OBJ actually finished uploading A file node can exist while its bytes are still in flight. Creating a revision from it would fail deep inside the converter with a far worse message
deadline = time.time() + 20 * 60 20-minute ceiling Conversion is genuinely slow. Sized to be generous but finite
time.sleep(15) Poll every 15 s ~80 polls over the window. Polling every second would just add load without learning anything sooner
if status == "Failed": … "resume": False Terminal failure Says do not retry — something is wrong with the input, and re-running will fail identically
if status != "Done": … "resume": True Still converting when time ran out Not an error. resume: true tells you to call again later. This is why the workflow in Chapter 12 tolerates a skipped 3D task
if not revision.get("published") Publishes only if needed Idempotent again
except Exception as exc: … "publish_error" Reports a publish failure without losing the work Conversion succeeded; only the last step failed. Returning the ids means the retry is cheap
if prev is None or subtreeSize < prev.subtreeSize On duplicate names, keep the smallest subtree The winning insight in this file. CAD trees repeat names at several levels — an assembly and the part inside it. Smallest subtree = most specific = the actual pump, not the skid containing it
cog_3d_model_{id} / cog_3d_revision_{id} Derives DMS externalIds from the numeric 3D ids Deterministic, so re-running overwrites the same nodes
Six ViewId(...) lines The core-model views being written through All in cdf_cdm — 3D is fully modelled in the core data model, nothing custom needed here
NodeApply(... cad_rev_xid, sources=[3 entries]) One node, written through three views at once A revision is a 3D model, a 3D revision, and a CAD revision. Multi-source NodeApply is how one node carries several view identities
obj3d_{name} + cadnode_{name} + asset update The three nodes each mapping needs Cognite3DObject = the geometry, CogniteCADNode = its place in the CAD tree, and the asset gains object3D pointing at the object. That third write is what makes clicking the 3D model select your pump
treeIndexes / subTreeSizes as lists The view expects arrays One CAD node can appear in several revisions, so these are per-revision lists
instances.apply(nodes=applies) One batched write at the end Everything accumulates into applies first — one call, not one per node
"mapped" / "unmapped" Which CAD names were found, which were not unmapped is the diagnostic that matters: it usually means TAG_MAP disagrees with what the CAD file actually calls things

📚 [DOCS] 3D models · Contextualize 3D · Cognite Functions

📝 [WRITE] requirements.txt: cognite-sdk==8.10.0

📝 [WRITE] training/modules/participants/<YOURNAME>/functions/Load3DRevision.Function.yaml

externalId: fnc_<YOURNAME>_Training_Load3DRevision
name: fnc_<YOURNAME>_Training_Load3DRevision
owner: Training
description: Upload 3D revision for the TRN CAD model and map CAD nodes to assets.
functionPath: handler.py
runtime: py311
dataSetExternalId: dts_<YOURNAME>_Training_TRN
envVars:
  PARTICIPANT: "<YOURNAME>"
  INSTANCE_SPACE: "isp_<YOURNAME>_TRN"
  SCHEMA_SPACE_EDM: "ssp_<YOURNAME>_TrainingCore_edm"
  SCHEMA_SPACE_SDM: "ssp_<YOURNAME>_MaintenanceInsight_sdm"
  DATASET: "dts_<YOURNAME>_Training_TRN"
  MODEL_VERSION: "v1.0.0"

💡 [GOOD TO KNOW] — the Fusion UI resolves an asset's 3D preview through a specific chain: CogniteAsset.object3D → Cognite3DObject ← CogniteCADNode, and CogniteCADNode.revisions must point at a node reachable through CogniteCADRevision with matching treeIndexes into the published revision. This handler writes every link in that chain deliberately, in the order shown — get the order wrong (e.g. reference a revision before it's published) and the asset shows no 3D preview even though every node technically exists.


9.5 [ACTION] Build, deploy, run

uv run cdf build --config-yaml training/config.<YOURNAME>-training.yaml
uv run cdf deploy --cdf-project <your-cdf-project> --include functions

🟢 [ACTION] Call it. Because conversion can take minutes, expect to call it more than once:

result = client.functions.call(external_id="fnc_<YOURNAME>_Training_Load3DRevision")
print(result.get_response())
# if status is Queued/Processing with resume=True, wait a few minutes and call again

[VERIFY] Eventually: status: "Done", published: true, and mapped containing at least 21-PA-2001A. Open the asset 21-PA-2001A in Fusion → its 3D tab should show the mapped geometry.

⚠️ [COMMON MISTAKE] Confirming the revision with three_d.revisions.list() and concluding it failed. For a revision created through the data-modeling 3D path — the one this chapter uses — that call returns an empty list, with no error, while the revision is perfectly healthy:

client.three_d.revisions.list(model_id=model_id)                 # -> []          (!)
client.three_d.revisions.retrieve(model_id=model_id, id=rev_id)  # -> status Done, published True

Verified live. Two lessons, and the second is the general one:

  • Check the thing you can name. retrieve with an explicit id is reliable here; the listing is not. Your Function returns the revision_id — use it.
  • A list endpoint returning [] is not proof of absence. You have now met this three times: instances.list without instance_type="edge" (Chapter 08 section 8.7), views.list(space=[a, b]) (Chapter 13 section 13.11), and this. When an empty result contradicts something you just created, suspect the query before the data.

[VERIFY] The durable check is the data model, not the classic 3D API — and it is what Chapter 17 asserts:

from cognite.client.data_classes.data_modeling import ViewId

space = f"isp_{YOURNAME}_TRN"
for view in ("CogniteCADModel", "Cognite3DObject", "CogniteCADNode"):
    items = client.data_modeling.instances.list(
        sources=ViewId("cdf_cdm", view, "v1"), space=space, limit=-1)
    print(f"{view:<18} {len(items)}")
# CogniteCADModel     1
# Cognite3DObject     6
# CogniteCADNode      6

🚧 [LIMITS] The handler's own poll budget is 20 minutes before it returns a non-Done status rather than blocking further — this is the resume pattern from Section 9.2 in code: it hands control back rather than occupying a Function execution slot indefinitely.


Gate

Do not proceed to Chapter 10 until:

  • Your 3D model and a published revision exist and are visible in Fusion
  • 21-PA-2001A shows mapped 3D geometry (or you understand exactly why it's still Queued/Processing and know to call the Function again later, not to panic)
  • You can explain, in one sentence, why this project uses raw HTTP for 3D instead of a typed SDK method or Toolkit YAML
  • 📓 You have added your two or three lines for this chapter to participants/<YOURNAME>/NOTES.mdnow, not tonight

Chapter 10 — Datasheet Parsing