Skip to content

Scene

Resolving a model's geometry into one frame, without changing the model.

get_scene(doc) returns every surface it could place, in world coordinates with the building rotation applied, alongside what it could not place and what it did not attempt. It reads; it does not author, validate or mutate. Writing the document before and after yields the same bytes.

The resolution it applies was measured against EnergyPlus's own surface vertex report rather than reasoned from the input reference, which is why translate_to_world now agrees with it.

Resolve a model's geometry into one frame, without changing the model.

get_scene(doc) reads a document and returns a :class:Scene: every surface it could place, in world coordinates with the building rotation applied, plus what it could not place and what it did not attempt. It modifies nothing.

WHY THIS IS A NEW MODULE AND NOT AN ADDITION TO geometry.py

geometry.py is the authoring and calculation surface, and it is already long. Nothing here authors. The one function there this module is related to is translate_to_world, which mutates the document by design and is corrected to the same rule this module establishes.

THE RULE, WHICH WAS MEASURED RATHER THAN REASONED

Three clauses, in this order, against the engine's own vertex report over 17 models and 532 surfaces:

  1. Only when the model declares the relative system, rotate each surface by its zone's direction_of_relative_north and then translate it by the zone's origin. The condition is not a nicety: twelve of the example models declare World and carry a non-zero zone origin anyway, and applying it displaces every surface in them.
  2. Rotate the whole resolved building by Building.north_axis, about the world origin, in the engine's sense. That sense is clockwise seen from above, so it is the negation of a counter-clockwise rotation. Applying it per surface inside its own zone frame instead leaves the zone layout unrotated, which is what translate_to_world does today: every surface correctly oriented and every zone in the wrong place, a drawing that passes an eyeball test at up to 201.98 m of error. Shading:Site:Detailed is the one exception, and it is the engine's: site shading is fixed in space and does not turn with the building, measured by entering one square under both detached shading types in a model declaring a north axis of 158.434 and reading back which of the two moved.
  3. Reverse the vertex order when the model declares clockwise entry, so that the right-hand rule gives the outward normal in every model.

Candidate rules and their agreement with the engine:

=================================================== ==================== =========== candidate models within 0.01 m worst error =================================================== ==================== =========== the rule in _resolve_surfaces 5/17 201.98 m the rule in translate_to_world 5/17 201.98 m coordinate system read, north axis applied per 8/17 201.98 m surface coordinate system read, north axis applied to the 14/17 17.59 m building all three clauses, compared as rings 17/17 0.0045 m =================================================== ==================== ===========

WHAT IS NOT DONE, AND IS NOT AN OVERSIGHT

The starting vertex is not renormalised. The engine's report begins every surface at its upper-left corner, and matching that would mean discarding the author's ordering to reproduce a reporting convention. The corpus check carries a fixture whose whole purpose is to fail if someone adds it.

The simplified surface family (Wall:Exterior, Window, Roof and their siblings) is not read. Those types are keyed on origin, width, height and tilt rather than on vertices and need their own rule. They are reported in :attr:Scene.unattempted so that a model made of them looks different from a model with no geometry at all.

The zone multiplier is not expanded. It is a simulation instruction, and the engine's own report does not repeat those surfaces either.

Reason = Literal['too-few-vertices', 'zone-not-found', 'parent-surface-not-found', 'no-vertices'] module-attribute

AppliedRules dataclass

What resolution read from the model, and what it had to assume.

Source code in idfkit/scene.py
@dataclass(frozen=True, slots=True)
class AppliedRules:
    """What resolution read from the model, and what it had to assume."""

    coordinate_system: str
    vertex_entry_direction: str
    starting_vertex_position: str
    north_axis: float
    defaulted: tuple[str, ...] = ()

    @property
    def is_relative(self) -> bool:
        """Whether zone origins and zone rotations apply, which is clause one's condition."""
        return self.coordinate_system.casefold() == "relative"

    @property
    def is_clockwise(self) -> bool:
        """Whether the author entered vertices clockwise, which clause three reverses."""
        return self.vertex_entry_direction.casefold().startswith("clockwise")

coordinate_system instance-attribute

defaulted = () class-attribute instance-attribute

is_clockwise property

Whether the author entered vertices clockwise, which clause three reverses.

is_relative property

Whether zone origins and zone rotations apply, which is clause one's condition.

north_axis instance-attribute

starting_vertex_position instance-attribute

vertex_entry_direction instance-attribute

ResolvedSurface dataclass

One surface, placed.

object_type together with name is the address. A name alone is not unique across types, and a consumer that must search the document by name to find what its user selected has been handed a picture rather than a view of the model.

Source code in idfkit/scene.py
@dataclass(frozen=True, slots=True)
class ResolvedSurface:
    """One surface, placed.

    ``object_type`` together with ``name`` is the address. A name alone is not unique across types,
    and a consumer that must search the document by name to find what its user selected has been
    handed a picture rather than a view of the model.
    """

    object_type: str
    name: str
    polygon: Polygon3D
    zone: str = ""
    surface_type: str = ""
    boundary: str = ""
    construction: str = ""
    parent_surface: str | None = None
    is_shading: bool = False

    @property
    def normal(self) -> Vector3D:
        """The outward normal, signed by the declared entry direction."""
        return self.polygon.normal

    @property
    def area(self) -> float:
        """The area of the resolved polygon."""
        return self.polygon.area

area property

The area of the resolved polygon.

boundary = '' class-attribute instance-attribute

construction = '' class-attribute instance-attribute

is_shading = False class-attribute instance-attribute

name instance-attribute

normal property

The outward normal, signed by the declared entry direction.

object_type instance-attribute

parent_surface = None class-attribute instance-attribute

polygon instance-attribute

surface_type = '' class-attribute instance-attribute

zone = '' class-attribute instance-attribute

Scene dataclass

A model's geometry, resolved into one frame.

All three lists are in document order. The corpus compares unresolved and unattempted as sets, because neither carries a semantically meaningful order; that is a statement about what counts as equal and not permission for the producer to vary. A list that reorders between runs is a flickering interface and an unreadable diff, and ordering costs nothing here because resolution already walks the document in order.

Source code in idfkit/scene.py
@dataclass(frozen=True, slots=True)
class Scene:
    """A model's geometry, resolved into one frame.

    All three lists are in document order. The corpus compares ``unresolved`` and ``unattempted`` as
    sets, because neither carries a semantically meaningful order; that is a statement about what
    counts as equal and not permission for the producer to vary. A list that reorders between runs
    is a flickering interface and an unreadable diff, and ordering costs nothing here because
    resolution already walks the document in order.
    """

    surfaces: tuple[ResolvedSurface, ...] = ()
    bounds: SceneBounds | None = None
    #: One shared instance rather than a factory: ``AppliedRules`` is frozen, so every default scene
    #: can hold the same object and none of them can change it.
    applied: AppliedRules = _ASSUMED_RULES
    unresolved: tuple[UnresolvedObject, ...] = ()
    unattempted: tuple[UnattemptedType, ...] = ()

applied = _ASSUMED_RULES class-attribute instance-attribute

bounds = None class-attribute instance-attribute

surfaces = () class-attribute instance-attribute

unattempted = () class-attribute instance-attribute

unresolved = () class-attribute instance-attribute

SceneBounds dataclass

The box enclosing every resolved vertex, and no more.

Source code in idfkit/scene.py
@dataclass(frozen=True, slots=True)
class SceneBounds:
    """The box enclosing every resolved vertex, and no more."""

    min: Vector3D
    max: Vector3D

max instance-attribute

min instance-attribute

UnattemptedType dataclass

A geometry type present in the model that this slice does not read.

Source code in idfkit/scene.py
@dataclass(frozen=True, slots=True)
class UnattemptedType:
    """A geometry type present in the model that this slice does not read."""

    object_type: str
    count: int

count instance-attribute

object_type instance-attribute

UnresolvedObject dataclass

A geometry object that could not be placed, and why.

The reason is an enumeration rather than a message, so that a consumer can group on it and a reworded string does not change behaviour.

missing_reference names what the object pointed at and the model does not hold, for the two reasons that are a dangling reference. The reason says how to group the failure; it does not say which wall to go and find, and a reader fixing the model needs the name rather than a second search through the document. Absent when nothing was referenced, as for an object whose vertex list is too short.

Source code in idfkit/scene.py
@dataclass(frozen=True, slots=True)
class UnresolvedObject:
    """A geometry object that could not be placed, and why.

    The reason is an enumeration rather than a message, so that a consumer can group on it and a
    reworded string does not change behaviour.

    ``missing_reference`` names what the object pointed at and the model does not hold, for the two
    reasons that are a dangling reference. The reason says how to group the failure; it does not say
    which wall to go and find, and a reader fixing the model needs the name rather than a second
    search through the document. Absent when nothing was referenced, as for an object whose vertex
    list is too short.
    """

    object_type: str
    name: str
    reason: Reason
    missing_reference: str | None = None

missing_reference = None class-attribute instance-attribute

name instance-attribute

object_type instance-attribute

reason instance-attribute

get_scene(doc)

Resolve a model's geometry into one frame, leaving the model untouched.

One argument, one return, no options. There is no include_shading, no zones= filter and no color_by: a filter is a list operation the caller already has, and a colour is a viewing decision this function has no business making.

Parameters:

Name Type Description Default
doc IDFDocument

the document to read. It is not modified, and a preserving write before and after yields identical bytes.

required

Returns:

Name Type Description
A Scene

class:Scene in which every geometry object in the model appears exactly once, as a

Scene

resolved surface, an unresolved object with a reason, or a count under an unattempted type.

Examples:

>>> from idfkit import new_document, get_scene
>>> model = new_document()
>>> scene = get_scene(model)
>>> scene.surfaces, scene.bounds
((), None)

An empty model and a model of surfaces this slice cannot read are different answers:

>>> scene.unattempted
()
Source code in idfkit/scene.py
def get_scene(doc: IDFDocument) -> Scene:
    """Resolve a model's geometry into one frame, leaving the model untouched.

    One argument, one return, no options. There is no ``include_shading``, no ``zones=`` filter and
    no ``color_by``: a filter is a list operation the caller already has, and a colour is a viewing
    decision this function has no business making.

    Args:
        doc: the document to read. It is not modified, and a preserving write before and after
            yields identical bytes.

    Returns:
        A :class:`Scene` in which every geometry object in the model appears exactly once, as a
        resolved surface, an unresolved object with a reason, or a count under an unattempted type.

    Examples:
        >>> from idfkit import new_document, get_scene
        >>> model = new_document()
        >>> scene = get_scene(model)
        >>> scene.surfaces, scene.bounds
        ((), None)

        An empty model and a model of surfaces this slice cannot read are different answers:

        >>> scene.unattempted
        ()
    """
    rules = _read_rules(doc)
    schema = doc.schema
    # Read once. Both walks below order by it, and it is a property of the document rather than of
    # either walk.
    rank = _type_rank(doc)

    zones: dict[str, IDFObject] = {obj.name.upper(): obj for obj in _objects(doc, "Zone")}
    surfaces_by_name: dict[str, IDFObject] = {}
    for object_type in _PARENTS:
        for obj in _objects(doc, object_type):
            surfaces_by_name[obj.name.upper()] = obj

    resolved: list[ResolvedSurface] = []
    unresolved: list[UnresolvedObject] = []
    for surface in _in_document_order(doc, _READ, rank):
        outcome = _resolve_one(surface, zones, rules, schema, surfaces_by_name)
        if isinstance(outcome, ResolvedSurface):
            resolved.append(outcome)
        else:
            unresolved.append(outcome)

    surfaces = tuple(resolved)
    return Scene(
        surfaces=surfaces,
        bounds=_bounds(surfaces),
        applied=rules,
        unresolved=tuple(unresolved),
        unattempted=_unattempted(doc, rank),
    )