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:
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¶
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:
- Ensure a 3D model container exists (create it only if absent)
- Upload the
.objas a revision and wait — minutes — for CDF to convert CAD geometry into a streamable format - Publish the finished revision so viewers can load it
- Wire each CAD part to the
CogniteAssetit 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 xMin…zMax. Defaults 0,0,0–1,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.
retrievewith an explicit id is reliable here; the listing is not. Your Function returns therevision_id— use it. - A list endpoint returning
[]is not proof of absence. You have now met this three times:instances.listwithoutinstance_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-2001Ashows mapped 3D geometry (or you understand exactly why it's stillQueued/Processingand 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.md— now, not tonight