diff --git a/src/bonsai/bonsai/bim/data/assets/default.css b/src/bonsai/bonsai/bim/data/assets/default.css index d82d97e8f2..88fb3f007d 100644 --- a/src/bonsai/bonsai/bim/data/assets/default.css +++ b/src/bonsai/bonsai/bim/data/assets/default.css @@ -32,6 +32,8 @@ path.boundary { stroke: black; stroke-width: 0.3; stroke-opacity: 0.9; } path.crease { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; } path.sharp { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; } path.flush { stroke: black; stroke-width: 0.1; stroke-opacity: 0.4; } +/* Occluded linework (issue #6424). fill:none is required because .projection fills white. */ +path.hidden { stroke: black; stroke-width: 0.13; stroke-opacity: 0.4; stroke-dasharray: 1.5 1.5; fill: none; } /* Debug CSS for troubleshooting edge classification */ /* diff --git a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc index aa39cc353b..e898167e3b 100644 --- a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc +++ b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc @@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D FILE_SCHEMA(('IFC4')); ENDSEC; DATA; -#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36,#37)); +#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36,#37,#38)); #2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -42,5 +42,6 @@ DATA; #35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); #36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #37=IFCSIMPLEPROPERTYTEMPLATE('1zM9sia2L8RQDnWZxgUwlZ',$,'JoinClasses','Comma separated list of IFC classes whose cut linework will be joined together when they meet (e.g. mitred at a corner).\X2\000A\X0\Defaults to ''IfcWall,IfcSlab'' if not set. Override to also join other classes, such as ''IfcWall,IfcSlab,IfcCovering''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); +#38=IFCSIMPLEPROPERTYTEMPLATE('0hLd7xQR91u8YbTcEvKmNs',$,'RenderHidden','Whether to draw the occluded parts of elements as hidden lines instead of omitting them, so an element entirely behind another still appears on the drawing (issue #6424).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 35d93db7d2..8eb521dccd 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1151,6 +1151,9 @@ class CreateDrawing(bpy.types.Operator): if global_id not in elements_with_faces: continue for path in projection.findall("./{http://www.w3.org/2000/svg}path"): + # Issue #6424: occluded linework doesn't bound a visible surface. + if "hidden" in path.get("class", "").split(): + continue start, end = [[float(o) for o in co[1:].split(",")] for co in path.attrib["d"].split()] if start == end: continue @@ -1409,6 +1412,8 @@ class CreateDrawing(bpy.types.Operator): self.svg_settings.set("svg-render-sharp-edges", self.cprops.render_sharp) self.svg_settings.set("svg-ridge-angle-min-degrees", self.cprops.ridge_angle_min_degrees) self.svg_settings.set("svg-emit-flush-edges", self.cprops.render_flush) + # Issue #6424: emit occluded linework instead of dropping it. + self.svg_settings.set("svg-render-hidden-edges", self.cprops.render_hidden) except Exception: # Backwards compatibility with older ifcopenshell builds that don't expose these keys. pass @@ -1417,6 +1422,9 @@ class CreateDrawing(bpy.types.Operator): self.serialiser = ifcopenshell.geom.serializers.svg( self.svg_buffer, self.svg_settings, self.serialiser_settings ) + # ready() is what latches the svg-* geometry settings into the serialiser. IfcConvert + # calls it before use; without it these settings are silently ignored. + self.serialiser.ready() self.serialiser.setWithoutStoreys(True) self.serialiser.setPolygonal(True) self.serialiser.setUseHlrPoly(True) diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index f22d7128d8..f07b374770 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -580,6 +580,14 @@ class BIMCameraProperties(PropertyGroup): default=False, update=get_update_layer_callback("render_flush", "RenderFlush"), ) + render_hidden: BoolProperty( + name="Render Hidden Lines", + description="Draw the occluded parts of elements as hidden lines instead of omitting " + "them, so an element that sits entirely behind another still appears on the drawing. " + "Occluded linework carries the 'hidden' CSS class", + default=False, + update=get_update_layer_callback("render_hidden", "RenderHidden"), + ) target_view: EnumProperty( name="Target View", default="PLAN_VIEW", diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 5c690e7282..c959ae0817 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -126,6 +126,9 @@ class BIM_PT_camera(Panel): row = self.layout.row() row.prop(props, "render_flush") + row = self.layout.row() + row.prop(props, "render_hidden") + row = self.layout.row() row.prop(props, "width") row = self.layout.row() diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index aedc1219d9..3822e6aa21 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -1130,6 +1130,8 @@ class Drawing(bonsai.core.tool.Drawing): camera_props.ridge_angle_min_degrees = float(pset["RidgeAngleMinDegrees"]) if "RenderFlush" in pset: camera_props.render_flush = bool(pset["RenderFlush"]) + if "RenderHidden" in pset: + camera_props.render_hidden = bool(pset["RenderHidden"]) if "DPI" in pset: camera_props.dpi = int(pset["DPI"]) if "LineworkMode" in pset: diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 580b326822..e53a1e243b 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -127,6 +127,7 @@ class TestImportCameraProps(NewFile): assert props.render_sharp is True assert props.ridge_angle_min_degrees == pytest.approx(45.0) assert props.render_flush is False + assert props.render_hidden is False def test_imports_edge_classification_props_from_drawing_pset(self): ifc = ifcopenshell.file() @@ -143,6 +144,7 @@ class TestImportCameraProps(NewFile): "RenderSharp": False, "RidgeAngleMinDegrees": 30.0, "RenderFlush": True, + "RenderHidden": True, }, ) camera = bpy.data.cameras.new("Camera") @@ -156,6 +158,7 @@ class TestImportCameraProps(NewFile): assert props.render_sharp is False assert props.ridge_angle_min_degrees == pytest.approx(30.0) assert props.render_flush is True + assert props.render_hidden is True class TestSyncPerspectiveCameraShifts(NewFile): diff --git a/src/ifcgeom/ConversionSettings.h b/src/ifcgeom/ConversionSettings.h index 621174cce2..9fdeff3ab2 100644 --- a/src/ifcgeom/ConversionSettings.h +++ b/src/ifcgeom/ConversionSettings.h @@ -407,6 +407,12 @@ namespace ifcopenshell { static constexpr bool defaultvalue = true; }; + struct SvgRenderHiddenEdges : public SettingBase { + static constexpr const char* const name = "svg-render-hidden-edges"; + static constexpr const char* const description = "Emit the occluded (hidden) portions of projected linework as additional paths carrying the 'hidden' CSS class, so elements that sit entirely behind others still appear on the drawing. Defaults to false."; + static constexpr bool defaultvalue = false; + }; + struct KeepBoundingBoxes : public SettingBase { static constexpr const char* const name = "keep-bounding-boxes"; static constexpr const char* const description = @@ -689,7 +695,7 @@ namespace ifcopenshell { }; class Settings : public SettingsContainer< - std::tuple + std::tuple > {}; } diff --git a/src/serializers/SvgSerializer.cpp b/src/serializers/SvgSerializer.cpp index 79101dfb67..7cb4e4814d 100644 --- a/src/serializers/SvgSerializer.cpp +++ b/src/serializers/SvgSerializer.cpp @@ -108,6 +108,7 @@ bool SvgSerializer::ready() { svg_use_edge_classification_ = geometry_settings().get().get(); svg_render_crease_edges_ = geometry_settings().get().get(); svg_render_sharp_edges_ = geometry_settings().get().get(); + svg_render_hidden_edges_ = geometry_settings().get().get(); return true; } @@ -1486,7 +1487,7 @@ void SvgSerializer::write(const geometry_data& data) { if (storey) { auto it = storey_hlr.find(storey); if (it == storey_hlr.end()) { - it = storey_hlr.insert({ storey, hlr_t(logger_, use_prefiltering_, use_hlr_poly_, segment_projection_, projection_plane) }).first; + it = storey_hlr.insert({ storey, hlr_t(logger_, use_prefiltering_, use_hlr_poly_, segment_projection_, projection_plane, svg_render_hidden_edges_) }).first; } it->second.add(*compound_to_hlr, data.product); for (auto& kv : classified_edge_buckets) { @@ -2379,7 +2380,7 @@ void SvgSerializer::finalize() { // @todo do we have always have pln here? if (use_hlr && pln) { - hlr = new hlr_t(logger_, use_prefiltering_, use_hlr_poly_, segment_projection_, *pln); + hlr = new hlr_t(logger_, use_prefiltering_, use_hlr_poly_, segment_projection_, *pln, svg_render_hidden_edges_); } section_data_ = std::vector{ sd }; @@ -2594,6 +2595,19 @@ void SvgSerializer::doWriteHeader() { " }\n"; } + // Occluded linework (issue #6424). Emitted only when the setting is on, so default + // output is unchanged. Last, so a hidden edge carrying an edge class still reads hidden. + if (svg_render_hidden_edges_) { + svg_file.stream << + " path.hidden {\n" + " stroke: #000000;\n" + " stroke-width: 0.13px;\n" + " stroke-opacity: 0.4;\n" + " stroke-dasharray: 1.5 1.5;\n" + " fill: none;\n" + " }\n"; + } + svg_file.stream << " ]]>\n" " \n"; diff --git a/src/serializers/SvgSerializer.h b/src/serializers/SvgSerializer.h index 9da7963f7c..e0bbfddef3 100644 --- a/src/serializers/SvgSerializer.h +++ b/src/serializers/SvgSerializer.h @@ -220,6 +220,8 @@ namespace { // though HLR's own output compounds carry no face topology at all. Empty class string // means "unclassified" (used for the two fallback cases below). const std::list>* classified_shapes_ = nullptr; + // Issue #6424: also extract the occluded counterparts of every visible query below. + bool emit_hidden_ = false; public: typedef std::list> result_type; @@ -227,6 +229,10 @@ namespace { hlr_calc(const HLRAlgo_Projector& projector) : projector_(projector) {} + void set_emit_hidden(bool b) { + emit_hidden_ = b; + } + void set_product_shape(const std::list>* product_shapes) { product_shapes_ = product_shapes; } @@ -239,18 +245,37 @@ namespace { throw std::runtime_error(""); } + // Issue #6424: occluded counterpart of a visible entry, emitted before it so hidden + // linework is painted underneath. Class is "hidden", suffixed with the edge class. + template + void push_hidden(result_type& r, const IfcUtil::IfcBaseEntity* product, const std::string& cls, ShapeT&& hidden) { + if (!TopExp_Explorer(hidden, TopAbs_EDGE).More()) { + return; + } + r.push_back({ product, cls.empty() ? std::string("hidden") : "hidden " + cls, std::forward(hidden) }); + } + template result_type extract(HlrToShapeT& hlr_shapes) { result_type r; if (classified_shapes_ && !classified_shapes_->empty()) { for (auto& t : *classified_shapes_) { + if (emit_hidden_) { + push_hidden(r, std::get<0>(t), std::get<1>(t), occt_join(hlr_shapes.OutLineHCompound(std::get<2>(t)), hlr_shapes.HCompound(std::get<2>(t)))); + } r.push_back({ std::get<0>(t), std::get<1>(t), occt_join(hlr_shapes.OutLineVCompound(std::get<2>(t)), hlr_shapes.VCompound(std::get<2>(t))) }); } } else if (product_shapes_) { for (auto& p : *product_shapes_) { + if (emit_hidden_) { + push_hidden(r, p.first, std::string(), occt_join(hlr_shapes.OutLineHCompound(p.second), hlr_shapes.HCompound(p.second))); + } r.push_back({ p.first, std::string(), occt_join(hlr_shapes.OutLineVCompound(p.second), hlr_shapes.VCompound(p.second)) }); } } else { + if (emit_hidden_) { + push_hidden(r, nullptr, std::string(), occt_join(hlr_shapes.OutLineHCompound(), hlr_shapes.HCompound())); + } r.push_back({ nullptr, std::string(), occt_join(hlr_shapes.OutLineVCompound(), hlr_shapes.VCompound()) }); } return r; @@ -373,6 +398,7 @@ namespace { hlr_brep_or_poly_t engine_; bool use_prefiltering_; + bool emit_hidden_; bool use_hlr_poly_; bool segment_projection_; gp_Ax1 view_direction_; @@ -387,9 +413,11 @@ namespace { public: - prefiltered_hlr(Logger& logger, bool use_prefiltering, bool use_hlr_poly, bool segment_projection, const gp_Pln& view_direction) + prefiltered_hlr(Logger& logger, bool use_prefiltering, bool use_hlr_poly, bool segment_projection, const gp_Pln& view_direction, bool emit_hidden = false) : logger_(logger) - , use_prefiltering_(use_prefiltering) + // Issue #6424: prefiltering drops exactly the geometry hidden lines need. + , use_prefiltering_(use_prefiltering && !emit_hidden) + , emit_hidden_(emit_hidden) , use_hlr_poly_(use_hlr_poly) , segment_projection_(segment_projection) // @nb negative z in accordance with occt projector convention (and opengl) @@ -553,6 +581,7 @@ namespace { vis.set_product_shape(&items_); } vis.set_classified_shapes(&classified_items_); + vis.set_emit_hidden(emit_hidden_); return boost::apply_visitor(vis, engine_); } }; @@ -603,6 +632,7 @@ protected: bool svg_use_edge_classification_; bool svg_render_crease_edges_; bool svg_render_sharp_edges_; + bool svg_render_hidden_edges_; IfcParse::IfcFile* file; const IfcUtil::IfcBaseEntity* storey_; @@ -663,6 +693,7 @@ public: , svg_use_edge_classification_(false) , svg_render_crease_edges_(true) , svg_render_sharp_edges_(true) + , svg_render_hidden_edges_(false) , file(0) , storey_(0) , xcoords_begin(0)