Working with Geometry

Detailed guide to vertices, edges, faces, loops, and the triangulation system. The snippets assume model = skppy.load("model.skp") and use its root Entities scope unless stated otherwise.


Vertex IDs and lookup

Every Vertex, Edge, and Face has an integer id unique within its entity scope. Cross-references (e.g., edge start/end, loop edge-uses) use these IDs.

e = model.entities

# Build a quick lookup map
vertex_map = {v.id: v for v in e.vertices}
edge_map = {ed.id: (ed.start_vertex_id, ed.end_vertex_id) for ed in e.edges}

# Resolve an edge
for edge in e.edges:
    start = vertex_map[edge.start_vertex_id]
    end   = vertex_map[edge.end_vertex_id]
    print(f"Edge {edge.id}: {start.position} -> {end.position}")

Do not use entities.vertices[id]: IDs need not be contiguous or zero-based, especially after loading a file or deleting objects in memory.


Traversing face loops

Each Face has an outer_loop and zero or more inner_loops (holes). Loops contain EdgeUse objects that specify an edge and traversal direction.

for face in e.faces:
    # Outer boundary vertex IDs
    outer_ids = face.outer_loop.vertex_ids(edge_map)
    print(f"Face {face.id} outer:", outer_ids)

    for i, hole in enumerate(face.inner_loops):
        hole_ids = hole.vertex_ids(edge_map)
        print(f"  hole {i}:", hole_ids)

Loop.vertex_ids() handles reversed EdgeUse entries and follows the stored loop traversal. Valid outer loops normally follow front-face winding, but the method does not repair malformed source topology.


Face normals and planes

The plane equation (a, b, c, d) satisfies ax + by + cz + d = 0. The normal is the unit vector (a, b, c):

for face in e.faces:
    n = face.normal()   # Vector3D
    print(f"Face {face.id} normal: ({n.x:.3f}, {n.y:.3f}, {n.z:.3f})")

Triangulating faces

Use Face.triangulate() for a simple fan of the outer boundary. The result is a list of (a, b, c) vertex-ID triples, not list indices:

for face in e.faces:
    triangles = face.triangulate(e)
    for a_id, b_id, c_id in triangles:
        va = vertex_map[a_id]
        vb = vertex_map[b_id]
        vc = vertex_map[c_id]
        print(va.position, vb.position, vc.position)

Face.triangulate() intentionally ignores inner loops. For importer-ready faces with holes, call Entities.prepare_mesh(): it merges loops with bridge edges and uses ear clipping. Importers that prefer n-gons can pass split_holes_to_ngons=True to represent a single-hole face as two simple n-gons; multiple-hole faces still triangulate.


Degenerate faces

Face.triangulate() returns an empty list only when fewer than three outer-loop edge uses resolve. It is a topology convenience and does not test collinearity or self-intersection. The prepare_mesh() path performs the stronger geometric processing used by the Blender importer and may omit faces that cannot produce valid polygons.


Working in world space

Vertex positions in a ComponentDefinition are in definition-local space. To transform to world space, multiply by the instance transform chain:

import numpy as np

def local_to_world(
    position: skppy.Vector3D, world_matrix: np.ndarray
) -> skppy.Vector3D:
    """Apply rotation, scale, translation, and homogeneous division."""
    point = world_matrix @ np.array([position.x, position.y, position.z, 1.0])
    if abs(point[3]) > 1.0e-12:
        point = point / point[3]
    return skppy.Vector3D(float(point[0]), float(point[1]), float(point[2]))

for inst in model.entities.component_instances:
    defn = defn_map[inst.definition_id]
    world_matrix = skppy.Transform(inst.transform).matrix
    for v in defn.entities.vertices:
        world_pos = local_to_world(v.position, world_matrix)
        print(world_pos)

For a nested instance, compose matrices in parent-to-child order: child_world = parent_world @ Transform(child.transform).matrix. Reusing this matrix for all vertices in a placement avoids repeated conversion of the 13-value representation.


Curves and arcs

Edges that belong to a curve have a non-None curve_id. SketchUp arcs and circles are stored as collections of straight edges sharing a curve_id:

from collections import defaultdict

curves = defaultdict(list)
for edge in e.edges:
    if edge.curve_id is not None:
        curves[edge.curve_id].append(edge)

for curve_id, edges in curves.items():
    print(f"Curve {curve_id}: {len(edges)} segments")

Use entities.curves and entities.arc_curves when you also need polygon state or circular parameters such as center, normal, radius, and angles.


Text and dimensions

Both SKP container families expose annotations through the same entity lists:

for text in e.texts:
    print(text.text, text.anchor.position, text.font)

for dimension in e.linear_dimensions:
    print(dimension.start.position, dimension.end.position, dimension.text)

for dimension in e.radial_dimensions:
    print(dimension.target_entity_id, dimension.radius_ratio, dimension.is_diameter)

Fonts are shared model resources. font_id retains the reference even when a source file does not provide a resolvable Font object.

PointReference.entity_id is populated when the source annotation points to an entity that can be resolved in the same scope. A null or unavailable association leaves it as None while preserving the measured position. drawing contains the normalized material, layer, visibility, shadow, smoothing, and lock state.