Converting 3DFACE Entities to OBJ Meshes
A DXF 3DFACE entity stores up to four corner points and nothing else — no shared vertex table, no connectivity — so converting a drawing full of them into a compact Wavefront OBJ mesh means reading vtx0 through vtx3, deduplicating coincident corners into a shared vertex list, and emitting 1-indexed f lines. The reliable Python route is ezdxf for entity access plus a dictionary keyed on rounded coordinate tuples for the dedup. This page is part of the Geometry Mesh Conversion workflow inside the wider Python Parsing & Geometry Extraction pipeline, and it sits alongside Triangulating CAD Polygons with Earcut in Python, which handles the ringed faces that 3DFACE never encodes.
How ezdxf Handles 3DFACE Corner Points
3DFACE is DXF’s simplest surface primitive: a single planar (or near-planar) facet defined by three or four points. ezdxf exposes them as entity.dxf.vtx0, vtx1, vtx2, and vtx3, each a Vec3 with .x, .y, .z attributes. There is always a vtx3; a triangular face is stored as a quad whose fourth corner repeats the third, so vtx3 == vtx2 is the triangle signal. Detecting that collapse is the difference between a clean triangle and a zero-area sliver in the output.
ezdxf reads the corner coordinates verbatim and does not merge shared edges between adjacent faces. A wall exported as fifty 3DFACE entities yields two hundred corner points even though the true vertex count is far lower. Deduplication is your job, and it must be tolerance-based: exporters round coordinates inconsistently, so two corners that are geometrically the same point can differ in the last decimal place. A dictionary keyed on the coordinate tuple rounded to a fixed number of decimals gives each unique location one stable index.
Two related entities need a different route. POLYFACE (a POLYLINE variant with is_poly_face_mesh true) and MESH already carry an indexed vertex/face structure through entity.vertices and face-record subentities, so you read their topology directly instead of deduplicating loose corners. Mixing them into the 3DFACE path double-counts vertices. Keep the query narrow: msp.query("3DFACE").
Production-Ready Script
The script reads every 3DFACE, deduplicates corners with a tolerance dictionary, collapses padded triangles, shifts the mesh to a local origin, and writes a valid OBJ file.
# ezdxf>=1.1.0, Python 3.9+
from __future__ import annotations
import sys
from pathlib import Path
import ezdxf
def faces_to_obj(dxf_path: str, obj_path: str, ndigits: int = 6) -> int:
"""Convert all DXF 3DFACE entities to a Wavefront OBJ mesh.
Deduplicates shared corners within 10**-ndigits tolerance, collapses
padded triangles, and shifts geometry to a local origin. Returns the
number of faces written. Raises RuntimeError on parse failure.
"""
try:
doc = ezdxf.readfile(dxf_path)
except (IOError, ezdxf.DXFStructureError) as exc:
raise RuntimeError(f"Cannot read DXF: {exc}") from exc
msp = doc.modelspace()
vertex_index: dict[tuple, int] = {}
vertices: list[tuple[float, float, float]] = []
faces: list[list[int]] = []
def intern(vec) -> int:
"""Return a stable 0-based index for a corner, deduplicating by tolerance."""
key = (round(vec.x, ndigits), round(vec.y, ndigits), round(vec.z, ndigits))
idx = vertex_index.get(key)
if idx is None:
idx = len(vertices)
vertex_index[key] = idx
vertices.append(key)
return idx
for face in msp.query("3DFACE"):
corners = [face.dxf.vtx0, face.dxf.vtx1, face.dxf.vtx2, face.dxf.vtx3]
indices = [intern(c) for c in corners]
# Collapse repeated consecutive corners (padded triangles, coincident pts).
unique: list[int] = []
for i in indices:
if not unique or unique[-1] != i:
unique.append(i)
if len(unique) > 1 and unique[0] == unique[-1]:
unique.pop() # wrap-around duplicate
if len(unique) < 3:
# Degenerate face collapsed to a line or point — skip it.
continue
faces.append(unique)
if not faces:
raise RuntimeError("No usable 3DFACE geometry found in modelspace.")
# Shift to a local origin so large survey coordinates keep OBJ precision.
min_x = min(v[0] for v in vertices)
min_y = min(v[1] for v in vertices)
min_z = min(v[2] for v in vertices)
lines: list[str] = [
f"# Generated from {Path(dxf_path).name} — {len(faces)} faces",
f"# local-origin offset: {min_x} {min_y} {min_z}",
]
for x, y, z in vertices:
lines.append(f"v {x - min_x:.6f} {y - min_y:.6f} {z - min_z:.6f}")
for face in faces:
# OBJ face indices are 1-based.
lines.append("f " + " ".join(str(i + 1) for i in face))
Path(obj_path).write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"Wrote {len(vertices)} vertices and {len(faces)} faces to {obj_path}")
return len(faces)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python faces_to_obj.py <input.dxf> <output.obj>")
sys.exit(1)
faces_to_obj(sys.argv[1], sys.argv[2])
Key implementation notes:
vtx3 == vtx2collapses to a triangle automatically because both corners resolve to the same deduplicated index, and the consecutive-duplicate pass drops the repeat. You never special-case triangles explicitly.- OBJ indices are 1-based. The
fline writer adds1to every 0-based Python index. Forgetting this shifts the entire mesh and is the single most common cause of scrambled OBJ output. - Quads stay quads. OBJ supports polygonal faces, so a genuine four-corner
3DFACEis written asf a b c d. Split into two triangles only when the consumer demands it. - Tolerance rounding is coarse on purpose.
ndigits=6merges corners within one micron for metre-unit drawings. Raise it for millimetre CAD or lower it for survey data; matching the tolerance to the drawing’s units prevents both over-merging and duplicate vertices. - Local-origin shift preserves precision. Subtracting the bounding-box minimum keeps OBJ coordinates small; record the offset in a comment so the mesh can be georeferenced later — the same concern that drives coordinate handling across CRS Normalization Workflows.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
| Python | 3.9 – 3.12 | dict[...] / list[...] hints need from __future__ import annotations on 3.9. |
ezdxf |
≥ 1.1.0 | vtx0–vtx3 and dxf.invisible stable since 1.0; query("3DFACE") unchanged. |
| DXF versions | R12 – R2018 | 3DFACE exists in every DXF version; no version-specific corner handling. |
| Wavefront OBJ | 1.0 | Vertex-only mesh; add vn/vt lines separately if normals or UVs are required. |
| OS | Linux, macOS, Windows | Pure Python; use pathlib.Path for cross-platform file paths. |
For the entity’s exact group-code layout, see the Autodesk DXF 3DFACE reference, and cross-check against the group-code taxonomy in the DXF Entity Structure Breakdown.
Fallback Strategies
3DFACE conversion fails on four recurring defects. Address them in order.
1. Degenerate quads and slivers. A face whose four corners collapse to fewer than three unique points is a line or a dot. The consecutive-duplicate pass in the script drops these, but near-degenerate quads — three near-collinear corners plus one real point — survive as slivers. After building the mesh, discard faces whose Newell-normal magnitude is below a threshold, or run trimesh.Trimesh(...).remove_degenerate_faces() if you route the result through trimesh.
2. Coincident vertices from inconsistent rounding. Two adjacent faces that should share an edge may store corners differing at the eighth decimal. If seams appear in the mesh, the dedup tolerance is too tight. Raise ndigits conservatively (for example from 6 to 4 for metre units) until adjacent faces share indices — but not so far that distinct features merge.
3. Winding consistency. 3DFACE corner order is not guaranteed to be consistent across a drawing, so exported normals may point in mixed directions. OBJ itself stores no per-face normal, but downstream renderers compute them from winding. Normalize after import with trimesh.repair.fix_winding(mesh) (BFS winding propagation) rather than trying to reorder corners in the DXF.
4. Very large coordinate origins. Files referenced to a survey datum place geometry at coordinates in the millions. When an OBJ viewer stores positions as 32-bit floats, that magnitude leaves only centimetre precision. The local-origin shift in the script handles the common case; for assemblies spanning multiple tiles, compute a single shared offset across all files so the meshes stay aligned — the same discipline documented in Understanding DWG Version Compatibility for cross-file coordinate consistency.
One more attribute is worth reading: face.dxf.invisible is a bitmask marking which of the four edges are hidden in the CAD viewport. It does not affect geometry, so OBJ export ignores it, but if you later reconstruct wireframe display you can test bits 1–8 to reproduce the original edge visibility.
FAQ
How do I tell a triangular 3DFACE from a quad?
A DXF 3DFACE always stores four corner points, vtx0 through vtx3. When the fourth corner equals the third (vtx3 == vtx2), the entity is a triangle padded to four points. Compare the corners after tolerance rounding and collapse repeated consecutive vertices to recover the true triangle or quad — the deduplication step does this automatically because both corners map to the same index.
Why are OBJ face indices off by one?
Wavefront OBJ vertex indices are 1-based, not 0-based. When you build a zero-based vertex list in Python, add one to every index before writing f lines, or every face will reference the wrong vertex and viewers will render scrambled geometry. The script does this with str(i + 1) in the face-writing loop.
Do I need to triangulate 3DFACE quads for OBJ?
No. The OBJ format accepts polygonal faces including quads, so a four-corner 3DFACE can be written as a single f line with four indices. Triangulate only if the downstream consumer requires triangles, in which case split the quad into two triangles sharing a diagonal (a b c and a c d).
How do I handle 3DFACE meshes with huge coordinate values?
Survey-referenced DXF files place geometry at full state-plane or UTM coordinates, which lose precision when an OBJ viewer stores positions as 32-bit floats. Subtract the bounding-box minimum from every vertex to move the mesh to a local origin and record that offset in a comment or sidecar file so the geometry can be georeferenced again later.
Related Pages
- Geometry Mesh Conversion — parent workflow covering vertex deduplication, normal repair, and mesh export formats
- Triangulating CAD Polygons with Earcut in Python — sibling guide for ringed faces with holes that
3DFACEcannot represent - Converting CAD Polylines to GeoJSON — sibling guide for 2D linework extraction from the same DXF sources
- Python Parsing & Geometry Extraction — top-level pipeline covering ingestion, extraction, and serialization stages
- DXF Entity Structure Breakdown — cross-topic reference for the group-code layout behind
3DFACEcorner storage