diff --git a/src/ifcopenshell-python/test/test_ifcconvert_point_representations.py b/src/ifcopenshell-python/test/test_ifcconvert_point_representations.py
new file mode 100644
index 0000000000..5dc9754326
--- /dev/null
+++ b/src/ifcopenshell-python/test/test_ifcconvert_point_representations.py
@@ -0,0 +1,267 @@
+# IfcOpenShell - IFC toolkit and geometry engine
+# Copyright (C) 2026 IfcOpenShell contributors
+#
+# This file is part of IfcOpenShell.
+#
+# IfcOpenShell is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IfcOpenShell is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IfcOpenShell. If not, see .
+
+# This file was generated with the assistance of an AI coding tool.
+
+"""End-to-end coverage that mimics IfcConvert for Vertex/Point/PointCloud
+representations (#134, #1409, #5218), as requested by aothms on PR #8759:
+ifcopenshell.geom.create_shape() succeeding is not proof that the actual
+IfcConvert output pipeline (serializers writing real files) works. These
+tests invoke the real IfcConvert binary and inspect the resulting files.
+"""
+
+import json
+import shutil
+import struct
+import subprocess
+import xml.etree.ElementTree as ET
+
+import pytest
+
+import ifcopenshell
+import ifcopenshell.api.context
+import ifcopenshell.api.root
+import ifcopenshell.api.unit
+
+IFCCONVERT = shutil.which("IfcConvert")
+
+pytestmark = pytest.mark.skipif(IFCCONVERT is None, reason="Requires IfcConvert in path")
+
+
+def make_model(tmp_path):
+ """A single model with a Vertex, a Point, a PointCloud and (as a
+ regression check) an ordinary extruded wall, so that IfcConvert has to
+ process all four in one pass.
+ """
+ f = ifcopenshell.file()
+ ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="Test")
+ unit = ifcopenshell.api.unit.add_si_unit(f, unit_type="LENGTHUNIT")
+ ifcopenshell.api.unit.assign_unit(f, units=[unit])
+ context = ifcopenshell.api.context.add_context(f, context_type="Model")
+
+ vertex_xyz = (1.0, 2.0, 3.0)
+ vertex_element = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingElementProxy", name="Vertex")
+ vertex_point = f.createIfcVertexPoint(f.createIfcCartesianPoint(vertex_xyz))
+ vertex_representation = f.createIfcTopologyRepresentation(context, "Body", "Vertex", [vertex_point])
+ vertex_element.Representation = f.createIfcProductDefinitionShape(Representations=[vertex_representation])
+
+ point_xyz = (4.0, 5.0, 6.0)
+ point_element = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingElementProxy", name="Point")
+ point_representation = f.createIfcShapeRepresentation(
+ context, "Body", "Point", [f.createIfcCartesianPoint(point_xyz)]
+ )
+ point_element.Representation = f.createIfcProductDefinitionShape(Representations=[point_representation])
+
+ cloud_coords = [(10.0, 11.0, 12.0), (13.0, 14.0, 15.0), (16.0, 17.0, 18.0), (-1.0, 0.5, 9.0)]
+ cloud_element = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingElementProxy", name="PointCloud")
+ point_list = f.createIfcCartesianPointList3D(cloud_coords)
+ cloud_representation = f.createIfcShapeRepresentation(context, "Body", "PointCloud", [point_list])
+ cloud_element.Representation = f.createIfcProductDefinitionShape(Representations=[cloud_representation])
+
+ wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall")
+ profile_points = [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)]
+ curve = f.createIfcPolyline([f.createIfcCartesianPoint(p) for p in profile_points])
+ extrusion = f.createIfcExtrudedAreaSolid(
+ f.createIfcArbitraryClosedProfileDef("AREA", None, curve),
+ f.createIfcAxis2Placement3D(f.createIfcCartesianPoint((0.0, 0.0, 0.0))),
+ f.createIfcDirection((0.0, 0.0, 1.0)),
+ 1.0,
+ )
+ wall_representation = f.createIfcShapeRepresentation(context, "Body", "SweptSolid", [extrusion])
+ wall.Representation = f.createIfcProductDefinitionShape(Representations=[wall_representation])
+
+ fn = tmp_path / "point_representations.ifc"
+ f.write(str(fn))
+ return fn, {
+ "Vertex": vertex_xyz,
+ "Point": point_xyz,
+ "PointCloud": cloud_coords,
+ }
+
+
+def run_ifcconvert(input_fn, output_fn, *extra_args):
+ # Point/vertex conversion (see kernels/opencascade/point.cpp) is only
+ # implemented in the OpenCascade kernel, force it explicitly since a
+ # build with CGAL/Manifold available would otherwise default to those.
+ # --use-element-names makes the OBJ "g"/glTF node names predictable
+ # (the IfcRoot.Name we set), instead of opaque unique IDs.
+ args = [
+ IFCCONVERT,
+ "-yqv",
+ "--kernel",
+ "opencascade",
+ "--use-element-names",
+ str(input_fn),
+ str(output_fn),
+ *extra_args,
+ ]
+ completed = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ return completed.returncode, completed.stdout.decode("utf-8", "replace")
+
+
+def parse_obj_groups(obj_text):
+ """Split a Wavefront OBJ into per-object groups of ("v"/"p"/"f" ...) lines."""
+ groups = {}
+ current = None
+ for line in obj_text.splitlines():
+ if line.startswith("g "):
+ current = line[2:].strip()
+ groups[current] = {"v": [], "p": [], "f": []}
+ elif current is not None and line[:2] in ("v ", "p ", "f "):
+ kind = line[0]
+ groups[current][kind].append(line)
+ return groups
+
+
+class TestIfcConvertObj:
+ def test_vertex_point_and_point_cloud_produce_p_records(self, tmp_path):
+ fn, expected = make_model(tmp_path)
+ obj_fn = tmp_path / "out.obj"
+
+ returncode, log = run_ifcconvert(fn, obj_fn)
+ assert returncode == 0, log
+ assert obj_fn.exists()
+
+ groups = parse_obj_groups(obj_fn.read_text())
+
+ # Every representation-only object should produce exactly as many
+ # "p" (point primitive) records as it has vertices, and no "f".
+ for name, coords in [("Vertex", [expected["Vertex"]]), ("Point", [expected["Point"]])]:
+ matches = [g for gid, g in groups.items() if gid.split("-")[0] == name or name in gid]
+ assert matches, f"No OBJ group found for {name} (groups: {list(groups)})"
+ g = matches[0]
+ assert len(g["v"]) == len(coords)
+ assert len(g["p"]) == len(coords)
+ assert len(g["f"]) == 0
+ for (x, y, z), vline in zip(coords, g["v"]):
+ _, vx, vy, vz = vline.split()
+ assert (float(vx), float(vy), float(vz)) == (x, y, z)
+
+ cloud_matches = [g for gid, g in groups.items() if "PointCloud" in gid]
+ assert cloud_matches, f"No OBJ group found for PointCloud (groups: {list(groups)})"
+ cloud = cloud_matches[0]
+ assert len(cloud["v"]) == len(expected["PointCloud"])
+ assert len(cloud["p"]) == len(expected["PointCloud"])
+ assert len(cloud["f"]) == 0
+ actual_coords = {tuple(float(c) for c in vline.split()[1:]) for vline in cloud["v"]}
+ assert actual_coords == set(expected["PointCloud"])
+
+ # OBJ vertex/point indices are 1-based and cumulative across the
+ # whole file (not reset per "g" group), matching how faces already
+ # index into vcount_total. The point cloud's "p" indices must still
+ # be distinct and contiguous, referencing exactly its own 4 "v" lines.
+ p_indices = sorted(int(pline.split()[1]) for pline in cloud["p"])
+ assert p_indices == list(range(p_indices[0], p_indices[0] + len(expected["PointCloud"])))
+
+ def test_ordinary_geometry_still_produces_faces(self, tmp_path):
+ # Regression check: a normal extruded wall in the same file must
+ # still triangulate to faces, unaffected by the point/vertex handling.
+ fn, _ = make_model(tmp_path)
+ obj_fn = tmp_path / "out.obj"
+
+ returncode, log = run_ifcconvert(fn, obj_fn)
+ assert returncode == 0, log
+
+ groups = parse_obj_groups(obj_fn.read_text())
+ wall_matches = [g for gid, g in groups.items() if "Wall" in gid]
+ assert wall_matches, f"No OBJ group found for Wall (groups: {list(groups)})"
+ wall = wall_matches[0]
+ assert len(wall["f"]) > 0
+ assert len(wall["p"]) == 0
+
+
+class TestIfcConvertGltf:
+ def test_point_cloud_uses_points_primitive_mode(self, tmp_path):
+ fn, expected = make_model(tmp_path)
+ glb_fn = tmp_path / "out.glb"
+
+ returncode, log = run_ifcconvert(fn, glb_fn)
+ assert returncode == 0, log
+ assert glb_fn.exists()
+
+ data = glb_fn.read_bytes()
+ magic, version, length = struct.unpack_from("<4sII", data, 0)
+ assert magic == b"glTF"
+
+ offset = 12
+ json_chunk = None
+ while offset < length:
+ chunk_length, chunk_type = struct.unpack_from(" in the .dae"
+
+ point_cloud_floats = " ".join(f"{c:g}" for coords in expected["PointCloud"] for c in coords)
+ found_point_cloud_positions = False
+ for geometry in geometries:
+ float_array = geometry.find(".//c:float_array", ns)
+ if (
+ float_array is not None
+ and float_array.text
+ and point_cloud_floats in " ".join(float_array.text.split())
+ ):
+ found_point_cloud_positions = True
+ # No triangles/lines/polylist: nothing meaningful to draw.
+ assert geometry.find(".//c:triangles", ns) is None
+ assert geometry.find(".//c:lines", ns) is None
+ assert found_point_cloud_positions, "PointCloud coordinates missing from .dae output"
+
+
+if __name__ == "__main__":
+ import pytest
+
+ pytest.main(["-vvsx", __file__])