Bonsai: draw element Axis reference lines in drawings

Bonsai already writes an Axis representation for walls (Plan/Axis/GRAPH_VIEW)
and for every profile based member such as beams and columns
(Model/Axis/GRAPH_VIEW), but CreateDrawing.get_linework_contexts only ever
collected Body, Facetation and Annotation subcontexts, so that centreline
geometry could never reach a drawing. Users were tracing centrelines by hand
as separate annotations for every element.

Collect the Axis subcontexts into a third linework bucket and, when the
drawing opts in via EPset_Drawing.HasAxisLinework, project them into the
linework SVG as their own groups classed "axis" alongside the element's usual
classes. Styling then goes through the existing CSS channel, with a
centreline dash pattern added to default.css.

The pass is off by default, so existing drawings are unchanged.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-20 12:35:10 +03:00
parent 55a2430d71
commit d2a08d37a1
7 changed files with 92 additions and 1 deletions
@@ -43,6 +43,8 @@ path.flush { stroke: blue; stroke-width: 0.1; stroke-opacity: 0.4; }
*/ */
.surface {fill: white; stroke-width: 0.1;} .surface {fill: white; stroke-width: 0.1;}
/* Reference lines from an element's Axis representation (issue #6423). */
.axis { fill: none; stroke: black; stroke-width: 0.13; stroke-dasharray: 12, 3, 3, 3; }
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; } .annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; } .IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
/* .IfcGeographicElement { fill: none; stroke: rgb(150, 150, 150); stroke-linecap: 'round'; stroke-dasharray: 1, 2;} */ /* .IfcGeographicElement { fill: none; stroke: rgb(150, 150, 150); stroke-linecap: 'round'; stroke-dasharray: 1, 2;} */
@@ -104,6 +104,7 @@ class profile:
class LineworkContexts(NamedTuple): class LineworkContexts(NamedTuple):
body: list[list[int]] body: list[list[int]]
annotation: list[list[int]] annotation: list[list[int]]
axis: list[list[int]]
class AddAnnotationType(bpy.types.Operator, tool.Ifc.Operator): class AddAnnotationType(bpy.types.Operator, tool.Ifc.Operator):
@@ -618,8 +619,15 @@ class CreateDrawing(bpy.types.Operator):
model_annotation_target_contexts: list[int] = [] model_annotation_target_contexts: list[int] = []
model_annotation_model_contexts: list[int] = [] model_annotation_model_contexts: list[int] = []
axis_contexts: list[int] = []
for rep_context in ifc.by_type("IfcGeometricRepresentationContext"): for rep_context in ifc.by_type("IfcGeometricRepresentationContext"):
if rep_context.is_a("IfcGeometricRepresentationSubContext"): if rep_context.is_a("IfcGeometricRepresentationSubContext"):
if rep_context.ContextIdentifier == "Axis":
# Axis geometry is a view independent reference line, so unlike
# body and annotation it isn't bucketed by target view.
axis_contexts.append(rep_context.id())
continue
if rep_context.ContextType == "Plan": if rep_context.ContextType == "Plan":
if rep_context.ContextIdentifier in ["Body", "Facetation"]: if rep_context.ContextIdentifier in ["Body", "Facetation"]:
if rep_context.TargetView == target_view: if rep_context.TargetView == target_view:
@@ -676,7 +684,7 @@ class CreateDrawing(bpy.types.Operator):
] ]
) )
return LineworkContexts(body_contexts, annotation_contexts) return LineworkContexts(body_contexts, annotation_contexts, [axis_contexts] if axis_contexts else [])
def serialize_contexts_elements( def serialize_contexts_elements(
self, self,
@@ -723,6 +731,63 @@ class CreateDrawing(bpy.types.Operator):
tree.add_element(elem) tree.add_element(elem)
drawing_elements -= processed drawing_elements -= processed
def collect_axis_linework(
self,
ifc: ifcopenshell.file,
contexts: LineworkContexts,
drawing_elements: set[ifcopenshell.entity_instance],
link_matrix: Optional[Matrix] = None,
) -> None:
context_ids = [i for context in contexts.axis for i in context]
if not context_ids or not drawing_elements:
return
with profile("Processing axis context"):
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES)
geom_settings.set("context-ids", context_ids)
if link_matrix is not None:
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
t = link_matrix.to_translation()
geom_settings.set("model-offset", (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale))
q = link_matrix.to_quaternion()
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
it = ifcopenshell.geom.iterator(
geom_settings, ifc, multiprocessing.cpu_count(), include=list(drawing_elements)
)
for shape in it:
edges = shape.geometry.edges
if not len(edges):
continue
element = ifc.by_id(shape.id)
self.axis_linework.append((element, list(shape.geometry.verts), list(edges)))
def generate_axis_linework(self, context: bpy.types.Context, root) -> None:
if not self.axis_linework:
return
camera_matrix_i = context.scene.camera.matrix_world.inverted()
group = root.find("{http://www.w3.org/2000/svg}g")
if group is None:
return
raw_width, raw_height = self.get_camera_dimensions()
x_offset = raw_width / 2
y_offset = raw_height / 2
svg_scale = self.scale * 1000 # IFC is in meters, SVG is in mm
for element, verts, edges in self.axis_linework:
g = etree.SubElement(group, "{http://www.w3.org/2000/svg}g")
g.attrib["{http://www.ifcopenshell.org/ns}guid"] = element.GlobalId
g.attrib["{http://www.ifcopenshell.org/ns}name"] = element.Name or ""
classes = self.get_svg_classes(element)
classes.append("axis")
g.set("class", " ".join(classes))
for i in range(0, len(edges), 2):
coords = []
for vi in (edges[i], edges[i + 1]):
co = camera_matrix_i @ Vector((verts[vi * 3], verts[vi * 3 + 1], verts[vi * 3 + 2]))
coords.append(((x_offset + co.x) * svg_scale, (y_offset - co.y) * svg_scale))
path = etree.SubElement(g, "{http://www.w3.org/2000/svg}path")
path.attrib["d"] = "M{},{} L{},{}".format(*coords[0], *coords[1])
def generate_bisect_linework(self, context: bpy.types.Context, root) -> None: def generate_bisect_linework(self, context: bpy.types.Context, root) -> None:
camera_matrix_i = context.scene.camera.matrix_world.inverted() camera_matrix_i = context.scene.camera.matrix_world.inverted()
@@ -1048,6 +1113,11 @@ class CreateDrawing(bpy.types.Operator):
raycast_objs = set() raycast_objs = set()
elements_with_faces = set() elements_with_faces = set()
# Reference lines an element already carries in its Axis representation, drawn
# as their own layer so they can be styled independently. See #6423.
self.axis_linework: list[tuple[ifcopenshell.entity_instance, list[float], list[int]]] = []
has_axis_linework = ifcopenshell.util.element.get_pset(self.drawing, "EPset_Drawing", "HasAxisLinework")
for ifc_path, (ifc, link_matrix) in files.items(): for ifc_path, (ifc, link_matrix) in files.items():
# Don't use draw.main() just whilst we're prototyping and experimenting # Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used # TODO: hash paths are never used
@@ -1075,6 +1145,8 @@ class CreateDrawing(bpy.types.Operator):
self.serialize_contexts_elements( self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
) )
if has_axis_linework:
self.collect_axis_linework(ifc, contexts, drawing_elements, link_matrix)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements: if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"): with profile("Camera element"):
@@ -1137,6 +1209,10 @@ class CreateDrawing(bpy.types.Operator):
self.merge_linework_and_add_metadata(root) self.merge_linework_and_add_metadata(root)
self.move_elements_to_top(root) self.move_elements_to_top(root)
# After merge_linework_and_add_metadata, which would otherwise reclassify these
# groups as cut or projection linework.
self.generate_axis_linework(context, root)
if self.cprops.fill_mode == "SHAPELY": if self.cprops.fill_mode == "SHAPELY":
# shapely variant # shapely variant
group = root.find("{http://www.w3.org/2000/svg}g") group = root.find("{http://www.w3.org/2000/svg}g")
@@ -536,6 +536,13 @@ class BIMCameraProperties(PropertyGroup):
default=True, default=True,
update=get_update_layer_callback("has_annotation", "HasAnnotation"), update=get_update_layer_callback("has_annotation", "HasAnnotation"),
) )
has_axis_linework: BoolProperty(
name="Axis Linework",
description="Draw the reference lines held in each element's Axis representation, such as "
"wall and beam centrelines. Style them with the .axis CSS class",
default=False,
update=get_update_layer_callback("has_axis_linework", "HasAxisLinework"),
)
use_edge_classification: BoolProperty( use_edge_classification: BoolProperty(
name="Use Edge Classification", name="Use Edge Classification",
description="Classify projection edges into boundary/outline/sharp/crease/flush " description="Classify projection edges into boundary/outline/sharp/crease/flush "
@@ -73,6 +73,8 @@ class BIM_PT_camera(Panel):
row = col.row(align=True) row = col.row(align=True)
row.prop(props, "has_annotation", icon="MOD_EDGESPLIT") row.prop(props, "has_annotation", icon="MOD_EDGESPLIT")
row.prop(dprops, "should_use_annotation_cache", text="", icon="FILE_REFRESH") row.prop(dprops, "should_use_annotation_cache", text="", icon="FILE_REFRESH")
row = col.row(align=True)
row.prop(props, "has_axis_linework", icon="MOD_SIMPLIFY")
# Drawing linked projects. # Drawing linked projects.
row = col.row(align=True) row = col.row(align=True)
+1
View File
@@ -331,6 +331,7 @@ def add_drawing(
"HasUnderlay": False, "HasUnderlay": False,
"HasLinework": True, "HasLinework": True,
"HasAnnotation": True, "HasAnnotation": True,
"HasAxisLinework": False,
"GlobalReferencing": True, "GlobalReferencing": True,
"Stylesheet": drawing.get_default_drawing_resource_path("Stylesheet"), "Stylesheet": drawing.get_default_drawing_resource_path("Stylesheet"),
"Markers": drawing.get_default_drawing_resource_path("Markers"), "Markers": drawing.get_default_drawing_resource_path("Markers"),
+2
View File
@@ -1116,6 +1116,8 @@ class Drawing(bonsai.core.tool.Drawing):
camera_props.has_linework = bool(pset["HasLinework"]) camera_props.has_linework = bool(pset["HasLinework"])
if "HasAnnotation" in pset: if "HasAnnotation" in pset:
camera_props.has_annotation = bool(pset["HasAnnotation"]) camera_props.has_annotation = bool(pset["HasAnnotation"])
if "HasAxisLinework" in pset:
camera_props.has_axis_linework = bool(pset["HasAxisLinework"])
if "IsNTS" in pset: if "IsNTS" in pset:
camera_props.is_nts = bool(pset["IsNTS"]) camera_props.is_nts = bool(pset["IsNTS"])
if "UseEdgeClassification" in pset: if "UseEdgeClassification" in pset:
+1
View File
@@ -374,6 +374,7 @@ class TestAddDrawing:
"HasUnderlay": False, "HasUnderlay": False,
"HasLinework": True, "HasLinework": True,
"HasAnnotation": True, "HasAnnotation": True,
"HasAxisLinework": False,
"GlobalReferencing": True, "GlobalReferencing": True,
"Stylesheet": "stylesheet.css", "Stylesheet": "stylesheet.css",
"Markers": "markers.svg", "Markers": "markers.svg",