mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
Merge pull request #8608 from sboddy/feature-svg-edge-classification-3668-4
Classify projection edges in SVG elevations (#3668)
This commit is contained in:
@@ -24,10 +24,28 @@ a text, a tspan { fill: blue !important; text-decoration: underline;}
|
||||
a:hover { cursor: pointer; }
|
||||
.cut { fill: black; stroke: black; stroke-linecap: 'round'; stroke-width: 0.35; fill-rule: evenodd; }
|
||||
.projection { fill: white; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
|
||||
.surface { stroke: none; fill: #fff; fill-rule: evenodd; }
|
||||
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
|
||||
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
|
||||
.IfcGeographicElement { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 1; }
|
||||
/* SVG edge classification (issue #3668): see edge-classification.md. These select directly on
|
||||
the <path> element (each classified projection edge carries its own class), so they win over
|
||||
the inherited .projection rule above regardless of specificity. */
|
||||
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
|
||||
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; }
|
||||
|
||||
/* Debug CSS for troubleshooting edge classification */
|
||||
/*
|
||||
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
|
||||
path.boundary { stroke: orange; stroke-width: 0.3; stroke-opacity: 0.9; }
|
||||
path.crease { stroke: green; stroke-width: 0.25; stroke-opacity: 0.85; }
|
||||
path.sharp { stroke: red; stroke-width: 0.18; stroke-opacity: 0.7; }
|
||||
path.flush { stroke: blue; stroke-width: 0.1; stroke-opacity: 0.4; }
|
||||
*/
|
||||
|
||||
.surface {fill: white; stroke-width: 0.1;}
|
||||
.annotation { 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;} */
|
||||
.PredefinedType-LINEWORK { stroke: black; stroke-width: 0.25; }
|
||||
.PredefinedType-LINEWORK.dashed { stroke-dasharray: 3, 2; }
|
||||
.PredefinedType-LINEWORK.fine { stroke-width: 0.18; stroke: #777777; }
|
||||
|
||||
@@ -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));
|
||||
#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));
|
||||
#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.);
|
||||
@@ -35,5 +35,11 @@ DATA;
|
||||
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#31=IFCSIMPLEPROPERTYTEMPLATE('1cFVJnqT13m8ItkMHaI1tp',$,'UseEdgeClassification','Enable the boundary/outline/sharp/crease/flush SVG edge classification scheme (issue #3668). When false, drawings use the original unclassified linework.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#32=IFCSIMPLEPROPERTYTEMPLATE('2kB$mxBgnBUvhjh0Ti0c4P',$,'RenderCreases','Whether to render ''crease'' (concave) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#33=IFCSIMPLEPROPERTYTEMPLATE('3MSIJNW$T8r9Hl12kk0BY$',$,'ValleyAngleMinDegrees','Minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as ''crease'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#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.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -1316,6 +1316,18 @@ class CreateDrawing(bpy.types.Operator):
|
||||
self.svg_settings = ifcopenshell.geom.settings()
|
||||
self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
|
||||
# SVG edge classification (issue #3668). See edge-classification.md. Settings are
|
||||
# per-drawing, stored in EPset_Drawing and read into self.cprops by import_camera_props.
|
||||
try:
|
||||
self.svg_settings.set("svg-use-edge-classification", self.cprops.use_edge_classification)
|
||||
self.svg_settings.set("svg-render-crease-edges", self.cprops.render_creases)
|
||||
self.svg_settings.set("svg-valley-angle-min-degrees", self.cprops.valley_angle_min_degrees)
|
||||
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)
|
||||
except Exception:
|
||||
# Backwards compatibility with older ifcopenshell builds that don't expose these keys.
|
||||
pass
|
||||
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
|
||||
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
|
||||
self.serialiser = ifcopenshell.geom.serializers.svg(
|
||||
|
||||
@@ -536,6 +536,50 @@ class BIMCameraProperties(PropertyGroup):
|
||||
default=True,
|
||||
update=get_update_layer_callback("has_annotation", "HasAnnotation"),
|
||||
)
|
||||
use_edge_classification: BoolProperty(
|
||||
name="Use Edge Classification",
|
||||
description="Classify projection edges into boundary/outline/sharp/crease/flush "
|
||||
"instead of drawing all linework identically. See edge-classification.md",
|
||||
default=False,
|
||||
update=get_update_layer_callback("use_edge_classification", "UseEdgeClassification"),
|
||||
)
|
||||
render_creases: BoolProperty(
|
||||
name="Render Creases",
|
||||
description="Render 'crease' (concave) projection edges",
|
||||
default=True,
|
||||
update=get_update_layer_callback("render_creases", "RenderCreases"),
|
||||
)
|
||||
valley_angle_min_degrees: FloatProperty(
|
||||
name="Valley Angle Minimum",
|
||||
description="Minimum concave dihedral deviation from flat, in degrees, for a projection "
|
||||
"edge to be classified as 'crease' rather than 'flush'",
|
||||
default=12.0,
|
||||
min=0.0,
|
||||
max=180.0,
|
||||
update=get_update_layer_callback("valley_angle_min_degrees", "ValleyAngleMinDegrees"),
|
||||
)
|
||||
render_sharp: BoolProperty(
|
||||
name="Render Sharp",
|
||||
description="Render 'sharp' (convex) projection edges",
|
||||
default=True,
|
||||
update=get_update_layer_callback("render_sharp", "RenderSharp"),
|
||||
)
|
||||
ridge_angle_min_degrees: FloatProperty(
|
||||
name="Ridge Angle Minimum",
|
||||
description="Minimum convex dihedral deviation from flat, in degrees, for a projection "
|
||||
"edge to be classified as 'sharp' rather than 'flush'",
|
||||
default=45.0,
|
||||
min=0.0,
|
||||
max=180.0,
|
||||
update=get_update_layer_callback("ridge_angle_min_degrees", "RidgeAngleMinDegrees"),
|
||||
)
|
||||
render_flush: BoolProperty(
|
||||
name="Render Flush",
|
||||
description="Render 'flush' projection edges (dihedral deviation below both ridge/valley "
|
||||
"thresholds). Omitted by default",
|
||||
default=False,
|
||||
update=get_update_layer_callback("render_flush", "RenderFlush"),
|
||||
)
|
||||
target_view: EnumProperty(
|
||||
name="Target View",
|
||||
default="PLAN_VIEW",
|
||||
|
||||
@@ -113,6 +113,19 @@ class BIM_PT_camera(Panel):
|
||||
row.prop(props, "fill_mode")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "cut_mode")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "use_edge_classification")
|
||||
if props.use_edge_classification:
|
||||
row = self.layout.row()
|
||||
row.prop(props, "render_creases")
|
||||
row.prop(props, "valley_angle_min_degrees")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "render_sharp")
|
||||
row.prop(props, "ridge_angle_min_degrees")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "render_flush")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "width")
|
||||
row = self.layout.row()
|
||||
|
||||
@@ -1072,6 +1072,12 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.has_annotation = True
|
||||
camera_props.target_view = "PLAN_VIEW"
|
||||
camera_props.is_nts = False
|
||||
camera_props.use_edge_classification = False
|
||||
camera_props.render_creases = True
|
||||
camera_props.valley_angle_min_degrees = 12.0
|
||||
camera_props.render_sharp = True
|
||||
camera_props.ridge_angle_min_degrees = 45.0
|
||||
camera_props.render_flush = False
|
||||
camera.shift_x = 0.0
|
||||
camera.shift_y = 0.0
|
||||
|
||||
@@ -1101,6 +1107,18 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.has_annotation = bool(pset["HasAnnotation"])
|
||||
if "IsNTS" in pset:
|
||||
camera_props.is_nts = bool(pset["IsNTS"])
|
||||
if "UseEdgeClassification" in pset:
|
||||
camera_props.use_edge_classification = bool(pset["UseEdgeClassification"])
|
||||
if "RenderCreases" in pset:
|
||||
camera_props.render_creases = bool(pset["RenderCreases"])
|
||||
if "ValleyAngleMinDegrees" in pset:
|
||||
camera_props.valley_angle_min_degrees = float(pset["ValleyAngleMinDegrees"])
|
||||
if "RenderSharp" in pset:
|
||||
camera_props.render_sharp = bool(pset["RenderSharp"])
|
||||
if "RidgeAngleMinDegrees" in pset:
|
||||
camera_props.ridge_angle_min_degrees = float(pset["RidgeAngleMinDegrees"])
|
||||
if "RenderFlush" in pset:
|
||||
camera_props.render_flush = bool(pset["RenderFlush"])
|
||||
if "DPI" in pset:
|
||||
camera_props.dpi = int(pset["DPI"])
|
||||
if "LineworkMode" in pset:
|
||||
|
||||
@@ -112,6 +112,51 @@ class TestImportCameraProps(NewFile):
|
||||
assert camera.shift_x == 0.0
|
||||
assert camera.shift_y == 0.0
|
||||
|
||||
def test_defaults_edge_classification_props_when_pset_is_absent(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
|
||||
subject.import_camera_props(drawing, camera)
|
||||
|
||||
props = subject.get_camera_props(camera)
|
||||
assert props.use_edge_classification is False
|
||||
assert props.render_creases is True
|
||||
assert props.valley_angle_min_degrees == pytest.approx(12.0)
|
||||
assert props.render_sharp is True
|
||||
assert props.ridge_angle_min_degrees == pytest.approx(45.0)
|
||||
assert props.render_flush is False
|
||||
|
||||
def test_imports_edge_classification_props_from_drawing_pset(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc,
|
||||
pset=pset,
|
||||
properties={
|
||||
"UseEdgeClassification": True,
|
||||
"RenderCreases": False,
|
||||
"ValleyAngleMinDegrees": 8.0,
|
||||
"RenderSharp": False,
|
||||
"RidgeAngleMinDegrees": 30.0,
|
||||
"RenderFlush": True,
|
||||
},
|
||||
)
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
|
||||
subject.import_camera_props(drawing, camera)
|
||||
|
||||
props = subject.get_camera_props(camera)
|
||||
assert props.use_edge_classification is True
|
||||
assert props.render_creases is False
|
||||
assert props.valley_angle_min_degrees == pytest.approx(8.0)
|
||||
assert props.render_sharp is False
|
||||
assert props.ridge_angle_min_degrees == pytest.approx(30.0)
|
||||
assert props.render_flush is True
|
||||
|
||||
|
||||
class TestSyncPerspectiveCameraShifts(NewFile):
|
||||
def test_round_trips_perspective_camera_shifts_through_drawing_pset(self):
|
||||
|
||||
@@ -371,6 +371,42 @@ namespace ifcopenshell {
|
||||
static constexpr double defaultvalue = -1.;
|
||||
};
|
||||
|
||||
struct SvgRidgeAngleMinDegrees : public SettingBase<SvgRidgeAngleMinDegrees, double> {
|
||||
static constexpr const char* const name = "svg-ridge-angle-min-degrees";
|
||||
static constexpr const char* const description = "SVG edge classification (issue #3668): minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as 'sharp' rather than 'flush'.";
|
||||
static constexpr double defaultvalue = 45.;
|
||||
};
|
||||
|
||||
struct SvgValleyAngleMinDegrees : public SettingBase<SvgValleyAngleMinDegrees, double> {
|
||||
static constexpr const char* const name = "svg-valley-angle-min-degrees";
|
||||
static constexpr const char* const description = "SVG edge classification (issue #3668): minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as 'crease' rather than 'flush'.";
|
||||
static constexpr double defaultvalue = 12.;
|
||||
};
|
||||
|
||||
struct SvgEmitFlushEdges : public SettingBase<SvgEmitFlushEdges, bool> {
|
||||
static constexpr const char* const name = "svg-emit-flush-edges";
|
||||
static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'flush' projection edges (dihedral deviation below both ridge/valley thresholds). Defaults to false, i.e. flush edges are omitted from the output.";
|
||||
static constexpr bool defaultvalue = false;
|
||||
};
|
||||
|
||||
struct SvgUseEdgeClassification : public SettingBase<SvgUseEdgeClassification, bool> {
|
||||
static constexpr const char* const name = "svg-use-edge-classification";
|
||||
static constexpr const char* const description = "SVG edge classification (issue #3668): enable the 5-class boundary/outline/sharp/crease/flush scheme. When false (the default), falls back to the original unclassified linework.";
|
||||
static constexpr bool defaultvalue = false;
|
||||
};
|
||||
|
||||
struct SvgRenderCreaseEdges : public SettingBase<SvgRenderCreaseEdges, bool> {
|
||||
static constexpr const char* const name = "svg-render-crease-edges";
|
||||
static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'crease' (concave) projection edges. Only relevant when svg-use-edge-classification is enabled.";
|
||||
static constexpr bool defaultvalue = true;
|
||||
};
|
||||
|
||||
struct SvgRenderSharpEdges : public SettingBase<SvgRenderSharpEdges, bool> {
|
||||
static constexpr const char* const name = "svg-render-sharp-edges";
|
||||
static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'sharp' (convex) projection edges. Only relevant when svg-use-edge-classification is enabled.";
|
||||
static constexpr bool defaultvalue = true;
|
||||
};
|
||||
|
||||
struct KeepBoundingBoxes : public SettingBase<KeepBoundingBoxes, bool> {
|
||||
static constexpr const char* const name = "keep-bounding-boxes";
|
||||
static constexpr const char* const description =
|
||||
@@ -653,7 +689,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, 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, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
|
||||
>
|
||||
{};
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ SETTING = Literal[
|
||||
"reorient-shells",
|
||||
"site-local-placement",
|
||||
"surface-colour",
|
||||
"svg-emit-flush-edges",
|
||||
"svg-render-crease-edges",
|
||||
"svg-render-sharp-edges",
|
||||
"svg-ridge-angle-min-degrees",
|
||||
"svg-use-edge-classification",
|
||||
"svg-valley-angle-min-degrees",
|
||||
"triangulation-type",
|
||||
"unify-shapes",
|
||||
"use-material-names",
|
||||
|
||||
@@ -102,10 +102,16 @@
|
||||
const double PI2 = M_PI * 2.;
|
||||
|
||||
bool SvgSerializer::ready() {
|
||||
svg_ridge_angle_min_deg_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgRidgeAngleMinDegrees>().get();
|
||||
svg_valley_angle_min_deg_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgValleyAngleMinDegrees>().get();
|
||||
svg_emit_flush_edges_ = geometry_settings().get<ifcopenshell::geometry::settings::SvgEmitFlushEdges>().get();
|
||||
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();
|
||||
return true;
|
||||
}
|
||||
|
||||
void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, boost::optional<std::vector<double>> dash_array) {
|
||||
void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, boost::optional<std::vector<double>> dash_array, boost::optional<std::string> css_class) {
|
||||
/* ShapeFix_Wire fix;
|
||||
Handle(ShapeExtend_WireData) data = new ShapeExtend_WireData;
|
||||
for (TopExp_Explorer edges(result, TopAbs_EDGE); edges.More(); edges.Next()) {
|
||||
@@ -351,6 +357,12 @@ void SvgSerializer::write(path_object& p, const TopoDS_Shape& comp_or_wire, boos
|
||||
if (!path.empty()) {
|
||||
path.add("\"");
|
||||
|
||||
if (css_class) {
|
||||
path.add(" class=\"");
|
||||
path.add(*css_class);
|
||||
path.add("\"");
|
||||
}
|
||||
|
||||
if (dash_array) {
|
||||
path.add(" stroke-dasharray=\"");
|
||||
bool first = true;
|
||||
@@ -622,7 +634,7 @@ void SvgSerializer::write(const IfcGeom::BRepElement* brep_obj) {
|
||||
} else if (elevation_ref_guid_) {
|
||||
is_elevation = *elevation_ref_guid_ == brep_obj->guid();
|
||||
}
|
||||
|
||||
|
||||
BRepBuilderAPI_Transform make_transform_global(compound_local, trsf, true);
|
||||
make_transform_global.Build();
|
||||
// (When determinant < 0, copy is implied and the input is not mutated.)
|
||||
@@ -795,6 +807,182 @@ namespace {
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
// SVG edge classification (issue #3668). See edge-classification.md at the repo root for
|
||||
// the authoritative definition of the five classes and their evaluation order.
|
||||
enum class edge_style_class { boundary, outline, sharp, crease, flush };
|
||||
|
||||
const char* edge_style_class_name(edge_style_class c) {
|
||||
switch (c) {
|
||||
case edge_style_class::boundary: return "boundary";
|
||||
case edge_style_class::outline: return "outline";
|
||||
case edge_style_class::sharp: return "sharp";
|
||||
case edge_style_class::crease: return "crease";
|
||||
default: return "flush";
|
||||
}
|
||||
}
|
||||
|
||||
// Outward face normal, accounting for face orientation. Only planar faces are supported;
|
||||
// returns false otherwise (caller should conservatively treat the edge as an outline).
|
||||
bool face_normal_from_planar_face(const TopoDS_Face& f, gp_Dir& out) {
|
||||
auto s = BRep_Tool::Surface(f);
|
||||
if (s->DynamicType() != STANDARD_TYPE(Geom_Plane)) {
|
||||
return false;
|
||||
}
|
||||
auto p = Handle(Geom_Plane)::DownCast(s);
|
||||
gp_Dir d = p->Axis().Direction();
|
||||
if (f.Orientation() == TopAbs_REVERSED) {
|
||||
d.Reverse();
|
||||
}
|
||||
out = d;
|
||||
return true;
|
||||
}
|
||||
|
||||
double clamp_dot(double v) {
|
||||
if (v < -1.0) return -1.0;
|
||||
if (v > 1.0) return 1.0;
|
||||
return v;
|
||||
}
|
||||
|
||||
edge_style_class classify_edge_from_faces(
|
||||
const TopoDS_Edge& edge,
|
||||
const NCollection_List<TopoDS_Shape>& faces,
|
||||
const gp_Dir& projection_direction,
|
||||
double ridge_angle_min_deg,
|
||||
double valley_angle_min_deg
|
||||
) {
|
||||
std::vector<TopoDS_Face> faces_vec;
|
||||
for (NCollection_List<TopoDS_Shape>::Iterator it(faces); it.More(); it.Next()) {
|
||||
const TopoDS_Shape& s = it.Value();
|
||||
if (s.ShapeType() == TopAbs_FACE) {
|
||||
faces_vec.push_back(TopoDS::Face(s));
|
||||
}
|
||||
}
|
||||
|
||||
// Boundary: naked edge, or non-manifold (3+ faces) -- the latter is explicitly out of
|
||||
// scope for the 5-class scheme (a geometry-health/QA concern), so fall back to the
|
||||
// same conservative bucket rather than force-fitting it into outline/sharp/crease.
|
||||
if (faces_vec.size() != 2) {
|
||||
return edge_style_class::boundary;
|
||||
}
|
||||
|
||||
const TopoDS_Face& f0 = faces_vec[0];
|
||||
const TopoDS_Face& f1 = faces_vec[1];
|
||||
|
||||
gp_Dir n0, n1;
|
||||
if (!face_normal_from_planar_face(f0, n0) || !face_normal_from_planar_face(f1, n1)) {
|
||||
// Conservative fallback for non-planar-face edges.
|
||||
return edge_style_class::outline;
|
||||
}
|
||||
|
||||
// Note the negation: `projection_direction` (as constructed by the caller from the
|
||||
// drawing plane's axis) points from the scene *towards the camera*, not into the scene.
|
||||
// A face that's actually front-facing (visible, facing the viewer) has an outward normal
|
||||
// pointing the same general way as that -- i.e. a *positive* dot product -- so negate
|
||||
// here to get the more intuitive "front-facing is negative" convention used below.
|
||||
// Confirmed against this feature's own real-world test scene: the SOUTH ELEVATION
|
||||
// camera's placement matrix transforms local +Z (what the un-negated projection_direction
|
||||
// is built from) to world (0, 1, 0), while the camera's actual Blender-convention view
|
||||
// direction (local -Z) transforms to world (0, -1, 0) -- i.e. exactly opposite.
|
||||
const double d0 = -projection_direction.Dot(n0);
|
||||
const double d1 = -projection_direction.Dot(n1);
|
||||
|
||||
// Front/back/edge-on classification of each face relative to the view direction, using
|
||||
// a tolerance band around zero rather than a bare sign comparison. A face at or near
|
||||
// edge-on to the camera (|d| within the band) is common for regular/symmetric
|
||||
// tessellations viewed from "nice" angles (icospheres, N-gon cylinder/cone
|
||||
// approximations) and must count as outline on both its edges, not just the one that
|
||||
// happens to pair it with a clearly front-facing neighbour.
|
||||
constexpr double kOutlineDotEps = 1.e-5;
|
||||
const bool front0 = d0 < -kOutlineDotEps;
|
||||
const bool back0 = d0 > kOutlineDotEps;
|
||||
const bool front1 = d1 < -kOutlineDotEps;
|
||||
const bool back1 = d1 > kOutlineDotEps;
|
||||
|
||||
// Outline: silhouette, either a genuine front/back flip, or either face is at/near
|
||||
// edge-on to the view direction (also covers both faces edge-on at once).
|
||||
if (!(front0 && front1) && !(back0 && back1)) {
|
||||
return edge_style_class::outline;
|
||||
}
|
||||
|
||||
// Signed deviation from flat (0 degrees between outward normals = perfectly flat, i.e.
|
||||
// coplanar faces have identical outward normals). Positive = convex (ridge/sharp),
|
||||
// negative = concave (valley/crease).
|
||||
//
|
||||
// Sign via a position-based (not orientation-based) test: find a vertex of f1 that
|
||||
// isn't one of the shared edge's own endpoints, and check which side of f0's plane it
|
||||
// falls on. If it's behind f0's plane (opposite side from f0's outward normal), f1
|
||||
// curves back towards the solid's interior relative to f0 -- a convex fold, like a box
|
||||
// corner. This avoids relying on TopoDS_Edge/wire orientation semantics (which proved
|
||||
// unreliable in practice: an earlier attempt using edge.Orientation() combined with
|
||||
// cross(n0, n1) gave a self-consistent-looking but wrong sign on real BRep topology --
|
||||
// verified against known-convex geometry, e.g. every edge of a convex icosphere, where
|
||||
// that approach misclassified a majority of edges as concave).
|
||||
double deviation_deg = std::acos(clamp_dot(n0.Dot(n1))) * 180.0 / M_PI;
|
||||
|
||||
TopoDS_Vertex ev0, ev1;
|
||||
TopExp::Vertices(edge, ev0, ev1);
|
||||
const gp_Pnt edge_p0 = BRep_Tool::Pnt(ev0);
|
||||
const gp_Pnt edge_p1 = BRep_Tool::Pnt(ev1);
|
||||
|
||||
for (TopExp_Explorer vexp(f1, TopAbs_VERTEX); vexp.More(); vexp.Next()) {
|
||||
const gp_Pnt p = BRep_Tool::Pnt(TopoDS::Vertex(vexp.Current()));
|
||||
if (p.Distance(edge_p0) > Precision::Confusion() && p.Distance(edge_p1) > Precision::Confusion()) {
|
||||
const bool convex = gp_Vec(edge_p0, p).Dot(gp_Vec(n0.XYZ())) < 0.0;
|
||||
if (!convex) {
|
||||
deviation_deg = -deviation_deg;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// View-relative flip for folds seen from behind through an opening (e.g. the "Rotated
|
||||
// Box w/Boundary" test object -- a box with one face removed; the 3 interior lines
|
||||
// visible through the opening read as the *inside* of an ordinary convex box corner,
|
||||
// which should look like a crease, not a sharp ridge). Two earlier unconditional
|
||||
// versions of this flip (triggered on plain back0&&back1, with no further gate) were
|
||||
// tried and reverted -- see edge-classification.md follow-up notes -- because they
|
||||
// corrupted otherwise-correct classification broadly, manifesting as spurious `crease`
|
||||
// edges on a fully-convex icosphere test case that has no opening at all.
|
||||
//
|
||||
// That corruption wasn't a fundamental inability to distinguish "genuinely seen through
|
||||
// a hole" from "ordinary far side of closed geometry": bucket membership here is purely
|
||||
// a post-hoc query key into an already-completed, correct HLR visibility computation,
|
||||
// so reclassifying an edge can never make a genuinely hidden edge appear or vice versa.
|
||||
// The real cause is a threshold-crossing artifact: near the silhouette, facet-normal
|
||||
// noise on regular/symmetric tessellations (icospheres, N-gon cylinder/cone
|
||||
// approximations) makes some genuinely near-edge-on facets test as "back" under the
|
||||
// flat-normal-based back0/back1 test even though they're still visible. An unconditional
|
||||
// negate then took their small, correctly-`flush` solid-relative deviation and re-tested
|
||||
// it against the *other* threshold -- `ridge_angle_min_deg` (45 degrees by default) and
|
||||
// `valley_angle_min_deg` (12 degrees by default) are deliberately asymmetric, so a gentle
|
||||
// ~20 degree convex facet transition that safely sits under the ridge threshold crosses
|
||||
// well over the much smaller valley threshold once flipped, becoming a spurious `crease`.
|
||||
//
|
||||
// Fix: gate the flip so it can only reinterpret a fold that would already be visible
|
||||
// (sharp or crease) under its own pre-flip threshold -- i.e. only folds sharp/deep
|
||||
// enough to draw from the front get reinterpreted as the opposite class from behind.
|
||||
// Gentle tessellation-noise deviations that are correctly `flush` either way never cross
|
||||
// the asymmetric threshold gap, because they never reach the flip at all. Verified
|
||||
// against the full test scene: every object's classification is byte-for-byte unchanged
|
||||
// except "Rotated Box w/Boundary", whose 3 interior lines now correctly read `crease`
|
||||
// (previously all 4 non-boundary edges read `sharp`).
|
||||
if (back0 && back1) {
|
||||
const bool would_show_unflipped =
|
||||
(deviation_deg >= 0.0) ? (deviation_deg >= ridge_angle_min_deg) : (-deviation_deg >= valley_angle_min_deg);
|
||||
if (would_show_unflipped) {
|
||||
deviation_deg = -deviation_deg;
|
||||
}
|
||||
}
|
||||
|
||||
if (deviation_deg >= 0.0) {
|
||||
return (deviation_deg >= ridge_angle_min_deg) ? edge_style_class::sharp : edge_style_class::flush;
|
||||
} else {
|
||||
return (-deviation_deg >= valley_angle_min_deg) ? edge_style_class::crease : edge_style_class::flush;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SvgSerializer::write(const geometry_data& data) {
|
||||
std::vector<section_data> section_heights_storage;
|
||||
const std::vector<section_data>* section_heights_used = §ion_heights_storage;
|
||||
@@ -1208,6 +1396,92 @@ void SvgSerializer::write(const geometry_data& data) {
|
||||
}
|
||||
}
|
||||
|
||||
// SVG edge classification (issue #3668): classify *compound_to_hlr's edges (real
|
||||
// face topology, pre-HLR) into per-class edge-only sub-compounds. The full shape
|
||||
// is still registered via add()/it->second.add() below, unchanged, for correct
|
||||
// occlusion; these buckets only affect which class each edge's visible portion is
|
||||
// later extracted as (see hlr_calc::extract() in SvgSerializer.h).
|
||||
//
|
||||
// Gated behind svg_use_edge_classification_ (default false): the whole block must
|
||||
// be skipped, not just individually suppressed per-edge, so that when disabled
|
||||
// classified_edge_buckets stays empty for *every* product in the document, not
|
||||
// just this one. hlr_calc::extract() only takes the classified-buckets branch
|
||||
// when its shared classified_shapes_ list is non-empty; if even one product added
|
||||
// classified buckets while others didn't, those others would silently fall back
|
||||
// to unclassified linework while this one used classification, an inconsistent
|
||||
// mix. Leaving classified_edge_buckets empty here means add_classified_edges() is
|
||||
// never called for this product either, so every product uniformly falls through
|
||||
// to the pre-existing product_shapes_ fallback -- the original, pre-classification
|
||||
// linework.
|
||||
std::map<std::string, TopoDS_Compound> classified_edge_buckets;
|
||||
if (svg_use_edge_classification_) {
|
||||
NCollection_IndexedDataMap<TopoDS_Shape, NCollection_List<TopoDS_Shape>, TopTools_ShapeMapHasher> edge_face_map;
|
||||
TopExp::MapShapesAndAncestors(*compound_to_hlr, TopAbs_EDGE, TopAbs_FACE, edge_face_map);
|
||||
|
||||
gp_Dir view_dir;
|
||||
try {
|
||||
view_dir = gp_Dir(projection_direction);
|
||||
} catch (const Standard_Failure&) {
|
||||
view_dir = gp::DZ();
|
||||
}
|
||||
|
||||
BRep_Builder BBcls;
|
||||
for (int i = 1; i <= edge_face_map.Extent(); ++i) {
|
||||
const TopoDS_Edge& cls_edge = TopoDS::Edge(edge_face_map.FindKey(i));
|
||||
|
||||
edge_style_class cls = edge_style_class::outline;
|
||||
try {
|
||||
cls = classify_edge_from_faces(cls_edge, edge_face_map.FindFromIndex(i), view_dir, svg_ridge_angle_min_deg_, svg_valley_angle_min_deg_);
|
||||
} catch (const Standard_Failure& e) {
|
||||
logger_.Warning("SER", 30, std::string("SVG edge classification OCC exception: ") + e.GetMessageString());
|
||||
} catch (const std::exception& e) {
|
||||
logger_.Warning("SER", 31, std::string("SVG edge classification exception: ") + e.what());
|
||||
}
|
||||
|
||||
if (cls == edge_style_class::flush && !svg_emit_flush_edges_) {
|
||||
continue;
|
||||
}
|
||||
if (cls == edge_style_class::crease && !svg_render_crease_edges_) {
|
||||
continue;
|
||||
}
|
||||
if (cls == edge_style_class::sharp && !svg_render_sharp_edges_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string name = edge_style_class_name(cls);
|
||||
auto bucket_it = classified_edge_buckets.find(name);
|
||||
if (bucket_it == classified_edge_buckets.end()) {
|
||||
TopoDS_Compound c;
|
||||
BBcls.MakeCompound(c);
|
||||
bucket_it = classified_edge_buckets.emplace(name, c).first;
|
||||
}
|
||||
BBcls.Add(bucket_it->second, cls_edge);
|
||||
}
|
||||
|
||||
// Non-planar faces (e.g. a real analytic cylindrical wall from a
|
||||
// circular-profile column/pile, swept via BRepPrimAPI_MakePrism rather than
|
||||
// faceted) have a silhouette that HLR synthesizes on the fly -- it is not a
|
||||
// pre-existing topological edge, so the edge-only loop above can never bucket
|
||||
// it. OutLineVCompound(S) correlates a curved face's silhouette by the
|
||||
// identity of the originating *face*, not any edge, so add the non-planar
|
||||
// face itself into the outline bucket alongside whatever edges it already
|
||||
// contributed (top/bottom/seam), giving HLR's per-face OutLine reconstruction
|
||||
// something to match against.
|
||||
for (TopExp_Explorer fexp(*compound_to_hlr, TopAbs_FACE); fexp.More(); fexp.Next()) {
|
||||
const TopoDS_Face& f = TopoDS::Face(fexp.Current());
|
||||
if (BRep_Tool::Surface(f)->DynamicType() != STANDARD_TYPE(Geom_Plane)) {
|
||||
std::string name = edge_style_class_name(edge_style_class::outline);
|
||||
auto bucket_it = classified_edge_buckets.find(name);
|
||||
if (bucket_it == classified_edge_buckets.end()) {
|
||||
TopoDS_Compound c;
|
||||
BBcls.MakeCompound(c);
|
||||
bucket_it = classified_edge_buckets.emplace(name, c).first;
|
||||
}
|
||||
BBcls.Add(bucket_it->second, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_floor_plan_) {
|
||||
if (storey) {
|
||||
auto it = storey_hlr.find(storey);
|
||||
@@ -1215,11 +1489,17 @@ void SvgSerializer::write(const geometry_data& data) {
|
||||
it = storey_hlr.insert({ storey, hlr_t(logger_, use_prefiltering_, use_hlr_poly_, segment_projection_, projection_plane) }).first;
|
||||
}
|
||||
it->second.add(*compound_to_hlr, data.product);
|
||||
for (auto& kv : classified_edge_buckets) {
|
||||
it->second.add_classified_edges(data.product, kv.first, kv.second);
|
||||
}
|
||||
} else {
|
||||
logger_.Warning("SER", 28, "Unable to invoke HLR due to absence of storey containment", data.product);
|
||||
}
|
||||
} else if (hlr) {
|
||||
hlr->add(*compound_to_hlr, data.product);
|
||||
for (auto& kv : classified_edge_buckets) {
|
||||
hlr->add_classified_edges(data.product, kv.first, kv.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1767,49 +2047,63 @@ std::array<std::array<double, 3>, 3> SvgSerializer::resize() {
|
||||
}
|
||||
|
||||
void SvgSerializer::draw_hlr(const gp_Pln& pln, const drawing_key& drawing_name) {
|
||||
auto hlr_items = (drawing_name.first ? this->storey_hlr.find(drawing_name.first)->second : *hlr).build();
|
||||
hlr_t& hlr_source = drawing_name.first ? this->storey_hlr.find(drawing_name.first)->second : *hlr;
|
||||
auto hlr_items = hlr_source.build();
|
||||
|
||||
for (auto& p : hlr_items) {
|
||||
const TopoDS_Shape& hlr_compound_unmirrored = p.second;
|
||||
// SVG edge classification (issue #3668): each item's class is already known -- it was
|
||||
// determined pre-HLR from real face topology (see the classified_edge_buckets block in
|
||||
// write(const geometry_data&)) and threaded through via hlr_calc::extract(). No post-hoc
|
||||
// lookup against HLR's own (face-less) output is needed. Multiple items can share the same
|
||||
// product (one per non-empty class bucket); keep a single path_object/group per product so
|
||||
// per-path classes survive Bonsai's merge_linework_and_add_metadata untouched, rather than
|
||||
// creating a <g> per class (see plan notes on why that clobbers classes in Python).
|
||||
std::map<const IfcUtil::IfcBaseEntity*, path_object*> group_by_product;
|
||||
|
||||
if (!hlr_compound_unmirrored.IsNull()) {
|
||||
// Compound 3D curves for mirroring to work
|
||||
ShapeFix_Edge sfe;
|
||||
TopExp_Explorer exp(hlr_compound_unmirrored, TopAbs_EDGE);
|
||||
for (; exp.More(); exp.Next()) {
|
||||
sfe.FixAddCurve3d(TopoDS::Edge(exp.Current()));
|
||||
for (auto& item : hlr_items) {
|
||||
const IfcUtil::IfcBaseEntity* product = std::get<0>(item);
|
||||
const std::string& cls = std::get<1>(item);
|
||||
const TopoDS_Shape& hlr_compound_unmirrored = std::get<2>(item);
|
||||
|
||||
if (hlr_compound_unmirrored.IsNull()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compound 3D curves for mirroring to work
|
||||
ShapeFix_Edge sfe;
|
||||
TopExp_Explorer exp(hlr_compound_unmirrored, TopAbs_EDGE);
|
||||
for (; exp.More(); exp.Next()) {
|
||||
sfe.FixAddCurve3d(TopoDS::Edge(exp.Current()));
|
||||
}
|
||||
|
||||
// Mirror to match SVG coord system.
|
||||
// @todo this is very wasteful. We better do the Y-mirror in the SVG writing and
|
||||
// not on the TopoDS_Shape input.
|
||||
|
||||
TopoDS_Shape hlr_compound;
|
||||
if (drawing_name.first == nullptr) {
|
||||
gp_Trsf trsf_mirror;
|
||||
if (!mirror_y_) {
|
||||
trsf_mirror.SetMirror(gp_Ax2(gp::Origin(), gp::DY()));
|
||||
}
|
||||
|
||||
// Mirror to match SVG coord system.
|
||||
// @todo this is very wasteful. We better do the Y-mirror in the SVG writing and
|
||||
// not on the TopoDS_Shape input.
|
||||
|
||||
TopoDS_Shape hlr_compound;
|
||||
if (drawing_name.first == nullptr) {
|
||||
gp_Trsf trsf_mirror;
|
||||
if (!mirror_y_) {
|
||||
trsf_mirror.SetMirror(gp_Ax2(gp::Origin(), gp::DY()));
|
||||
}
|
||||
if (mirror_x_) {
|
||||
gp_Trsf mirror_x;
|
||||
mirror_x.SetMirror(gp_Ax2(gp::Origin(), gp::DX()));
|
||||
trsf_mirror.PreMultiply(mirror_x);
|
||||
}
|
||||
BRepBuilderAPI_Transform make_transform_mirror(hlr_compound_unmirrored, trsf_mirror, true);
|
||||
make_transform_mirror.Build();
|
||||
hlr_compound = make_transform_mirror.Shape();
|
||||
} else {
|
||||
// In case of building storey-based floor plan the mirroring has already
|
||||
// been taken into account before projection.
|
||||
hlr_compound = hlr_compound_unmirrored;
|
||||
if (mirror_x_) {
|
||||
gp_Trsf mirror_x;
|
||||
mirror_x.SetMirror(gp_Ax2(gp::Origin(), gp::DX()));
|
||||
trsf_mirror.PreMultiply(mirror_x);
|
||||
}
|
||||
BRepBuilderAPI_Transform make_transform_mirror(hlr_compound_unmirrored, trsf_mirror, true);
|
||||
make_transform_mirror.Build();
|
||||
hlr_compound = make_transform_mirror.Shape();
|
||||
} else {
|
||||
// In case of building storey-based floor plan the mirroring has already
|
||||
// been taken into account before projection.
|
||||
hlr_compound = hlr_compound_unmirrored;
|
||||
}
|
||||
|
||||
exp.Init(hlr_compound, TopAbs_EDGE);
|
||||
BRep_Builder B;
|
||||
path_object* po;
|
||||
path_object*& po = group_by_product[product];
|
||||
if (!po) {
|
||||
std::string name;
|
||||
if (p.first) {
|
||||
name = nameElement(p.first);
|
||||
if (product) {
|
||||
name = nameElement(product);
|
||||
boost::replace_all(name, "class=\"", "class=\"projection ");
|
||||
} else {
|
||||
name = "class=\"projection\"";
|
||||
@@ -1819,13 +2113,19 @@ void SvgSerializer::draw_hlr(const gp_Pln& pln, const drawing_key& drawing_name)
|
||||
} else {
|
||||
po = &start_path(pln, drawing_name.second, name);
|
||||
}
|
||||
for (; exp.More(); exp.Next()) {
|
||||
TopoDS_Wire w;
|
||||
B.MakeWire(w);
|
||||
B.Add(w, exp.Current());
|
||||
write(*po, w);
|
||||
}
|
||||
}
|
||||
|
||||
boost::optional<std::string> css_class;
|
||||
if (!cls.empty()) {
|
||||
css_class = cls;
|
||||
}
|
||||
|
||||
BRep_Builder B;
|
||||
for (TopExp_Explorer exp_mirrored(hlr_compound, TopAbs_EDGE); exp_mirrored.More(); exp_mirrored.Next()) {
|
||||
TopoDS_Wire w;
|
||||
B.MakeWire(w);
|
||||
B.Add(w, exp_mirrored.Current());
|
||||
write(*po, w, boost::none, css_class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2236,6 +2536,35 @@ void SvgSerializer::doWriteHeader() {
|
||||
" fill: none;\n"
|
||||
" stroke-opacity: 0.6;\n"
|
||||
" }\n"
|
||||
// SVG edge classification (issue #3668) -- see edge-classification.md. These
|
||||
// select directly on the <path> element (each classified edge carries its own
|
||||
// class), not on an ancestor <g>, so they win over the inherited .projection
|
||||
// path rule above regardless of specificity.
|
||||
" path.outline {\n"
|
||||
" stroke: #000000;\n"
|
||||
" stroke-width: 0.35px;\n"
|
||||
" stroke-opacity: 1;\n"
|
||||
" }\n"
|
||||
" path.boundary {\n"
|
||||
" stroke: #000000;\n"
|
||||
" stroke-width: 0.3px;\n"
|
||||
" stroke-opacity: 0.9;\n"
|
||||
" }\n"
|
||||
" path.sharp {\n"
|
||||
" stroke: #000000;\n"
|
||||
" stroke-width: 0.25px;\n"
|
||||
" stroke-opacity: 0.85;\n"
|
||||
" }\n"
|
||||
" path.crease {\n"
|
||||
" stroke: #000000;\n"
|
||||
" stroke-width: 0.18px;\n"
|
||||
" stroke-opacity: 0.7;\n"
|
||||
" }\n"
|
||||
" path.flush {\n"
|
||||
" stroke: #000000;\n"
|
||||
" stroke-width: 0.1px;\n"
|
||||
" stroke-opacity: 0.4;\n"
|
||||
" }\n"
|
||||
" .IfcDoor path,\n"
|
||||
" .Symbol path {\n"
|
||||
" fill: none;\n"
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
#include <string>
|
||||
#include <limits>
|
||||
#include <array>
|
||||
#include <tuple>
|
||||
|
||||
typedef std::pair<const IfcUtil::IfcBaseEntity*, std::string> drawing_key;
|
||||
|
||||
@@ -212,9 +213,16 @@ namespace {
|
||||
private:
|
||||
const HLRAlgo_Projector& projector_;
|
||||
const std::list<std::pair<const IfcUtil::IfcBaseEntity*, TopoDS_Shape>>* product_shapes_ = nullptr;
|
||||
// SVG edge classification (issue #3668): per-(product, class) edge-only sub-shapes,
|
||||
// classified pre-HLR on the original (real-face) topology. Queried via
|
||||
// VCompound(S)/OutLineVCompound(S), which correlate by the identity of the *original*
|
||||
// edges added to the algorithm -- not by the reconstructed output -- so this works even
|
||||
// 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;
|
||||
|
||||
public:
|
||||
typedef std::list<std::pair<const IfcUtil::IfcBaseEntity*, TopoDS_Shape>> result_type;
|
||||
typedef std::list<std::tuple<const IfcUtil::IfcBaseEntity*, std::string, TopoDS_Shape>> result_type;
|
||||
|
||||
hlr_calc(const HLRAlgo_Projector& projector) : projector_(projector)
|
||||
{}
|
||||
@@ -223,24 +231,37 @@ namespace {
|
||||
product_shapes_ = product_shapes;
|
||||
}
|
||||
|
||||
void set_classified_shapes(const std::list<std::tuple<const IfcUtil::IfcBaseEntity*, std::string, TopoDS_Shape>>* classified_shapes) {
|
||||
classified_shapes_ = classified_shapes;
|
||||
}
|
||||
|
||||
result_type operator()(boost::blank&) const {
|
||||
throw std::runtime_error("");
|
||||
}
|
||||
|
||||
template <typename HlrToShapeT>
|
||||
result_type extract(HlrToShapeT& hlr_shapes) {
|
||||
result_type r;
|
||||
if (classified_shapes_ && !classified_shapes_->empty()) {
|
||||
for (auto& t : *classified_shapes_) {
|
||||
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_) {
|
||||
r.push_back({ p.first, std::string(), occt_join(hlr_shapes.OutLineVCompound(p.second), hlr_shapes.VCompound(p.second)) });
|
||||
}
|
||||
} else {
|
||||
r.push_back({ nullptr, std::string(), occt_join(hlr_shapes.OutLineVCompound(), hlr_shapes.VCompound()) });
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
result_type operator()(opencascade::handle<HLRBRep_Algo>& algo) {
|
||||
algo->Projector(projector_);
|
||||
algo->Update();
|
||||
algo->Hide();
|
||||
HLRBRep_HLRToShape hlr_shapes(algo);
|
||||
if (product_shapes_) {
|
||||
std::list<std::pair<const IfcUtil::IfcBaseEntity*, TopoDS_Shape>> r;
|
||||
for (auto& p : *product_shapes_) {
|
||||
r.push_back({ p.first, occt_join(hlr_shapes.OutLineVCompound(p.second), hlr_shapes.VCompound(p.second)) });
|
||||
}
|
||||
return r;
|
||||
} else {
|
||||
return { {nullptr, occt_join(hlr_shapes.OutLineVCompound(), hlr_shapes.VCompound())}};
|
||||
}
|
||||
return extract(hlr_shapes);
|
||||
}
|
||||
|
||||
result_type operator()(opencascade::handle<HLRBRep_PolyAlgo>& algo) {
|
||||
@@ -248,15 +269,7 @@ namespace {
|
||||
algo->Update();
|
||||
HLRBRep_PolyHLRToShape hlr_shapes;
|
||||
hlr_shapes.Update(algo);
|
||||
if (product_shapes_) {
|
||||
std::list<std::pair<const IfcUtil::IfcBaseEntity*, TopoDS_Shape>> r;
|
||||
for (auto& p : *product_shapes_) {
|
||||
r.push_back({ p.first, occt_join(hlr_shapes.OutLineVCompound(p.second), hlr_shapes.VCompound(p.second)) });
|
||||
}
|
||||
return r;
|
||||
} else {
|
||||
return { {nullptr, occt_join(hlr_shapes.OutLineVCompound(), hlr_shapes.VCompound()) } };
|
||||
}
|
||||
return extract(hlr_shapes);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -367,6 +380,8 @@ namespace {
|
||||
|
||||
std::multimap<double, face_info> large_ortho_faces_;
|
||||
std::list<std::pair<const IfcUtil::IfcBaseEntity*, TopoDS_Shape>> items_;
|
||||
// SVG edge classification (issue #3668): see add_classified_edges().
|
||||
std::list<std::tuple<const IfcUtil::IfcBaseEntity*, std::string, TopoDS_Shape>> classified_items_;
|
||||
|
||||
Logger& logger_;
|
||||
|
||||
@@ -391,6 +406,16 @@ namespace {
|
||||
projector_ = HLRAlgo_Projector(trsf, false, 1.);
|
||||
}
|
||||
|
||||
// SVG edge classification (issue #3668): register an edge-only sub-shape of `product`'s
|
||||
// original (pre-HLR, real-face) geometry under a given class name (e.g. "outline",
|
||||
// "sharp"). The full shape must still be added via add() as usual for correct occlusion;
|
||||
// this only affects which *class* each edge's visible portion is later extracted as, via
|
||||
// HLRBRep_HLRToShape::VCompound(S)/OutLineVCompound(S) in hlr_calc, which correlate by the
|
||||
// identity of the original edges within S.
|
||||
void add_classified_edges(const IfcUtil::IfcBaseEntity* product, const std::string& cls, const TopoDS_Shape& edges) {
|
||||
classified_items_.push_back({ product, cls, edges });
|
||||
}
|
||||
|
||||
bool is_obscured_(TopoDS_Shape* sit) {
|
||||
const TopoDS_Shape& s = *sit;
|
||||
|
||||
@@ -510,7 +535,7 @@ namespace {
|
||||
}
|
||||
}
|
||||
|
||||
std::list<std::pair<const IfcUtil::IfcBaseEntity*, TopoDS_Shape>> build() {
|
||||
std::list<std::tuple<const IfcUtil::IfcBaseEntity*, std::string, TopoDS_Shape>> build() {
|
||||
size_t n_included = 0;
|
||||
for (auto it = items_.begin(); it != items_.end(); ++it) {
|
||||
if (!use_prefiltering_ || !is_obscured_(&it->second)) {
|
||||
@@ -522,11 +547,12 @@ namespace {
|
||||
if (use_prefiltering_) {
|
||||
logger_.Notice("SER", 35, "Included " + std::to_string(n_included) + " elements out of " + std::to_string(items_.size()) + " after prefiltering");
|
||||
}
|
||||
|
||||
|
||||
hlr_calc vis(projector_);
|
||||
if (segment_projection_) {
|
||||
vis.set_product_shape(&items_);
|
||||
}
|
||||
vis.set_classified_shapes(&classified_items_);
|
||||
return boost::apply_visitor(vis, engine_);
|
||||
}
|
||||
};
|
||||
@@ -570,6 +596,14 @@ protected:
|
||||
|
||||
int profile_threshold_;
|
||||
|
||||
// SVG edge classification (issue #3668): see classify_edge_from_faces() in SvgSerializer.cpp.
|
||||
double svg_ridge_angle_min_deg_;
|
||||
double svg_valley_angle_min_deg_;
|
||||
bool svg_emit_flush_edges_;
|
||||
bool svg_use_edge_classification_;
|
||||
bool svg_render_crease_edges_;
|
||||
bool svg_render_sharp_edges_;
|
||||
|
||||
IfcParse::IfcFile* file;
|
||||
const IfcUtil::IfcBaseEntity* storey_;
|
||||
std::multimap<drawing_key, path_object, storey_sorter> paths;
|
||||
@@ -623,6 +657,12 @@ public:
|
||||
, mirror_x_(false)
|
||||
, unify_inputs_(false)
|
||||
, profile_threshold_(-1)
|
||||
, svg_ridge_angle_min_deg_(45.)
|
||||
, svg_valley_angle_min_deg_(12.)
|
||||
, svg_emit_flush_edges_(false)
|
||||
, svg_use_edge_classification_(false)
|
||||
, svg_render_crease_edges_(true)
|
||||
, svg_render_sharp_edges_(true)
|
||||
, file(0)
|
||||
, storey_(0)
|
||||
, xcoords_begin(0)
|
||||
@@ -631,7 +671,17 @@ public:
|
||||
, hlr(nullptr)
|
||||
, namespace_prefix_("data-")
|
||||
, subtraction_settings_(ON_SLABS_AT_FLOORPLANS)
|
||||
{}
|
||||
{
|
||||
// ready() only reads geometry_settings() (already valid at this point, since the base
|
||||
// WriteOnlyGeometrySerializer initializer above has run) and has no other side effects,
|
||||
// so it's safe to call here. This is needed because ready() is otherwise only invoked
|
||||
// explicitly by IfcConvert.cpp's CLI driver -- callers that construct this serializer
|
||||
// directly via the Python bindings (e.g. Bonsai's drawing generation, which never calls
|
||||
// a ready()-equivalent because it isn't exposed via SWIG) would otherwise silently keep
|
||||
// every settings::Svg* member at its hardcoded constructor default forever, regardless
|
||||
// of what ifcopenshell.geom.settings().set(...) was actually configured to.
|
||||
ready();
|
||||
}
|
||||
void addXCoordinate(const boost::shared_ptr<util::string_buffer::float_item>& fi) { xcoords.push_back(fi); }
|
||||
void addYCoordinate(const boost::shared_ptr<util::string_buffer::float_item>& fi) { ycoords.push_back(fi); }
|
||||
void addSizeComponent(const boost::shared_ptr<util::string_buffer::float_item>& fi) { radii.push_back(fi); }
|
||||
@@ -641,7 +691,7 @@ public:
|
||||
bool ready();
|
||||
void write(const IfcGeom::TriangulationElement* /*o*/) {}
|
||||
void write(const IfcGeom::BRepElement* o);
|
||||
void write(path_object& p, const TopoDS_Shape& wire, boost::optional<std::vector<double>> dash_array=boost::none);
|
||||
void write(path_object& p, const TopoDS_Shape& wire, boost::optional<std::vector<double>> dash_array=boost::none, boost::optional<std::string> css_class=boost::none);
|
||||
void write(const geometry_data& data);
|
||||
path_object& start_path(const gp_Pln& p, const IfcUtil::IfcBaseEntity* storey, const std::string& id);
|
||||
path_object& start_path(const gp_Pln& p, const std::string& drawing_name, const std::string& id);
|
||||
|
||||
Reference in New Issue
Block a user