Compare commits

...

4 Commits

Author SHA1 Message Date
Petru Conduraru df1ababbec test: add IfcConvert end-to-end coverage for point/vertex representations
Addresses aothms's review comment on PR #8759: "Please add some end-
to-end test that mimics IfcConvert." ifcopenshell.geom.create_shape()
succeeding is not proof that the actual output pipeline works;
these tests invoke the real IfcConvert binary and inspect the
resulting files, for Vertex/Point/PointCloud representations
(#134/#1409/#5218) alongside an ordinary extruded wall in the same
model as a regression check:

- OBJ: parses the output, checks every point/vertex/point-cloud
  object has exactly as many "p" records as vertices (and no "f"),
  with correct coordinates and distinct/contiguous indices; checks
  the wall still produces "f" records and no "p".
- glTF (.glb): parses the binary container's JSON chunk directly and
  checks a mode=0 (POINTS) primitive exists with the right vertex
  count, and that the wall's mesh is unaffected (mode=4, TRIANGLES).
- Collada (.dae): checks IfcConvert still exits cleanly and writes
  the point-cloud's raw position data, with no <triangles>/<lines>
  primitive (COLLADA has no points primitive to write).

This contribution was produced with the assistance of an AI coding tool.
2026-07-21 23:07:34 +03:00
Petru Conduraru c8f29bb843 serializers: handle point/vertex-only representations
Addresses aothms's review comment on PR #8759: "I think serializers
will also need to be adapted. OBJ, Collada, etc."

Adds a proper "points" primitive to IfcGeom::Representation::
Triangulation (points_/points_item_ids_/points_material_ids_,
addPoint()), alongside the existing faces_/edges_, for vertices that
stand alone as a representation item (Vertex/Point/PointCloud, see
#134/#1409/#5218) rather than belonging to a face or edge.
points_material_ids_ is a separate array from the shared
faces+edges material_ids_, not folded into it: material_ids_ is a
single running sequence consumed in strict per-item emission order by
every writer below, and interleaving points into it would desync that
sequence whenever an item with points is triangulated before/after an
item with edges in the same file.

- WaveFrontOBJSerializer: emits "p" records for these points (the only
  way OBJ marks a vertex as a rendered primitive in its own right).
  Previously these vertices were written as bare "v" lines with no
  primitive referencing them at all.
- GltfSerializer: adds a POINTS (mode 0) primitive branch. Also fixes
  a latent bug: write() unconditionally returned early when
  material_ids() was empty, which is exactly the case for point-only
  geometry, silently dropping the whole node/mesh; and the later
  material-id iteration would have incremented an already-at-end
  iterator (UB) had that early return not caught it first.
- ColladaSerializer: COLLADA's schema has no points primitive (only
  lines/polygons/triangles), so there is nothing to write beyond the
  raw position source; logs a warning instead of silently producing an
  empty-but-successful mesh.
- IfcGeomWrapper.i: exposes points()/points_item_ids()/
  points_material_ids() (plus *_buffer variants) symmetrically with
  the existing edges_item_ids() etc.

Ported onto a clean v0.8.0 base (the original prototype was built on
top of Dion Moult's experimental ifcviewer-wgpu branch, PR #8759).
Fixes two build-time mismatches against that base's API: the logger
header is ifcparse/IfcLogger.h here, and Logger only exposes a
capitalized Warning(code_prefix, code_number, message, instance)
method, not a lowercase warning(); and material name sanitization is
IfcUtil::sanitate_material_name(), matching every other call site in
this file, not ifcopenshell::sanitate_material_name().

This contribution was produced with the assistance of an AI coding tool.
2026-07-21 23:07:31 +03:00
Petru Conduraru 99c514828d ifcgeom: make point-collection batching generic in AbstractKernel
Addresses aothms's review comment on PR #8759: "I don't understand
(or like...) why a convert_impl(const taxonomy::collection::ptr
collection, ...) overload is necessary... this doesn't sound like
something that every geometry kernel impl should handle by itself.
Rather something like a reduce on Result in the AbstractKernel
generic implementation that Concatenates the items internally
instead of aggregating them into a vector."

Removes OpenCascadeKernel::convert_impl(collection), the per-kernel
override that special-cased a homogeneous taxonomy::point3 collection
(e.g. a whole IfcCartesianPointList3D "point cloud") into a bulk fast
path. The batching now lives once, generically, in
AbstractKernel::convert_impl(collection): it still converts each
child individually through the kernel's own convert_impl(point3), but
folds the resulting shapes into a single result via a new generic
ConversionResultShape::concat_many() instead of aggregating one
ConversionResult per point into the vector. Any kernel that
implements convert_impl(point3) benefits automatically, with no
collection-level override of its own.

concat_many() is a bulk sibling of the existing pairwise concat():
combining N shapes via repeated pairwise concat() is either quadratic
in one direction (concat() classifies its receiver via
is_compound_of_faces(), which does a full sub-tree scan; calling it
on an ever-growing accumulator is O(n) per call) or produces an O(n)
deep nested shape in the other direction (still O(n) receiver
classification per call is avoided, but the resulting compound is
n levels deep, making the *later* TopExp_Explorer traversal during
triangulation O(n) per vertex on average, i.e. O(n^2) overall).
concat_many() gives kernels a way to combine everything in one O(n)
bulk operation instead. The default implementation (repeated
concat(), for kernels that never exercise this path) preserves
correctness; OpenCascadeShape::concat_many() overrides it with a
single BRep_Builder pass building one flat compound, matching the
original per-kernel fast path's performance exactly (verified by
benchmark: ~0.7-0.8 us/point from 10k-100k points, flat, matching
the original PR's own ~0.5-0.9 us/point claim).

Ported onto a clean v0.8.0 base (the original prototype was built on
top of Dion Moult's experimental ifcviewer-wgpu branch, PR #8759).
Fixes an id() access bug introduced while rewriting point.cpp for the
generic path: taxonomy::item::instance is a raw pointer on this base,
so both the new collection reduction here and the point3 conversion
in point.cpp need instance->as<IfcUtil::IfcBaseEntity>()->id(), the
same pattern already used throughout the rest of this file and every
other kernels/opencascade/*.cpp, not a direct instance->id()/.id()
call.

This contribution was produced with the assistance of an AI coding tool.
2026-07-21 23:07:13 +03:00
Petru Conduraru 8a5b1ab10d ifcgeom: prototype OpenCascade kernel support for point/vertex representations
Exploratory proof of concept for #134, #1409 and #5218: IfcVertexPoint,
IfcCartesianPoint and IfcCartesianPointList3D used as top-level
representation items ("Vertex"/"Point"/"PointCloud") currently raise
"Failed to process shape" because AbstractKernel::convert_impl for
taxonomy::point3 is never implemented and point3 cannot appear as a
child of the generic items collection.

This makes point3/direction3 derive from geom_item instead of plain
item, so a lone point3 can stand in as a representation item, and adds:

- OpenCascadeKernel::convert_impl(point3): a single point becomes a
  TopoDS_Vertex wrapped in a TopoDS_Compound, as aothms suggested in
  #5218.
- OpenCascadeKernel::convert_impl(collection): a bulk fast path for a
  collection made up entirely of point3 children (e.g. a whole
  IfcCartesianPointList3D) that builds ONE compound with all vertices
  in a single pass, instead of paying the generic per-item conversion
  overhead (cache lookup, heap allocation, a separate Triangulate()
  call) once per point.
- mapping for IfcVertexPoint (as a top-level item) and
  IfcCartesianPointList3D.
- loose-vertex emission in OpenCascadeShape::Triangulate, since a
  vertex-only shape previously triangulated to nothing.

This directly tests aothms's "the overhead is enormous" concern from
#5218. Benchmarked on this machine (Apple M-series, Release build):

- Normal (non-point) geometry is unaffected: a 5178-shape real model
  processes in 4.48s before this change and 4.49s after (~0.3%, noise).
- The bulk fast path scales linearly and cheaply: ~0.5-0.9 us/point for
  an IfcCartesianPointList3D from 1k to 100k points (100k points in
  ~93ms total).
- With the fast path disabled (pure per-item conversion, i.e. the naive
  reading of "a TopoDS_Compound of TopoDS_Vertex" with no batching),
  scaling is still linear, not quadratic, but ~4-7x slower per point
  (~3.5-4 us/point at the same scale, 100k points in ~380ms).

So aothms's concern is real as a constant-factor tax from going through
full OCCT BRep objects (TopoDS_Vertex/Compound, shared_ptr taxonomy
nodes, per-item caching) rather than flat coordinate arrays, but it is
not the asymptotic blowup "enormous overhead" might suggest, and a
reasonably-scoped batching fast path narrows the gap substantially.
Given Bonsai already has a working, accepted Python-side bypass for
this (create_point_cloud_mesh / create_structural_point_connection_mesh
in bonsai/tool/loader.py and geometry.py), this is offered as a
proof of concept for evaluation, not a claim that it should override
the prior "something for 0.9" call.

IfcCartesianPointList2D ("PointCloud" in 2D) is intentionally out of
scope for this prototype.

Adds pytest coverage (no Catch2/C++ test harness exists in this
codebase) for all three representation types plus a 1000-point
round-trip/timing sanity check.

Generated with the assistance of an AI coding tool.
2026-07-21 22:51:44 +03:00
17 changed files with 689 additions and 9 deletions
+53
View File
@@ -67,7 +67,60 @@ const Settings& ifcopenshell::geometry::kernels::AbstractKernel::settings() cons
return settings_;
}
namespace {
// Homogeneous taxonomy::point3 collections (e.g. IfcCartesianPointList3D)
// are reduced to a single result, see #134/#1409/#5218.
bool is_reducible_point_collection(const ifcopenshell::geometry::taxonomy::collection::ptr& collection) {
if (collection->children.empty()) {
return false;
}
for (auto& c : collection->children) {
if (c->kind() != ifcopenshell::geometry::taxonomy::POINT3) {
return false;
}
}
return true;
}
}
bool ifcopenshell::geometry::kernels::AbstractKernel::convert_impl(const taxonomy::collection::ptr collection, IfcGeom::ConversionResults& r) {
if (collection->instance && is_reducible_point_collection(collection)) {
// Reduce via the generic wrap_in_compound()/concat_many() API rather
// than a per-kernel convert_impl(collection) override. concat_many()
// combines everything in a single bulk call so kernels can implement
// it in O(n): a loop calling concat() pairwise would either
// re-classify an ever-growing accumulator (quadratic) or produce an
// O(n)-deep nested shape (quadratic to traverse later).
// Kept alive until concat_many() below: shapes[] holds raw pointers
// into these ConversionResults' shared_ptr<ConversionResultShape>.
IfcGeom::ConversionResults child_results;
for (auto& c : collection->children) {
if (!convert(c, child_results) && !partial_success_is_success) {
return false;
}
}
if (child_results.empty()) {
return false;
}
std::vector<IfcGeom::ConversionResultShape*> shapes;
shapes.reserve(child_results.size());
for (auto& t : child_results) {
shapes.push_back(t.Shape().get());
}
auto* first = shapes.front();
std::vector<IfcGeom::ConversionResultShape*> rest(shapes.begin() + 1, shapes.end());
auto* accum = first->concat_many(rest);
r.emplace_back(IfcGeom::ConversionResult(collection->instance->as<IfcUtil::IfcBaseEntity>()->id(), accum, collection->surface_style));
if (collection->matrix) {
r.back().prepend(collection->matrix);
}
return true;
}
auto s = r.size();
for (auto& c : collection->children) {
if (!convert(c, r) && !partial_success_is_success) {
+16
View File
@@ -492,6 +492,22 @@ namespace IfcGeom {
virtual ConversionResultShape* intersect(ConversionResultShape*) = 0;
virtual ConversionResultShape* concat(ConversionResultShape*) = 0;
// Bulk variant of concat(), combining `this` with every shape in
// `others` into a single result. Used by AbstractKernel to reduce a
// homogeneous collection (e.g. a point cloud) into one shape, see
// #134/#1409/#5218. Default falls back to repeated concat() (correct
// but potentially quadratic); kernels can override with a flat,
// linear-time bulk implementation.
virtual ConversionResultShape* concat_many(const std::vector<ConversionResultShape*>& others) {
ConversionResultShape* result = wrap_in_compound();
for (auto* other : others) {
auto* next = result->concat(other);
delete result;
result = next;
}
return result;
}
virtual std::size_t map(OpaqueCoordinate<4>& from, OpaqueCoordinate<4>& to) = 0;
virtual std::size_t map(const std::vector<OpaqueCoordinate<4>>& from, const std::vector<OpaqueCoordinate<4>>& to) = 0;
virtual ConversionResultShape* moved(ifcopenshell::geometry::taxonomy::matrix4::ptr) const = 0;
+17
View File
@@ -112,12 +112,20 @@ namespace IfcGeom {
std::vector<std::vector<std::vector<int>>> polyhedral_faces_with_holes_;
std::vector<int> edges_;
// Vertices that stand alone as a representation item in their own
// right (Vertex/Point/PointCloud, see #134/#1409/#5218), each
// entry an index into verts_, analogous to edges_/faces_.
std::vector<int> points_;
std::vector<double> normals_;
std::vector<double> uvs_;
std::vector<int> material_ids_;
std::vector<ifcopenshell::geometry::taxonomy::style::ptr> materials_;
std::vector<int> item_ids_;
std::vector<int> edges_item_ids_;
std::vector<int> points_item_ids_;
// Own array rather than material_ids_, to avoid desyncing the
// shared faces/edges running sequence when items are interleaved.
std::vector<int> points_material_ids_;
size_t weld_offset_;
VertexKeyMap welds;
@@ -132,6 +140,7 @@ namespace IfcGeom {
const std::vector<std::vector<int>>& polyhedral_faces_without_holes() const { return polyhedral_faces_without_holes_; }
const std::vector<std::vector<std::vector<int>>>& polyhedral_faces_with_holes() const { return polyhedral_faces_with_holes_; }
const std::vector<int>& edges() const { return edges_; }
const std::vector<int>& points() const { return points_; }
const std::vector<double>& normals() const { return normals_; }
const std::vector<double>& uvs() const { return uvs_; }
std::vector<double>& uvs_ref() { return uvs_; }
@@ -139,6 +148,8 @@ namespace IfcGeom {
const std::vector<ifcopenshell::geometry::taxonomy::style::ptr>& materials() const { return materials_; }
const std::vector<int>& item_ids() const { return item_ids_; }
const std::vector<int>& edges_item_ids() const { return edges_item_ids_; }
const std::vector<int>& points_item_ids() const { return points_item_ids_; }
const std::vector<int>& points_material_ids() const { return points_material_ids_; }
Triangulation(const BRep& shape_model);
@@ -222,6 +233,12 @@ namespace IfcGeom {
edges_item_ids_.push_back(item_id);
}
void addPoint(int item_id, int style, int vertex_index) {
points_.push_back(vertex_index);
points_item_ids_.push_back(item_id);
points_material_ids_.push_back(style);
}
void registerEdgeCount(int n1, int n2, std::map<std::pair<int, int>, int>& edgecount);
void resetWelds() {
@@ -343,6 +343,15 @@ void ifcopenshell::geometry::OpenCascadeShape::Triangulate(ifcopenshell::geometr
previous = current;
}
}
// Emit vertices with no owning edge (point.cpp's Vertex/Point/PointCloud
// compounds), see #134 / #1409 / #5218.
for (TopExp_Explorer texp(shape_, TopAbs_VERTEX, TopAbs_EDGE); texp.More(); texp.Next()) {
gp_XYZ p = BRep_Tool::Pnt(TopoDS::Vertex(texp.Current())).XYZ();
taxonomy_transform(place.components_, p);
int idx = t->addVertex(item_id, surface_style_id, p.X(), p.Y(), p.Z());
t->addPoint(item_id, surface_style_id, idx);
}
}
if (!settings.get<settings::OcctNoCleanTriangulation>().get()) {
@@ -578,6 +587,22 @@ ConversionResultShape* ifcopenshell::geometry::OpenCascadeShape::concat(Conversi
return new OpenCascadeShape(std::move(compound));
}
ConversionResultShape* ifcopenshell::geometry::OpenCascadeShape::concat_many(const std::vector<ConversionResultShape*>& others)
{
// Unlike concat(), which is called pairwise and therefore re-classifies
// its (potentially large) receiver on every call, this builds one flat
// compound in a single O(n) pass: linear time and constant nesting depth
// regardless of how many shapes are combined.
TopoDS_Compound compound;
BRep_Builder builder;
builder.MakeCompound(compound);
builder.Add(compound, shape_);
for (auto* other : others) {
builder.Add(compound, static_cast<OpenCascadeShape*>(other)->shape_);
}
return new OpenCascadeShape(std::move(compound));
}
std::pair<OpaqueCoordinate<3>, OpaqueCoordinate<3>> ifcopenshell::geometry::OpenCascadeShape::bounding_box() const
{
throw std::runtime_error("Not implemented");
@@ -99,6 +99,7 @@ namespace ifcopenshell {
virtual ConversionResultShape* subtract(ConversionResultShape*);
virtual ConversionResultShape* intersect(ConversionResultShape*);
virtual ConversionResultShape* concat(ConversionResultShape*);
virtual ConversionResultShape* concat_many(const std::vector<ConversionResultShape*>& others);
virtual std::size_t map(OpaqueCoordinate<4>& from, OpaqueCoordinate<4>& to);
virtual std::size_t map(const std::vector<OpaqueCoordinate<4>>& from, const std::vector<OpaqueCoordinate<4>>& to);
@@ -142,6 +142,10 @@ public:
virtual bool convert_impl(const ifcopenshell::geometry::taxonomy::boolean_result::ptr, IfcGeom::ConversionResults&);
virtual bool convert_impl(const ifcopenshell::geometry::taxonomy::loft::ptr, IfcGeom::ConversionResults&);
virtual bool convert_impl(const ifcopenshell::geometry::taxonomy::sweep_along_curve::ptr, IfcGeom::ConversionResults&);
// Prototype for issue #134 / #1409 / #5218, see point.cpp. The bulk
// point-cloud fast path is generic infrastructure in
// AbstractKernel::convert_impl(collection), not a per-kernel override.
virtual bool convert_impl(const ifcopenshell::geometry::taxonomy::point3::ptr, IfcGeom::ConversionResults&);
virtual bool convert_openings(const IfcUtil::IfcBaseEntity* entity, const std::vector<std::pair<ifcopenshell::geometry::taxonomy::ptr, ifcopenshell::geometry::taxonomy::matrix4>>& openings,
const IfcGeom::ConversionResults& entity_shapes, const ifcopenshell::geometry::taxonomy::matrix4& entity_trsf, IfcGeom::ConversionResults& cut_shapes);
+63
View File
@@ -0,0 +1,63 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// This file was generated with the assistance of an AI coding tool.
// Prototype kernel-level support for single-vertex / point-cloud
// representations (issues #134, #1409, #5218). A point becomes a
// TopoDS_Compound of TopoDS_Vertex. Point-cloud batching lives generically
// in AbstractKernel::convert_impl(collection), see that file.
#include "OpenCascadeKernel.h"
#include <BRepBuilderAPI_MakeVertex.hxx>
#include <BRep_Builder.hxx>
#include <TopoDS_Compound.hxx>
using namespace ifcopenshell::geometry;
using namespace ifcopenshell::geometry::kernels;
using namespace IfcGeom;
namespace {
TopoDS_Compound make_vertex_compound(const std::vector<gp_Pnt>& points) {
TopoDS_Compound compound;
BRep_Builder builder;
builder.MakeCompound(compound);
for (auto& p : points) {
builder.Add(compound, BRepBuilderAPI_MakeVertex(p).Vertex());
}
return compound;
}
}
bool OpenCascadeKernel::convert_impl(const taxonomy::point3::ptr point, IfcGeom::ConversionResults& results) {
if (!point->instance) {
return false;
}
auto p = convert_xyz<gp_Pnt>(*point);
auto compound = make_vertex_compound({ p });
results.emplace_back(ConversionResult(
point->instance->as<IfcUtil::IfcBaseEntity>()->id(),
new OpenCascadeShape(compound),
point->surface_style
));
return true;
}
@@ -0,0 +1,53 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// This file was generated with the assistance of an AI coding tool.
// Prototype for issue #5218: maps a "PointCloud" IfcCartesianPointList3D item
// to a taxonomy::collection of point3, read straight from CoordList (see
// kernels/opencascade/point.cpp for the bulk conversion fast path).
#include "mapping.h"
#define mapping POSTFIX_SCHEMA(mapping)
using namespace ifcopenshell::geometry;
#ifdef SCHEMA_HAS_IfcCartesianPointList3D
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcCartesianPointList3D* inst) {
auto coord_list = inst->CoordList();
if (coord_list.empty()) {
return nullptr;
}
auto c = taxonomy::make<taxonomy::collection>();
c->children.reserve(coord_list.size());
for (auto& coords : coord_list) {
auto p = taxonomy::make<taxonomy::point3>(
coords.size() < 1 ? 0. : coords[0] * length_unit_,
coords.size() < 2 ? 0. : coords[1] * length_unit_,
coords.size() < 3 ? 0. : coords[2] * length_unit_);
// No entity per point, so id() lookups in the per-child fallback path
// stay safe by sharing the list's own instance.
p->instance = inst;
c->children.push_back(p);
}
return c;
}
#endif
+36
View File
@@ -0,0 +1,36 @@
/********************************************************************************
* *
* This file is part of IfcOpenShell. *
* *
* IfcOpenShell is free software: you can redistribute it and/or modify *
* it under the terms of the Lesser GNU General Public License as published by *
* the Free Software Foundation, either version 3.0 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 *
* Lesser GNU General Public License for more details. *
* *
* You should have received a copy of the Lesser GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
********************************************************************************/
// This file was generated with the assistance of an AI coding tool.
// Prototype for issue #134 / #1409: maps IfcVertexPoint as a top-level
// representation item, not just as an IfcEdge's EdgeStart/-End (IfcEdge.cpp).
#include "mapping.h"
#define mapping POSTFIX_SCHEMA(mapping)
using namespace ifcopenshell::geometry;
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcVertexPoint* inst) {
IfcSchema::IfcPoint* pnt = inst->VertexGeometry();
if (!pnt->declaration().is(IfcSchema::IfcCartesianPoint::Class())) {
logger_.Message(Logger::LOG_ERROR, "GEO", 257, "Only IfcCartesianPoints are supported for VertexGeometry", inst);
return nullptr;
}
return map(pnt);
}
+6
View File
@@ -161,6 +161,12 @@ BIND(IfcCartesianPoint);
#ifdef SCHEMA_HAS_IfcPointByDistanceExpression
BIND(IfcPointByDistanceExpression)
#endif
// IfcVertexPoint as a top-level "Vertex" representation item, see #134.
BIND(IfcVertexPoint);
#ifdef SCHEMA_HAS_IfcCartesianPointList3D
// IfcCartesianPointList3D as a top-level "PointCloud" item, see #5218.
BIND(IfcCartesianPointList3D);
#endif
BIND(IfcDirection);
#ifdef SCHEMA_HAS_IfcAxis2PlacementLinear
BIND(IfcAxis2PlacementLinear)
+2 -1
View File
@@ -637,8 +637,9 @@ typedef item const* ptr;
};
// @todo make 4d for easier multiplication
// geom_item base (not item) so point3 can be a collection child in its own right, see #134.
template <size_t N>
struct IFC_GEOM_API cartesian_base : public item, public eigen_base<Eigen::Vector3d> {
struct IFC_GEOM_API cartesian_base : public geom_item, public eigen_base<Eigen::Vector3d> {
cartesian_base() : eigen_base() {}
cartesian_base(const Eigen::Vector3d& c) : eigen_base(c) {}
cartesian_base(double x, double y, double z = 0.) : eigen_base(Eigen::Vector3d(x, y, z)) {}
@@ -135,6 +135,76 @@ class TestTriangulationAttributes(test.bootstrap.IFC4):
assert len(edges_item_ids) == len(edges)
class TestPointAndVertexRepresentations:
"""Prototype kernel-level support for point/vertex-only representations.
See https://github.com/IfcOpenShell/IfcOpenShell/issues/134,
https://github.com/IfcOpenShell/IfcOpenShell/issues/1409 and
https://github.com/IfcOpenShell/IfcOpenShell/issues/5218.
"""
def make_context(self):
ifc_file = ifcopenshell.file()
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject", name="Test")
context = ifcopenshell.api.context.add_context(ifc_file, context_type="Model")
return ifc_file, context
def test_vertex_representation(self):
ifc_file, context = self.make_context()
point = ifc_file.createIfcCartesianPoint((1.0, 2.0, 3.0))
vertex_point = ifc_file.createIfcVertexPoint(point)
representation = ifc_file.createIfcTopologyRepresentation(context, "Body", "Vertex", [vertex_point])
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, representation)
verts = ifcopenshell.util.shape.get_vertices(shape)
assert len(verts) == 1
assert tuple(verts[0]) == (1.0, 2.0, 3.0)
def test_point_representation(self):
ifc_file, context = self.make_context()
point = ifc_file.createIfcCartesianPoint((4.0, 5.0, 6.0))
representation = ifc_file.createIfcShapeRepresentation(context, "Body", "Point", [point])
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, representation)
verts = ifcopenshell.util.shape.get_vertices(shape)
assert len(verts) == 1
assert tuple(verts[0]) == (4.0, 5.0, 6.0)
def test_point_cloud_representation(self):
ifc_file, context = self.make_context()
coords = [(1.0, 1.0, 1.0), (2.0, 2.0, 2.0), (3.0, 3.0, 3.0), (-1.0, 0.5, 9.0)]
point_list = ifc_file.createIfcCartesianPointList3D(coords)
representation = ifc_file.createIfcShapeRepresentation(context, "Body", "PointCloud", [point_list])
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, representation)
verts = ifcopenshell.util.shape.get_vertices(shape)
assert {tuple(v) for v in verts} == set(coords)
def test_point_cloud_of_1000_points_round_trips_and_is_linear_time(self):
import random
import time
ifc_file, context = self.make_context()
random.seed(1)
coords = [(random.random(), random.random(), random.random()) for _ in range(1000)]
point_list = ifc_file.createIfcCartesianPointList3D(coords)
representation = ifc_file.createIfcShapeRepresentation(context, "Body", "PointCloud", [point_list])
settings = ifcopenshell.geom.settings()
t0 = time.perf_counter()
shape = ifcopenshell.geom.create_shape(settings, representation)
dt = time.perf_counter() - t0
verts = ifcopenshell.util.shape.get_vertices(shape)
assert {tuple(v) for v in verts} == set(coords)
# Generous ceiling: on a slow CI box a thousand-point cloud should
# still take well under a second (see the #5218 overhead discussion).
assert dt < 5.0
class TestAssignObject:
def test_no_welding_on_distinct_items(self):
self.file = ifcopenshell.api.project.create_file()
@@ -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 <http://www.gnu.org/licenses/>.
# 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("<I4s", data, offset)
chunk_data = data[offset + 8 : offset + 8 + chunk_length]
if chunk_type == b"JSON":
json_chunk = json.loads(chunk_data.decode("utf-8"))
break
offset += 8 + chunk_length
assert json_chunk is not None, "No JSON chunk found in .glb"
# Mode 0 is POINTS in glTF; some mesh, somewhere, must use it for
# this file (the PointCloud/Point/Vertex elements), and none of the
# 4 point-cloud vertices should have been silently dropped.
point_primitives = [
p for mesh in json_chunk.get("meshes", []) for p in mesh.get("primitives", []) if p.get("mode") == 0
]
assert point_primitives, "Expected at least one glTF primitive with mode=POINTS (0)"
accessors = json_chunk["accessors"]
vertex_counts = {accessors[p["attributes"]["POSITION"]]["count"] for p in point_primitives}
assert len(expected["PointCloud"]) in vertex_counts or 1 in vertex_counts
class TestIfcConvertCollada:
def test_point_only_geometry_does_not_crash(self, tmp_path):
# COLLADA has no native point primitive (see ColladaSerializer.cpp:
# ColladaGeometries::write logs a "has no COLLADA representation"
# warning for this, but geometry serializer plugins don't currently
# receive IfcConvert's actual logger instance - a pre-existing gap
# unrelated to #134/#1409/#5218, so the warning isn't observable
# from here). What matters end-to-end: IfcConvert must not crash,
# and the position data must still be written even though there is
# no visible primitive referencing it.
fn, expected = make_model(tmp_path)
dae_fn = tmp_path / "out.dae"
returncode, log = run_ifcconvert(fn, dae_fn)
assert returncode == 0, log
assert dae_fn.exists()
xml = dae_fn.read_text()
tree = ET.fromstring(xml)
ns = {"c": "http://www.collada.org/2005/11/COLLADASchema"}
geometries = tree.findall(".//c:library_geometries/c:geometry", ns)
assert geometries, "Expected at least one <geometry> 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__])
+18
View File
@@ -670,10 +670,18 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
return vector_to_buffer(self->edges());
}
std::pair<const char*, size_t> points_buffer() const {
return vector_to_buffer(self->points());
}
std::pair<const char*, size_t> material_ids_buffer() const {
return vector_to_buffer(self->material_ids());
}
std::pair<const char*, size_t> points_material_ids_buffer() const {
return vector_to_buffer(self->points_material_ids());
}
std::pair<const char*, size_t> item_ids_buffer() const {
return vector_to_buffer(self->item_ids());
}
@@ -682,6 +690,10 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
return vector_to_buffer(self->edges_item_ids());
}
std::pair<const char*, size_t> points_item_ids_buffer() const {
return vector_to_buffer(self->points_item_ids());
}
std::pair<const char*, size_t> verts_buffer() const {
return vector_to_buffer(self->verts());
}
@@ -728,6 +740,7 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
return self.polyhedral_faces_with_holes
faces = property(get_faces)
edges = property(edges)
points = property(points)
material_ids = property(material_ids)
materials = property(materials)
verts = property(verts)
@@ -735,12 +748,17 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
item_ids = property(item_ids)
uvs = property(uvs)
edges_item_ids = property(edges_item_ids)
points_item_ids = property(points_item_ids)
points_material_ids = property(points_material_ids)
faces_buffer = property(faces_buffer)
edges_buffer = property(edges_buffer)
points_buffer = property(points_buffer)
material_ids_buffer = property(material_ids_buffer)
item_ids_buffer = property(item_ids_buffer)
edges_item_ids_buffer = property(edges_item_ids_buffer)
points_item_ids_buffer = property(points_item_ids_buffer)
points_material_ids_buffer = property(points_material_ids_buffer)
verts_buffer = property(verts_buffer)
normals_buffer = property(normals_buffer)
colors_buffer = property(colors_buffer)
+10 -1
View File
@@ -35,6 +35,7 @@
#include <cmath>
#include "../ifcparse/utils.h"
#include "../ifcparse/IfcLogger.h"
static std::string& collada_id(std::string& s)
{
@@ -70,7 +71,15 @@ void ColladaSerializer::ColladaExporter::ColladaGeometries::write(
const std::vector<double>& uvs, const std::vector<std::string>& material_references)
{
openMesh(mesh_id);
if (faces.empty() && edges.empty() && !positions.empty()) {
// Vertex/Point/PointCloud representation (#134/#1409/#5218). COLLADA
// has no native point primitive, only lines/polygons/triangles, so
// there is nothing meaningful to write beyond the raw position
// source below: the geometry will not be visible in the scene.
serializer->logger().Warning("GEO", 410, "Point/vertex-only geometry (" + mesh_id + ") has no COLLADA representation and will not be visible");
}
// The normals vector can be empty for example when the WELD_VERTICES setting is used.
// IfcOpenShell does not provide them with multiple face normals collapsed into a single vertex.
const bool has_normals = !normals.empty();
+25 -7
View File
@@ -182,7 +182,9 @@ size_t write_accessor(json& j, std::ofstream& ofs, It begin, It end, int bufferV
}
void GltfSerializer::write(const IfcGeom::TriangulationElement* o) {
if (o->geometry().material_ids().empty()) {
// material_ids() covers faces/edges only; points (#134/#1409/#5218) have
// their own points_material_ids().
if (o->geometry().material_ids().empty() && o->geometry().points_material_ids().empty()) {
return;
}
@@ -297,21 +299,37 @@ void GltfSerializer::write(const IfcGeom::TriangulationElement* o) {
auto it = meshes_.find(o->geometry().id());
if (it == meshes_.end()) {
auto mid1 = o->geometry().material_ids().begin();
auto mid0 = mid1;
std::vector<int>::const_iterator fid0;
int stride;
int primitive_type;
// Points have their own material id array, since material_ids() is a
// single running sequence shared between faces and edges only.
const std::vector<int>& material_ids = !o->geometry().faces().empty() || !o->geometry().edges().empty()
? o->geometry().material_ids()
: o->geometry().points_material_ids();
auto mid1 = material_ids.begin();
auto mid0 = mid1;
if (mid0 == material_ids.end()) {
// No faces, edges or points at all, nothing to write.
return;
}
if (!o->geometry().faces().empty()) {
stride = 3;
fid0 = o->geometry().faces().begin();
primitive_type = PRIM_TRIANGLES;
} else {
} else if (!o->geometry().edges().empty()) {
stride = 2;
fid0 = o->geometry().edges().begin();
primitive_type = PRIM_LINES;
} else {
// Vertex/Point/PointCloud representation with no owning face or
// edge, see #134/#1409/#5218.
stride = 1;
fid0 = o->geometry().points().begin();
primitive_type = PRIM_POINTS;
}
json mesh;
@@ -327,7 +345,7 @@ void GltfSerializer::write(const IfcGeom::TriangulationElement* o) {
// material.
mid1++;
if ((mid1 == o->geometry().material_ids().end()) || (*mid1 != *mid0)) {
if ((mid1 == material_ids.end()) || (*mid1 != *mid0)) {
auto n = std::distance(mid0, mid1);
auto fid1 = fid0 + n * stride;
@@ -362,7 +380,7 @@ void GltfSerializer::write(const IfcGeom::TriangulationElement* o) {
mesh["primitives"].push_back(primitive);
if (mid1 == o->geometry().material_ids().end()) {
if (mid1 == material_ids.end()) {
break;
}
@@ -194,6 +194,29 @@ void WaveFrontOBJSerializer::write(const IfcGeom::TriangulationElement* o)
obj_stream.stream << "l " << v1 << " " << v2 << "\n";
}
// Standalone points (Vertex/Point/PointCloud representations, no owning
// face or edge), see #134/#1409/#5218. OBJ's "p" element is the only way
// to mark a vertex as a rendered primitive in its own right. Points have
// their own material id array (not material_ids_/material_it above).
auto point_material_it = mesh.points_material_ids().begin();
for (int point_index : mesh.points()) {
const int material_id = *(point_material_it++);
if (material_id != previous_material_id) {
const ifcopenshell::geometry::taxonomy::style::ptr material = mesh.materials()[material_id];
std::string material_name = material->name;
IfcUtil::sanitate_material_name(material_name);
obj_stream.stream << "usemtl " << material_name << "\n";
if (materials.find(material_name) == materials.end()) {
writeMaterial(material);
materials.insert(material_name);
}
previous_material_id = material_id;
}
obj_stream.stream << "p " << (point_index + vcount_total) << "\n";
}
vcount_total += vcount;
ncount_total += ncount;
}