mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-20 12:12:15 +00:00
SvgSerializer: optionally draw occluded linework as hidden lines
Elements that sit entirely behind others were dropped from drawings altogether. A beam beneath a slab simply did not exist in the SVG, so the only workaround was hiding the slab and tracing the beam by hand. Two things discarded the geometry. prefiltered_hlr drops fully obscured items (and back faces) before they ever reach the algorithm, which Bonsai enables via setUsePrefiltering. What survived that was then read back with VCompound/OutLineVCompound only, so OCCT's hidden edge sets were thrown away. Both engines already expose HCompound/OutLineHCompound with the same shape-parameterised overloads, so the occluded linework was there for the asking. Adds svg-render-hidden-edges, off by default. When on, hlr_calc extracts the occluded counterpart of every visible query and emits it as paths carrying the "hidden" class, before their visible siblings so hidden linework paints underneath. Prefiltering is forced off in that mode since it removes exactly the geometry hidden lines need, which is a real cost and the reason this is opt-in. Edge classification composes: a classified hidden edge reads "hidden sharp", so a stylesheet can select either. The default stylesheet rule is emitted only when the setting is on, so output with it off is byte-identical to before. On the Bonsai side this is a per-drawing Render Hidden Lines toggle stored in EPset_Drawing. The SHAPELY fill mode polygonises projection linework to build filled surfaces, so hidden paths are excluded there: an occluded edge doesn't bound a visible surface. setup_serialiser also now calls ready(), which is what latches the svg-* geometry settings into the serialiser. IfcConvert calls it before use and Bonsai never did, so the edge classification settings added in #3668 were silently inert there too. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -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 */
|
||||
/*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -407,6 +407,12 @@ namespace ifcopenshell {
|
||||
static constexpr bool defaultvalue = true;
|
||||
};
|
||||
|
||||
struct SvgRenderHiddenEdges : public SettingBase<SvgRenderHiddenEdges, bool> {
|
||||
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<KeepBoundingBoxes, bool> {
|
||||
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<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, SvgRidgeAngleMinDegrees, SvgValleyAngleMinDegrees, SvgEmitFlushEdges, SvgUseEdgeClassification, SvgRenderCreaseEdges, SvgRenderSharpEdges, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
|
||||
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, SvgRidgeAngleMinDegrees, SvgValleyAngleMinDegrees, SvgEmitFlushEdges, SvgUseEdgeClassification, SvgRenderCreaseEdges, SvgRenderSharpEdges, SvgRenderHiddenEdges, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
|
||||
>
|
||||
{};
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ bool SvgSerializer::ready() {
|
||||
svg_use_edge_classification_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgUseEdgeClassification>().get();
|
||||
svg_render_crease_edges_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgRenderCreaseEdges>().get();
|
||||
svg_render_sharp_edges_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgRenderSharpEdges>().get();
|
||||
svg_render_hidden_edges_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgRenderHiddenEdges>().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<section_data>{ 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"
|
||||
" </style>\n";
|
||||
|
||||
@@ -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<std::tuple<const IfcUtil::IfcBaseEntity*, std::string, TopoDS_Shape>>* classified_shapes_ = nullptr;
|
||||
// Issue #6424: also extract the occluded counterparts of every visible query below.
|
||||
bool emit_hidden_ = false;
|
||||
|
||||
public:
|
||||
typedef std::list<std::tuple<const IfcUtil::IfcBaseEntity*, std::string, TopoDS_Shape>> 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<std::pair<const IfcUtil::IfcBaseEntity*, TopoDS_Shape>>* 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 <typename ShapeT>
|
||||
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<ShapeT>(hidden) });
|
||||
}
|
||||
|
||||
template <typename HlrToShapeT>
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user