mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d38c8ad18 | |||
| 0b7e25a3ef | |||
| d16c283aef | |||
| a0f493b471 | |||
| e389939092 | |||
| 380675e214 | |||
| 3e55c5126c | |||
| 7e3d2f936d | |||
| 0d70812641 | |||
| dd9fa65629 | |||
| eb7324e7fc | |||
| 061bb90d50 | |||
| a8d0ef3437 | |||
| 438c0955f2 | |||
| b9deb9c63d | |||
| e14b3ec8a0 | |||
| c0d2c2ea24 | |||
| 0ce6e94352 | |||
| be55400ec6 | |||
| 6f1737bb58 |
@@ -127,6 +127,7 @@ src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
|
||||
|
||||
# temp files from AI coding tools
|
||||
*.claude
|
||||
CLAUDE.local.md
|
||||
*.py.tmp*
|
||||
*.json.tmp*
|
||||
|
||||
|
||||
@@ -314,8 +314,12 @@ if(WASM_BUILD)
|
||||
else()
|
||||
# @todo review this, shouldn't this be all possible header-only now?
|
||||
# ... or rewritten using C++17 features?
|
||||
# Boost.System has been header-only since 1.69 and its compiled stub library
|
||||
# was dropped in newer Boost, so requesting it as a component makes
|
||||
# find_package fail on Boost 1.70 and up (for example Boost 1.90). It is
|
||||
# still pulled in transitively by thread / iostreams where needed, so do not
|
||||
# request it explicitly.
|
||||
set(BOOST_COMPONENTS
|
||||
system
|
||||
program_options
|
||||
regex
|
||||
thread
|
||||
|
||||
@@ -320,9 +320,11 @@ def loadIfcStore(scene: bpy.types.Scene) -> None:
|
||||
IfcStore.purge()
|
||||
refresh_ui_data()
|
||||
if not tool.Ifc.get():
|
||||
tool.Autosave.cancel_timer()
|
||||
return
|
||||
tool.Ifc.schema()
|
||||
IfcStore.relink_all_objects()
|
||||
tool.Autosave.reset_timer()
|
||||
|
||||
|
||||
@persistent
|
||||
|
||||
@@ -2343,7 +2343,9 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
"Activates the selected drawing view.\n\n"
|
||||
+ "ALT+CLICK to keep the viewport position.\n\n"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
|
||||
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
|
||||
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
|
||||
)
|
||||
|
||||
drawing: bpy.props.IntProperty()
|
||||
@@ -2365,16 +2367,25 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
include_annotations_in_selection: bpy.props.BoolProperty(
|
||||
name="Include Annotations In Selection",
|
||||
description="Also select the loaded annotation objects, not just the drawing cameras.",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
drawing: int
|
||||
should_view_from_camera: bool
|
||||
use_quick_preview: bool
|
||||
load_selected_annotations: bool
|
||||
include_annotations_in_selection: bool
|
||||
|
||||
def invoke(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
|
||||
if event.type == "LEFTMOUSE" and event.shift and event.ctrl:
|
||||
self.load_selected_annotations = True
|
||||
if event.alt:
|
||||
self.include_annotations_in_selection = True
|
||||
return self.execute(context)
|
||||
if event.type == "LEFTMOUSE" and event.alt:
|
||||
self.should_view_from_camera = False
|
||||
@@ -2389,15 +2400,34 @@ class ActivateDrawingBase(tool.Ifc.Operator):
|
||||
bpy.ops.bim.load_drawings()
|
||||
|
||||
if self.load_selected_annotations:
|
||||
objs_to_select = []
|
||||
active_camera = None
|
||||
for d in props.drawings:
|
||||
if not (d.is_drawing and d.is_selected):
|
||||
continue
|
||||
selected_drawing = tool.Ifc.get().by_id(d.ifc_definition_id)
|
||||
# Importing the camera (if missing) ensures the drawing's
|
||||
# collection exists so the annotations get collected into it.
|
||||
if not tool.Ifc.get_object(selected_drawing):
|
||||
tool.Drawing.import_drawing(selected_drawing)
|
||||
tool.Drawing.import_annotations_in_group(tool.Drawing.get_drawing_group(selected_drawing))
|
||||
if not (camera := tool.Ifc.get_object(selected_drawing)):
|
||||
camera = tool.Drawing.import_drawing(selected_drawing)
|
||||
group = tool.Drawing.get_drawing_group(selected_drawing)
|
||||
tool.Drawing.import_annotations_in_group(group)
|
||||
|
||||
if active_camera is None:
|
||||
active_camera = camera
|
||||
objs_to_select.append(camera)
|
||||
if self.include_annotations_in_selection:
|
||||
for element in tool.Drawing.get_group_elements(group) or []:
|
||||
if element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING":
|
||||
if annotation_obj := tool.Ifc.get_object(element):
|
||||
objs_to_select.append(annotation_obj)
|
||||
|
||||
# Select the checked drawings' objects, with the first drawing's camera as active.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in objs_to_select:
|
||||
obj.select_set(True)
|
||||
if active_camera is not None:
|
||||
context.view_layer.objects.active = active_camera
|
||||
return {"FINISHED"}
|
||||
|
||||
drawing = tool.Ifc.get().by_id(self.drawing)
|
||||
@@ -2486,7 +2516,9 @@ class ActivateDrawing(bpy.types.Operator, ActivateDrawingBase):
|
||||
"Activates the selected drawing view.\n\n"
|
||||
+ "ALT+CLICK to keep the viewport position.\n\n"
|
||||
+ "SHIFT+CLICK to load a quick preview of the drawing view.\n\n"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views"
|
||||
+ "SHIFT+CTRL+CLICK to load the annotations of all selected drawings without switching views, "
|
||||
+ "then select their cameras (the first selected drawing's camera becomes active).\n\n"
|
||||
+ "SHIFT+CTRL+ALT+CLICK to do the same but also select the annotations, not just the cameras"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
from . import decorator, gizmo, operator, prop, ui, workspace
|
||||
|
||||
classes = (
|
||||
@@ -58,6 +60,8 @@ classes = (
|
||||
operator.LinkIfc,
|
||||
operator.LoadBlendMetadataAndIFC,
|
||||
operator.LoadLink,
|
||||
operator.AutosavePrompt,
|
||||
operator.LoadAutosavedRecoveryPopup,
|
||||
operator.LoadLinkedProject,
|
||||
operator.LoadProject,
|
||||
operator.LoadProjectElements,
|
||||
@@ -136,6 +140,7 @@ def register():
|
||||
def unregister():
|
||||
if not bpy.app.background:
|
||||
bpy.utils.unregister_tool(workspace.ExploreTool)
|
||||
tool.Autosave.cancel_timer()
|
||||
del bpy.types.Scene.BIMProjectProperties
|
||||
del bpy.types.Scene.MeasureToolSettings
|
||||
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
|
||||
|
||||
@@ -985,8 +985,10 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
skip_autosave_recovery: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
|
||||
filename_ext = ".ifc"
|
||||
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filepath: str
|
||||
@@ -995,6 +997,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
use_relative_path: bool
|
||||
should_start_fresh_session: bool
|
||||
import_without_ifc_data: bool
|
||||
skip_autosave_recovery: bool
|
||||
use_detailed_tooltip: bool
|
||||
|
||||
@classmethod
|
||||
@@ -1041,7 +1044,26 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
|
||||
return tooltip
|
||||
|
||||
def check_autosave_recovery(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"] | None:
|
||||
if self.skip_autosave_recovery:
|
||||
return None
|
||||
autosaved_filepath = tool.Autosave.get_newer_autosaved_path(self.get_filepath_abs())
|
||||
if not autosaved_filepath:
|
||||
return None
|
||||
return bpy.ops.bim.load_autosaved_recovery_popup(
|
||||
"INVOKE_DEFAULT",
|
||||
original_filepath=str(self.get_filepath_abs()),
|
||||
autosaved_filepath=autosaved_filepath,
|
||||
is_advanced=self.is_advanced,
|
||||
use_relative_path=self.use_relative_path,
|
||||
should_start_fresh_session=self.should_start_fresh_session,
|
||||
import_without_ifc_data=self.import_without_ifc_data,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
if recovery := self.check_autosave_recovery(context):
|
||||
return recovery
|
||||
|
||||
if (
|
||||
tool.Blender.get_addon_preferences().save_metadata_blend_file
|
||||
and self.should_start_fresh_session
|
||||
@@ -1136,7 +1158,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
props.should_save_metadata_for_this_file = metadata_doc is not None
|
||||
|
||||
tool.Blender.register_toolbar()
|
||||
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
|
||||
if not self.skip_recent:
|
||||
tool.Project.add_recent_ifc_project(self.get_filepath_abs())
|
||||
|
||||
if self.is_advanced:
|
||||
pass
|
||||
@@ -1149,10 +1172,13 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
|
||||
except:
|
||||
bonsai.last_error = traceback.format_exc()
|
||||
raise
|
||||
tool.Autosave.reset_timer()
|
||||
return {"FINISHED"}
|
||||
|
||||
def invoke(self, context, event):
|
||||
if self.filepath:
|
||||
if recovery := self.check_autosave_recovery(context):
|
||||
return recovery
|
||||
return self.execute(context)
|
||||
return ImportHelper.invoke(self, context, event)
|
||||
|
||||
@@ -1947,6 +1973,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
|
||||
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
|
||||
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
|
||||
skip_recent: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
filter_glob: str
|
||||
@@ -2007,6 +2034,18 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
return {"FINISHED"}
|
||||
|
||||
def _execute(self, context):
|
||||
project_props = tool.Project.get_project_props()
|
||||
project_props.use_relative_project_path = self.use_relative_path
|
||||
|
||||
# Fallback if filepath is not set
|
||||
if not getattr(self, "filepath", None) or self.filepath.strip() in ("", ".ifc"):
|
||||
props = tool.Blender.get_bim_props()
|
||||
if props.ifc_file:
|
||||
self.filepath = str(tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file)))
|
||||
else:
|
||||
self.report({"ERROR"}, "No filepath available for saving.")
|
||||
return {"CANCELLED"}
|
||||
|
||||
committed, failed_commits = tool.Parametric.commit_pending_edits()
|
||||
# Previews are session-transient — discard rather than commit. Sibling
|
||||
# gizmo polls gate on each preview's is_active flag, and a stuck flag
|
||||
@@ -2069,7 +2108,8 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
settings.logger.info("Export finished in {:.2f} seconds".format(time.time() - start))
|
||||
print("Export finished in {:.2f} seconds".format(time.time() - start))
|
||||
# New project created in Bonsai should be in recent projects too.
|
||||
tool.Project.add_recent_ifc_project(Path(output_file))
|
||||
if not self.skip_recent:
|
||||
tool.Project.add_recent_ifc_project(Path(output_file))
|
||||
props = tool.Project.get_project_props()
|
||||
if props.use_relative_project_path and bpy.data.is_saved:
|
||||
output_file = os.path.relpath(output_file, bpy.path.abspath("//"))
|
||||
@@ -2103,6 +2143,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
)
|
||||
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
tool.Autosave.reset_timer()
|
||||
|
||||
@classmethod
|
||||
def description(cls, context, properties):
|
||||
@@ -2111,6 +2152,97 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
return "Save the IFC file. Will save both .IFC/.BLEND files if synced together"
|
||||
|
||||
|
||||
class LoadAutosavedRecoveryPopup(bpy.types.Operator):
|
||||
bl_idname = "bim.load_autosaved_recovery_popup"
|
||||
bl_label = "Recover Autosaved File"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
original_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
|
||||
autosaved_filepath: bpy.props.StringProperty(options={"SKIP_SAVE"})
|
||||
is_advanced: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
use_relative_path: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
should_start_fresh_session: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"})
|
||||
import_without_ifc_data: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="A newer autosaved copy was found:", icon="INFO")
|
||||
layout.label(text=os.path.basename(self.autosaved_filepath))
|
||||
layout.separator()
|
||||
layout.label(text="Do you want to load the autosaved version instead?")
|
||||
layout.label(text="(Cancel will load the original)")
|
||||
|
||||
def invoke(self, context, event):
|
||||
# invoke_props_dialog is modal - unlike invoke_popup/popup_menu, it
|
||||
# isn't dismissed by the mouse simply leaving its bounds. It always
|
||||
# renders both a fixed "Cancel" button and this confirm_text one, so
|
||||
# the question is framed as Yes/Cancel rather than adding separate
|
||||
# Load buttons on top.
|
||||
return context.window_manager.invoke_props_dialog(
|
||||
self, width=420, title="Recover Autosaved File", confirm_text="Yes"
|
||||
)
|
||||
|
||||
def _load(self, filepath: str, skip_recent: bool) -> set["rna_enums.OperatorReturnItems"]:
|
||||
return bpy.ops.bim.load_project(
|
||||
filepath=filepath,
|
||||
skip_autosave_recovery=True, # Prevent infinite loop
|
||||
is_advanced=self.is_advanced,
|
||||
use_relative_path=self.use_relative_path,
|
||||
should_start_fresh_session=self.should_start_fresh_session,
|
||||
import_without_ifc_data=self.import_without_ifc_data,
|
||||
skip_recent=skip_recent,
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
result = self._load(self.autosaved_filepath, skip_recent=True)
|
||||
# Re-point tracking at the original path so future saves write back
|
||||
# to it, not "_autosaved.ifc".
|
||||
tool.Ifc.set_path(self.original_filepath)
|
||||
return result
|
||||
|
||||
def cancel(self, context):
|
||||
# Also reached via Escape or a click outside the dialog, not just Cancel.
|
||||
self._load(self.original_filepath, skip_recent=False)
|
||||
|
||||
|
||||
class AutosavePrompt(bpy.types.Operator):
|
||||
bl_idname = "bim.autosave_prompt"
|
||||
bl_label = "Autosave Reminder"
|
||||
bl_options = set()
|
||||
|
||||
def invoke(self, context, event):
|
||||
return context.window_manager.invoke_props_dialog(
|
||||
self, width=400, confirm_text="Save", title="Autosave Reminder"
|
||||
)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.label(text="The autosave timer has expired.", icon="INFO")
|
||||
layout.label(text="Would you like to save your IFC project now?")
|
||||
|
||||
def execute(self, context):
|
||||
# Get current IFC path
|
||||
props = tool.Blender.get_bim_props()
|
||||
current_ifc_path = props.ifc_file
|
||||
|
||||
if not current_ifc_path:
|
||||
self.report({"WARNING"}, "No IFC file path set. Please save manually.")
|
||||
tool.Autosave.reset_timer()
|
||||
return {"CANCELLED"}
|
||||
|
||||
# Call save_project with explicit filepath using EXEC_DEFAULT
|
||||
result = bpy.ops.bim.save_project(
|
||||
"EXEC_DEFAULT", filepath=current_ifc_path, should_save_as=False, skip_recent=True
|
||||
)
|
||||
|
||||
tool.Autosave.reset_timer()
|
||||
return result
|
||||
|
||||
def cancel(self, context):
|
||||
tool.Autosave.reset_timer()
|
||||
return {"CANCELLED"}
|
||||
|
||||
|
||||
class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
bl_idname = "bim.load_linked_project"
|
||||
bl_label = "Load Project For Viewing Only"
|
||||
|
||||
@@ -577,6 +577,43 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
should_disable_undo_on_save: BoolProperty(
|
||||
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
|
||||
)
|
||||
|
||||
def update_autosave_settings(self, context: bpy.types.Context) -> None:
|
||||
if self.autosave_enabled:
|
||||
tool.Autosave.reset_timer()
|
||||
else:
|
||||
tool.Autosave.cancel_timer()
|
||||
|
||||
autosave_enabled: BoolProperty(
|
||||
name="Enable IFC Autosave Timer",
|
||||
description="Periodically remind you to save or automatically create a backup copy of the IFC file",
|
||||
default=False,
|
||||
update=update_autosave_settings,
|
||||
)
|
||||
autosave_interval_minutes: bpy.props.IntProperty(
|
||||
name="Autosave Interval (Minutes)",
|
||||
description="Time between autosave reminders or backups. The timer resets whenever you open or save a project",
|
||||
default=10,
|
||||
min=1,
|
||||
max=1440,
|
||||
update=update_autosave_settings,
|
||||
)
|
||||
autosave_mode: bpy.props.EnumProperty(
|
||||
name="Autosave Mode",
|
||||
items=[
|
||||
(
|
||||
"PROMPT",
|
||||
"Prompt to Save",
|
||||
"Show a dialog offering to save the IFC project when the timer expires",
|
||||
),
|
||||
(
|
||||
"BACKUP",
|
||||
"Automatic Backup",
|
||||
"Save a backup copy as filename_autosaved.ifc when the timer expires",
|
||||
),
|
||||
],
|
||||
default="PROMPT",
|
||||
)
|
||||
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
|
||||
should_always_cache: BoolProperty(
|
||||
name="Always Cache Geometry",
|
||||
@@ -689,6 +726,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
bsdd_load_test_dictionaries: bool
|
||||
bsdd_baseurl: str
|
||||
should_disable_undo_on_save: bool
|
||||
autosave_enabled: bool
|
||||
autosave_interval_minutes: int
|
||||
autosave_mode: Literal["PROMPT", "BACKUP"]
|
||||
should_stream: bool
|
||||
should_always_cache: bool
|
||||
occurrence_name_style: Literal["CLASS", "TYPE", "CUSTOM"]
|
||||
@@ -837,6 +877,12 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
|
||||
def draw_other_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None:
|
||||
layout.prop(self, "opening_focus_opacity")
|
||||
layout.prop(self, "should_disable_undo_on_save")
|
||||
layout.separator()
|
||||
layout.label(text="Autosave:")
|
||||
layout.prop(self, "autosave_enabled")
|
||||
if self.autosave_enabled:
|
||||
layout.prop(self, "autosave_interval_minutes")
|
||||
layout.prop(self, "autosave_mode")
|
||||
layout.prop(self, "should_stream")
|
||||
layout.prop(self, "should_always_cache")
|
||||
layout.label(text="bSDD:")
|
||||
|
||||
@@ -80,3 +80,6 @@ from bonsai.tool.type import Type
|
||||
from bonsai.tool.unit import Unit
|
||||
from bonsai.tool.wall import Wall
|
||||
from bonsai.tool.web import Web
|
||||
|
||||
# Have to move after import of tool.drawing
|
||||
from bonsai.tool.autosave import Autosave # isort: skip
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import bpy
|
||||
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import export_ifc
|
||||
from bonsai.bim.module.model import preview_base
|
||||
|
||||
AUTOSAVING_SUFFIX = "_autosaving.ifc"
|
||||
AUTOSAVED_SUFFIX = "_autosaved.ifc"
|
||||
|
||||
_timer_callback: Union[Callable[[], None], None] = None
|
||||
# See cleanup_stale_autosave() for why this is a cached plain string rather
|
||||
# than looked up live.
|
||||
_active_ifc_path_cache: Union[str, None] = None
|
||||
|
||||
|
||||
class Autosave:
|
||||
@classmethod
|
||||
def get_paths(cls, ifc_path: Union[str, Path]) -> tuple[Path, Path, Path]:
|
||||
path = Path(ifc_path)
|
||||
stem = path.stem if path.suffix.lower() == ".ifc" else path.name
|
||||
parent = path.parent
|
||||
main_path = path if path.suffix.lower() == ".ifc" else parent / f"{stem}.ifc"
|
||||
autosaving_path = parent / f"{stem}{AUTOSAVING_SUFFIX}"
|
||||
autosaved_path = parent / f"{stem}{AUTOSAVED_SUFFIX}"
|
||||
return main_path, autosaving_path, autosaved_path
|
||||
|
||||
@classmethod
|
||||
def get_active_ifc_path(cls) -> Union[Path, None]:
|
||||
props = tool.Blender.get_bim_props()
|
||||
if not props.ifc_file:
|
||||
return None
|
||||
path = tool.Blender.ensure_blender_path_is_abs(Path(props.ifc_file))
|
||||
if path.suffix.lower() != ".ifc":
|
||||
return None
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
def _update_active_ifc_path_cache(cls) -> None:
|
||||
global _active_ifc_path_cache
|
||||
ifc_path = cls.get_active_ifc_path()
|
||||
_active_ifc_path_cache = ifc_path.as_posix() if ifc_path is not None else None
|
||||
|
||||
@classmethod
|
||||
def is_enabled(cls) -> bool:
|
||||
return bool(tool.Blender.get_addon_preferences().autosave_enabled)
|
||||
|
||||
@classmethod
|
||||
def get_interval_seconds(cls) -> float:
|
||||
minutes = tool.Blender.get_addon_preferences().autosave_interval_minutes
|
||||
return max(1.0, float(minutes) * 60.0)
|
||||
|
||||
@classmethod
|
||||
def is_eligible(cls) -> bool:
|
||||
return cls.is_enabled() and tool.Ifc.get() is not None and cls.get_active_ifc_path() is not None
|
||||
|
||||
@classmethod
|
||||
def cancel_timer(cls) -> None:
|
||||
global _timer_callback
|
||||
if _timer_callback is not None and bpy.app.timers.is_registered(_timer_callback):
|
||||
bpy.app.timers.unregister(_timer_callback)
|
||||
_timer_callback = None
|
||||
|
||||
@classmethod
|
||||
def reset_timer(cls) -> None:
|
||||
cls.cancel_timer()
|
||||
cls._update_active_ifc_path_cache()
|
||||
if not cls.is_eligible():
|
||||
return
|
||||
|
||||
def on_timer() -> None:
|
||||
cls._on_timer_expired()
|
||||
return None
|
||||
|
||||
global _timer_callback
|
||||
_timer_callback = on_timer
|
||||
bpy.app.timers.register(on_timer, first_interval=cls.get_interval_seconds())
|
||||
|
||||
@classmethod
|
||||
def _on_timer_expired(cls) -> None:
|
||||
if not cls.is_eligible():
|
||||
return
|
||||
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
|
||||
if bim_props.is_dirty:
|
||||
if prefs.autosave_mode == "PROMPT":
|
||||
bpy.ops.bim.autosave_prompt("INVOKE_DEFAULT")
|
||||
elif prefs.autosave_mode == "BACKUP":
|
||||
try:
|
||||
cls.perform_backup(bpy.context)
|
||||
except Exception as error:
|
||||
print(f"Bonsai: autosave backup failed: {error}")
|
||||
cls.reset_timer()
|
||||
|
||||
@classmethod
|
||||
def perform_backup(cls, context: bpy.types.Context) -> None:
|
||||
ifc_path = cls.get_active_ifc_path()
|
||||
if ifc_path is None:
|
||||
return
|
||||
|
||||
_, autosaving_path, autosaved_path = cls.get_paths(ifc_path)
|
||||
autosaving_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tool.Parametric.commit_pending_edits()
|
||||
preview_base.discard_pending_previews(context.scene)
|
||||
|
||||
logger = logging.getLogger("ExportIFC")
|
||||
output_file = autosaving_path.as_posix().replace("\\", "/")
|
||||
settings = export_ifc.IfcExportSettings.factory(context, output_file, logger)
|
||||
export_ifc.IfcExporter(settings).export()
|
||||
|
||||
try:
|
||||
os.replace(autosaving_path, autosaved_path)
|
||||
except OSError:
|
||||
if autosaving_path.is_file():
|
||||
autosaving_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def get_newer_autosaved_path(cls, ifc_path: Union[str, Path]) -> Union[str, None]:
|
||||
path = Path(ifc_path)
|
||||
if path.suffix.lower() != ".ifc" or not path.is_file():
|
||||
return None
|
||||
|
||||
_, _, autosaved_path = cls.get_paths(path)
|
||||
if not autosaved_path.is_file():
|
||||
return None
|
||||
if autosaved_path.stat().st_mtime > path.stat().st_mtime:
|
||||
return autosaved_path.as_posix().replace("\\", "/")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def cleanup_stale_autosave(cls) -> None:
|
||||
"""Remove the active IFC's autosave file(s) on a graceful shutdown.
|
||||
|
||||
Registered via `atexit`, which only runs on a normal interpreter
|
||||
shutdown - never on an actual crash. So a deliberate quit (whether
|
||||
the user saved or chose "don't save") clears the recovery file and
|
||||
won't prompt on next startup, while a genuine crash leaves it in
|
||||
place for recovery, since no atexit callbacks fire then.
|
||||
|
||||
Deliberately reads only `_active_ifc_path_cache` - a plain string
|
||||
kept up to date by `reset_timer()` - rather than touching `bpy` here.
|
||||
By the time `atexit` fires, Blender's own C++ side is torn down far
|
||||
enough that even reading `bpy.context.scene` aborts the process
|
||||
(std::bad_optional_access) instead of raising a catchable exception.
|
||||
"""
|
||||
if _active_ifc_path_cache is None:
|
||||
return
|
||||
try:
|
||||
_, autosaving_path, autosaved_path = cls.get_paths(_active_ifc_path_cache)
|
||||
autosaving_path.unlink(missing_ok=True)
|
||||
autosaved_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(Autosave.cleanup_stale_autosave)
|
||||
@@ -1258,19 +1258,20 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
|
||||
@classmethod
|
||||
def toggle_hide_spaces(cls, spaces: list[ifcopenshell.entity_instance]) -> None:
|
||||
first_obj = tool.Ifc.get_object(spaces[0])
|
||||
assert isinstance(first_obj, bpy.types.Object)
|
||||
obj: bpy.types.Object
|
||||
if first_obj.hide_get() == False:
|
||||
for space in spaces:
|
||||
obj = tool.Ifc.get_object(space)
|
||||
obj.hide_set(True)
|
||||
# `hide_get`/`hide_set` raise for objects that are not in the active view
|
||||
# layer (e.g. spaces living in an excluded collection), so skip those.
|
||||
view_layer = bpy.context.view_layer
|
||||
objs = [
|
||||
obj
|
||||
for space in spaces
|
||||
if isinstance(obj := tool.Ifc.get_object(space), bpy.types.Object) and view_layer.objects.get(obj.name)
|
||||
]
|
||||
if not objs:
|
||||
return
|
||||
|
||||
elif first_obj.hide_get() == True:
|
||||
for space in spaces:
|
||||
obj = tool.Ifc.get_object(space)
|
||||
obj.hide_set(False)
|
||||
should_hide = objs[0].hide_get() == False
|
||||
for obj in objs:
|
||||
obj.hide_set(should_hide)
|
||||
|
||||
@classmethod
|
||||
def set_default_container(cls, container: ifcopenshell.entity_instance) -> None:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bonsai.tool.autosave import AUTOSAVED_SUFFIX, AUTOSAVING_SUFFIX, Autosave
|
||||
|
||||
pytestmark = pytest.mark.project
|
||||
|
||||
|
||||
class TestAutosavePaths:
|
||||
def test_get_paths_for_ifc_file(self):
|
||||
main_path, autosaving_path, autosaved_path = Autosave.get_paths("/tmp/myfile.ifc")
|
||||
assert main_path == Path("/tmp/myfile.ifc")
|
||||
assert autosaving_path == Path(f"/tmp/myfile{AUTOSAVING_SUFFIX}")
|
||||
assert autosaved_path == Path(f"/tmp/myfile{AUTOSAVED_SUFFIX}")
|
||||
|
||||
def test_get_newer_autosaved_path_when_missing(self, tmp_path):
|
||||
ifc_path = tmp_path / "myfile.ifc"
|
||||
ifc_path.write_text("ifc")
|
||||
assert Autosave.get_newer_autosaved_path(ifc_path) is None
|
||||
|
||||
def test_get_newer_autosaved_path_when_older(self, tmp_path):
|
||||
ifc_path = tmp_path / "myfile.ifc"
|
||||
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
|
||||
ifc_path.write_text("ifc")
|
||||
autosaved_path.write_text("autosaved")
|
||||
past = time.time() - 10
|
||||
os.utime(ifc_path, (past, past))
|
||||
os.utime(autosaved_path, (time.time(), time.time()))
|
||||
assert Autosave.get_newer_autosaved_path(ifc_path) == autosaved_path.as_posix()
|
||||
|
||||
def test_get_newer_autosaved_path_when_not_newer(self, tmp_path):
|
||||
ifc_path = tmp_path / "myfile.ifc"
|
||||
autosaved_path = tmp_path / f"myfile{AUTOSAVED_SUFFIX}"
|
||||
ifc_path.write_text("ifc")
|
||||
autosaved_path.write_text("autosaved")
|
||||
now = time.time()
|
||||
os.utime(ifc_path, (now, now))
|
||||
past = now - 10
|
||||
os.utime(autosaved_path, (past, past))
|
||||
assert Autosave.get_newer_autosaved_path(ifc_path) is None
|
||||
|
||||
def test_get_newer_autosaved_path_ignores_non_ifc(self, tmp_path):
|
||||
path = tmp_path / "myfile.ifczip"
|
||||
path.write_text("zip")
|
||||
assert Autosave.get_newer_autosaved_path(path) is None
|
||||
@@ -252,6 +252,10 @@ int main(int argc, char** argv) {
|
||||
("stderr-progress", "output progress to stderr stream")
|
||||
("yes,y", "answer 'yes' automatically to possible confirmation queries (e.g. overwriting an existing output file)")
|
||||
("no-progress", "suppress possible progress bar type of prints that use carriage return")
|
||||
("fail-on-error", "return a non-zero exit code when one or more errors were logged during "
|
||||
"geometry conversion (e.g. an element failed to convert). By default IfcConvert exits "
|
||||
"successfully as long as an output file could be written, even if some elements were "
|
||||
"silently dropped. Enable this flag so scripts and CI can detect partial conversions.")
|
||||
("log-format", po::value<std::string>(&log_format), "log format: plain or json")
|
||||
("log-file", new po::typed_value<path_t, char_t>(&log_file), "redirect log output to file");
|
||||
|
||||
@@ -449,6 +453,7 @@ int main(int argc, char** argv) {
|
||||
|
||||
const bool mmap = vmap.count("mmap") != 0;
|
||||
const bool no_progress = vmap.count("no-progress") != 0;
|
||||
const bool fail_on_error = vmap.count("fail-on-error") != 0;
|
||||
const bool quiet = vmap.count("quiet") != 0;
|
||||
const bool stderr_progress = vmap.count("stderr-progress") != 0;
|
||||
|
||||
@@ -885,6 +890,7 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
|
||||
if (!serializer->ready()) {
|
||||
logger.Error("SYS", 25, "Unable to open output file '" + IfcUtil::path::to_utf8(output_filename) + "' for writing; check that the directory exists and is writable");
|
||||
IfcUtil::path::delete_file(IfcUtil::path::to_utf8(output_temp_filename));
|
||||
write_log(!quiet);
|
||||
return EXIT_FAILURE;
|
||||
@@ -1220,6 +1226,11 @@ int main(int argc, char** argv) {
|
||||
successful = false;
|
||||
}
|
||||
|
||||
if (fail_on_error && logger.MaxSeverity() >= Logger::LOG_ERROR) {
|
||||
logger.Error("SYS", 26, "Errors encountered during processing, failing due to --fail-on-error.");
|
||||
successful = false;
|
||||
}
|
||||
|
||||
if (logger.Verbosity() == Logger::LOG_PERF) {
|
||||
logger.PrintPerformanceStats();
|
||||
}
|
||||
|
||||
@@ -361,8 +361,8 @@ namespace ifcopenshell {
|
||||
|
||||
struct CircleSegments : public SettingBase<CircleSegments, int> {
|
||||
static constexpr const char* const name = "circle-segments";
|
||||
static constexpr const char* const description = "Number of segments to approximate full circles in CGAL kernel.";
|
||||
static constexpr int defaultvalue = 16;
|
||||
static constexpr const char* const description = "Number of segments to approximate full circles in the CGAL kernel. When 0 (the default) the segment count is derived from mesher-linear-deflection instead, so curves stay within the deflection tolerance regardless of radius.";
|
||||
static constexpr int defaultvalue = 0;
|
||||
};
|
||||
|
||||
struct CgalSmoothAngleDegrees : public SettingBase<CgalSmoothAngleDegrees, double> {
|
||||
|
||||
@@ -391,6 +391,11 @@ namespace {
|
||||
}
|
||||
};
|
||||
|
||||
// Representative radius used to size the polygonal approximation of a conic.
|
||||
// For an ellipse the larger semi-axis is the conservative choice.
|
||||
inline double conic_radius(const taxonomy::circle::ptr& c) { return c->radius; }
|
||||
inline double conic_radius(const taxonomy::ellipse::ptr& e) { return e->radius > e->radius2 ? e->radius : e->radius2; }
|
||||
|
||||
struct cgal_curve_creation_visitor {
|
||||
Settings& settings_;
|
||||
parameter_range param;
|
||||
@@ -425,7 +430,36 @@ namespace {
|
||||
if (b <= a) {
|
||||
b += 2 * M_PI;
|
||||
}
|
||||
int num_segments = (int)std::ceil(std::fabs(a - b) / (2 * M_PI) * settings_.get<settings::CircleSegments>().get());
|
||||
const double span = std::fabs(a - b);
|
||||
// CircleSegments controls how conics (circles, ellipses, arcs) are approximated
|
||||
// in the CGAL kernel. Two modes, one or the other:
|
||||
// - CircleSegments == 0 (the default): the segment count is derived from
|
||||
// MesherLinearDeflection, so the chord deviation stays within the mesher's
|
||||
// linear deflection regardless of radius. This matches the deflection based
|
||||
// meshing the OpenCascade kernel already does and fixes issue #8051, where
|
||||
// large radius arcs (curved curtain wall mullions) collapsed to straight chords
|
||||
// because a fixed segment count is radius agnostic.
|
||||
// - CircleSegments > 0: it is used directly as the number of segments for a full
|
||||
// circle, giving deterministic, radius independent output.
|
||||
int num_segments;
|
||||
const int circle_segments = settings_.get<settings::CircleSegments>().get();
|
||||
if (circle_segments > 0) {
|
||||
num_segments = (int)std::ceil(span / (2 * M_PI) * circle_segments);
|
||||
} else {
|
||||
const double radius = conic_radius(t);
|
||||
const double deflection = settings_.get<settings::MesherLinearDeflection>().get();
|
||||
if (deflection > 0. && radius > deflection) {
|
||||
const double max_segment_angle = 2.0 * std::acos(1.0 - deflection / radius);
|
||||
num_segments = (int)std::ceil(span / max_segment_angle);
|
||||
} else {
|
||||
// Radius within the deflection tolerance (or no deflection set): a chord per
|
||||
// quarter turn already keeps the deviation within tolerance.
|
||||
num_segments = (int)std::ceil(span / (M_PI / 2.));
|
||||
}
|
||||
}
|
||||
if (num_segments < 1) {
|
||||
num_segments = 1;
|
||||
}
|
||||
double du = (b - a) / num_segments;
|
||||
taxonomy::point3 P;
|
||||
// @nb for loop is not inclusive of the both end points
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include <ShapeFix_Shape.hxx>
|
||||
#include <ShapeFix_ShapeTolerance.hxx>
|
||||
#include <BRep_Tool.hxx>
|
||||
#include <BRepExtrema_DistShapeShape.hxx>
|
||||
|
||||
#include <Standard_Macro.hxx>
|
||||
#include <TopoDS_Shape.hxx>
|
||||
@@ -356,6 +357,27 @@ bool OpenCascadeKernel::convert(const taxonomy::face::ptr face, TopoDS_Shape& re
|
||||
return false;
|
||||
}
|
||||
|
||||
// #527: A face whose inner boundary intersects the outer boundary (or
|
||||
// another inner boundary) is invalid per the schema. Open Cascade heals or
|
||||
// drops such a face silently, so the intended hole is lost with no
|
||||
// diagnostic. The distance between two non-intersecting loops is strictly
|
||||
// positive; a distance at (or below) the modelling precision means the
|
||||
// boundaries touch or cross. Emit a clear warning so the invalid input is
|
||||
// not silently lost. wires() is ordered outer-first, inner-bounds after.
|
||||
if (fd.wires().size() > 1) {
|
||||
const auto& fwires = fd.wires();
|
||||
bool reported = false;
|
||||
for (size_t i = 1; i < fwires.size() && !reported; ++i) {
|
||||
for (size_t j = 0; j < i && !reported; ++j) {
|
||||
BRepExtrema_DistShapeShape dss(fwires[i], fwires[j]);
|
||||
if (dss.IsDone() && dss.Value() < precision_) {
|
||||
logger().Warning("GEO", 402, "Face inner boundary intersects another face boundary", face->instance);
|
||||
reported = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fd.surface().IsNull()) {
|
||||
// Use the first wire to find a plane manually for polygonal wires
|
||||
const TopoDS_Wire& wire = fd.wires().front();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
/********************************************************************************
|
||||
* *
|
||||
* This file is part of IfcOpenShell. *
|
||||
* *
|
||||
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the Lesser GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* Lesser GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the Lesser GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
#include "mapping.h"
|
||||
#define mapping POSTFIX_SCHEMA(mapping)
|
||||
using namespace ifcopenshell::geometry;
|
||||
|
||||
#include "../profile_helper.h"
|
||||
|
||||
// In IFC2X3 IfcAsymmetricIShapeProfileDef is a subtype of IfcIShapeProfileDef and is
|
||||
// therefore dispatched (and handled) by the IfcIShapeProfileDef mapping. From IFC4
|
||||
// onwards it is a standalone subtype of IfcParameterizedProfileDef with its own
|
||||
// Bottom*/Top* attributes, so nothing mapped it and the extrusion came out empty.
|
||||
// The presence of the standalone BottomFlangeWidth attribute is the discriminator:
|
||||
// it is only defined in the schemas where the type is standalone (IFC4 / IFC4X3).
|
||||
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeWidth
|
||||
|
||||
taxonomy::ptr mapping::map_impl(const IfcSchema::IfcAsymmetricIShapeProfileDef* inst) {
|
||||
// Bottom flange (half width), overall depth (half), web (half thickness).
|
||||
const double xb = inst->BottomFlangeWidth() / 2.0 * length_unit_;
|
||||
const double xt = inst->TopFlangeWidth() / 2.0 * length_unit_;
|
||||
const double y = inst->OverallDepth() / 2.0 * length_unit_;
|
||||
const double d1 = inst->WebThickness() / 2.0 * length_unit_;
|
||||
|
||||
// Bottom flange thickness; top flange thickness defaults to the bottom one.
|
||||
const double ftb = inst->BottomFlangeThickness() * length_unit_;
|
||||
const double ftt = inst->TopFlangeThickness().get_value_or(inst->BottomFlangeThickness()) * length_unit_;
|
||||
|
||||
// Optional fillet radii (web/flange transition) and flange edge radii.
|
||||
const double fb = inst->BottomFlangeFilletRadius().get_value_or(0.) * length_unit_;
|
||||
const double ft_top = inst->TopFlangeFilletRadius().get_value_or(0.) * length_unit_;
|
||||
const double feb = inst->BottomFlangeEdgeRadius().get_value_or(0.) * length_unit_;
|
||||
const double fet = inst->TopFlangeEdgeRadius().get_value_or(0.) * length_unit_;
|
||||
|
||||
// Optional flange slopes: the inner edge of the flange rises towards the web.
|
||||
const double bottomSlope = inst->BottomFlangeSlope().get_value_or(0.) * angle_unit_;
|
||||
const double topSlope = inst->TopFlangeSlope().get_value_or(0.) * angle_unit_;
|
||||
const double dyb = (xb - d1) * tan(bottomSlope);
|
||||
const double dyt = (xt - d1) * tan(topSlope);
|
||||
|
||||
const double tol = settings_.get<settings::Precision>().get();
|
||||
|
||||
if (xb < tol || xt < tol || y < tol || d1 < tol || ftb < tol || ftt < tol) {
|
||||
logger_.Message(Logger::LOG_NOTICE, "GEO", 264, "Skipping zero sized profile:", inst);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
taxonomy::matrix4::ptr m4;
|
||||
bool has_position = true;
|
||||
#ifdef SCHEMA_IfcParameterizedProfileDef_Position_IS_OPTIONAL
|
||||
has_position = !!inst->Position();
|
||||
#endif
|
||||
if (has_position) {
|
||||
m4 = taxonomy::cast<taxonomy::matrix4>(map(inst->Position()));
|
||||
}
|
||||
|
||||
// Twelve corner points, running counter-clockwise from the bottom-left, with the
|
||||
// bottom flange (xb) possibly wider than the top flange (xt). Fillet/edge radii are
|
||||
// attached to the corner they round, matching the symmetric IfcIShapeProfileDef.
|
||||
return profile_helper(m4, {
|
||||
{{-xb,-y}},
|
||||
{{xb,-y}},
|
||||
{{xb,-y + ftb}, {feb}},
|
||||
{{d1,-y + ftb + dyb},{fb} },
|
||||
{{d1,y - ftt - dyt},{ft_top} },
|
||||
{{xt,y - ftt}, {fet}},
|
||||
{{xt,y}},
|
||||
{{-xt,y}},
|
||||
{{-xt,y - ftt}, {fet}},
|
||||
{{-d1,y - ftt - dyt},{ft_top} },
|
||||
{{-d1,-y + ftb + dyb},{fb} },
|
||||
{{-xb,-y + ftb}, {feb}}
|
||||
});
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -39,8 +39,25 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
|
||||
|
||||
int max_index = (int)points.size();
|
||||
|
||||
// When the optional PnIndex is present, CoordIndex values do not index into
|
||||
// CoordList directly but into PnIndex, which in turn remaps to CoordList.
|
||||
// Both index levels are 1-based per the IFC specification.
|
||||
auto pn_index = inst->PnIndex();
|
||||
auto resolve = [&](int idx) -> const taxonomy::point3::ptr& {
|
||||
if (pn_index) {
|
||||
if (idx < 1 || idx > (int)pn_index->size()) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet PnIndex out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
idx = (*pn_index)[idx - 1];
|
||||
}
|
||||
if (idx < 1 || idx > max_index) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
return points[idx - 1];
|
||||
};
|
||||
|
||||
auto shell = taxonomy::make<taxonomy::shell>();
|
||||
|
||||
|
||||
for (auto& f : *polygonal_faces) {
|
||||
auto fa = taxonomy::make<taxonomy::face>();
|
||||
shell->children.push_back(fa);
|
||||
@@ -52,17 +69,14 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
|
||||
auto indices = f->CoordIndex();
|
||||
taxonomy::point3::ptr previous;
|
||||
for (std::vector<int>::const_iterator jt = indices.begin(); jt != indices.end(); ++jt) {
|
||||
if (*jt < 1 || *jt > max_index) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
|
||||
}
|
||||
auto current = points[(*jt) - 1];
|
||||
auto current = resolve(*jt);
|
||||
if (jt != indices.begin()) {
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
if (!indices.empty()) {
|
||||
auto current = points[indices.front() - 1];
|
||||
auto current = resolve(indices.front());
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
}
|
||||
@@ -77,17 +91,14 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPolygonalFaceSet* inst) {
|
||||
loop->external = false;
|
||||
|
||||
for (std::vector<int>::const_iterator jt = li.begin(); jt != li.end(); ++jt) {
|
||||
if (*jt < 1 || *jt > max_index) {
|
||||
throw IfcParse::IfcException("IfcPolygonalFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
|
||||
}
|
||||
auto current = points[(*jt) - 1];
|
||||
auto current = resolve(*jt);
|
||||
if (jt != li.begin()) {
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
if (!li.empty()) {
|
||||
auto current = points[li.front() - 1];
|
||||
auto current = resolve(li.front());
|
||||
loop->children.push_back(taxonomy::make<taxonomy::edge>(previous, current));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,23 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcTriangulatedFaceSet* inst) {
|
||||
|
||||
int max_index = (int)points.size();
|
||||
|
||||
// When the optional PnIndex is present, CoordIndex values do not index into
|
||||
// CoordList directly but into PnIndex, which in turn remaps to CoordList.
|
||||
// Both index levels are 1-based per the IFC specification.
|
||||
auto pn_index = inst->PnIndex();
|
||||
auto resolve = [&](int idx) -> const taxonomy::point3::ptr& {
|
||||
if (pn_index) {
|
||||
if (idx < 1 || idx > (int)pn_index->size()) {
|
||||
throw IfcParse::IfcException("IfcTriangulatedFaceSet PnIndex out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
idx = (*pn_index)[idx - 1];
|
||||
}
|
||||
if (idx < 1 || idx > max_index) {
|
||||
throw IfcParse::IfcException("IfcTriangulatedFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(idx));
|
||||
}
|
||||
return points[idx - 1];
|
||||
};
|
||||
|
||||
auto shell = taxonomy::make<taxonomy::shell>();
|
||||
|
||||
for (auto& indices : indices_list) {
|
||||
@@ -51,10 +68,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcTriangulatedFaceSet* inst) {
|
||||
loop->external = true;
|
||||
taxonomy::point3::ptr first, previous;
|
||||
for (std::vector<int>::const_iterator jt = indices.begin(); jt != indices.end(); ++jt) {
|
||||
if (*jt < 1 || *jt > max_index) {
|
||||
throw IfcParse::IfcException("IfcTriangulatedFaceSet index out of bounds for index " + boost::lexical_cast<std::string>(*jt));
|
||||
}
|
||||
const taxonomy::point3::ptr& current = points[(*jt) - 1];
|
||||
const taxonomy::point3::ptr& current = resolve(*jt);
|
||||
if (jt == indices.begin()) {
|
||||
first = current;
|
||||
} else {
|
||||
|
||||
@@ -89,7 +89,11 @@ BIND(IfcRectangleHollowProfileDef);
|
||||
BIND(IfcRectangleProfileDef);
|
||||
BIND(IfcTrapeziumProfileDef);
|
||||
BIND(IfcCShapeProfileDef);
|
||||
// IfcAsymmetricIShapeProfileDef included
|
||||
// In IFC2X3 IfcAsymmetricIShapeProfileDef is a subtype of IfcIShapeProfileDef and is
|
||||
// mapped by it; from IFC4 onwards it is a standalone type and needs its own binding.
|
||||
#ifdef SCHEMA_IfcAsymmetricIShapeProfileDef_HAS_BottomFlangeWidth
|
||||
BIND(IfcAsymmetricIShapeProfileDef);
|
||||
#endif
|
||||
BIND(IfcIShapeProfileDef);
|
||||
BIND(IfcLShapeProfileDef);
|
||||
BIND(IfcTShapeProfileDef);
|
||||
|
||||
@@ -311,8 +311,12 @@ CLI Manual
|
||||
output.
|
||||
--force-space-transparency arg Overrides transparency of spaces in
|
||||
geometry output.
|
||||
--circle-segments arg (= 16) Number of segments to approximate full
|
||||
circles in CGAL kernel.
|
||||
--circle-segments arg (= 0) Number of segments to approximate full
|
||||
circles in the CGAL kernel. When 0 (the
|
||||
default) the segment count is derived from
|
||||
mesher-linear-deflection instead, so curves
|
||||
stay within the deflection tolerance
|
||||
regardless of radius.
|
||||
--cgal-smooth-angle-degrees arg (= -1)
|
||||
Angle in degrees under which adjacent
|
||||
facets will have averaged vertex
|
||||
|
||||
@@ -72,6 +72,8 @@ Filtering is typically used to select any IFC element or type.
|
||||
|
||||
"``IfcPump, location=""Level 3""``", "Locations bubble up the hierarchy. So if a pump is in a space and that space is on Level 3, then you can say ""all pumps on level 3"" which will include that pump in the space."
|
||||
|
||||
"``IfcElement, query:""parent.Name""=""My Site""``", "Only elements *immediately* under ""My Site"" in the spatial hierarchy. Unlike the ``location`` and ``parent`` filters, which both match at any depth, the ``parent`` query key resolves the direct parent only, so nested storeys (and their contents) are excluded."
|
||||
|
||||
The filter elements syntax works by specifying one or more groups of filters
|
||||
separated by a ``+`` character. Each filter group will return a set of filtered
|
||||
elements, and these are unioned together.
|
||||
@@ -111,6 +113,15 @@ will search through all IfcTypeProducts and IfcProducts in the IFC project.
|
||||
"Parent", "Filter", "``parent{{=}}{{value}}``", "``parent=Foo`` specifies the criteria that elements must be a direct or indirect child in the spatial hierarchy to an element with a ``Name`` attribute with a value of ``Foo``."
|
||||
"Query", "Filter", "``query:{{keys}}{{=}}{{value}}``", "``query:types.count=0`` specifies the criteria that elements must have zero type occurrences. The query keys corresponds to the syntax used in the `Getting element values`_ section"
|
||||
|
||||
.. note::
|
||||
|
||||
The ``location`` and ``parent`` filters both match at **any depth** in the
|
||||
spatial hierarchy. To match only elements *immediately* contained in (or
|
||||
aggregated under) a spatial element, use the ``parent`` query key, which
|
||||
resolves the direct parent only. For example,
|
||||
``query:"parent.Name"="My Site"`` selects elements directly under ``My
|
||||
Site`` but excludes anything nested inside its sub-storeys or spaces.
|
||||
|
||||
When you specify a filter with a ``{{=}}`` check, you can choose from one of
|
||||
the following comparison checks:
|
||||
|
||||
@@ -191,7 +202,7 @@ Valid keys are:
|
||||
"``storey``", "Gets the first IfcBuildingStorey spatial element that an element is contained in."
|
||||
"``building``", "Gets the first IfcBuilding spatial element that an element is contained in."
|
||||
"``site``", "Gets the first IfcSite spatial element that an element is contained in."
|
||||
"``parent``", "Gets the parent element in the spatial hierarchy."
|
||||
"``parent``", "Gets the **immediate** parent element in the spatial hierarchy (the direct spatial container, or the direct aggregate/nest/fill/void parent). Combine with ``.Name`` in a query filter to match only immediate children, e.g. ``query:""parent.Name""=""My Site""``."
|
||||
"``classification``", "Gets the element's classification reference(s)"
|
||||
"``group``", "Gets the element's group(s)"
|
||||
"``system``", "Gets the element's system(s). This is a subset of group(s)."
|
||||
|
||||
@@ -228,10 +228,10 @@ circle-segments
|
||||
+------+-----------------------+---------+
|
||||
| Type | IfcConvert Option | Default |
|
||||
+======+=======================+=========+
|
||||
| INT | ``--circle-segments`` | 16 |
|
||||
| INT | ``--circle-segments`` | 0 |
|
||||
+------+-----------------------+---------+
|
||||
|
||||
Number of segments to approximate full circles in CGAL kernel.
|
||||
Number of segments to approximate full circles in the CGAL kernel. When 0 (the default) the segment count is derived from mesher-linear-deflection instead, so curves stay within the deflection tolerance regardless of radius.
|
||||
|
||||
context-identifiers
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
@@ -187,6 +187,15 @@ void IfcUtil::sanitate_material_name(std::string& str) {
|
||||
}
|
||||
|
||||
void IfcUtil::escape_xml(std::string& str) {
|
||||
// Strip characters that are illegal in XML 1.0. Control characters other
|
||||
// than tab (0x09), newline (0x0A) and carriage return (0x0D) are not valid
|
||||
// XML 1.0 characters and cannot even be represented as numeric character
|
||||
// references, so they would otherwise make the serialized XML/SVG output
|
||||
// non-well-formed. Bytes belonging to a valid UTF-8 multibyte sequence are
|
||||
// always >= 0x80, so filtering on the low control range leaves them intact.
|
||||
str.erase(std::remove_if(str.begin(), str.end(), [](unsigned char c) {
|
||||
return c < 0x20 && c != '\t' && c != '\n' && c != '\r';
|
||||
}), str.end());
|
||||
boost::replace_all(str, "&", "&");
|
||||
boost::replace_all(str, "\"", """);
|
||||
boost::replace_all(str, "'", "'");
|
||||
|
||||
@@ -305,7 +305,7 @@ ptree* descend(Logger& logger, ifcopenshell::geometry::abstract_mapping* mapping
|
||||
<IfcSchema::IfcObject, IfcSchema::IfcRelDefinesByProperties, IfcSchema::IfcPropertySetDefinition>
|
||||
(logger, object, &IfcSchema::IfcObject::IsDefinedBy, &IfcSchema::IfcRelDefinesByProperties::RelatingPropertyDefinition);
|
||||
|
||||
#ifdef SCHEMAS_HAS_IfcPropertySetDefinitionSet
|
||||
#ifdef SCHEMA_HAS_IfcPropertySetDefinitionSet
|
||||
aggregate_of<IfcSchema::IfcPropertySetDefinitionSet>::ptr property_set_sets = get_related
|
||||
<IfcSchema::IfcObject, IfcSchema::IfcRelDefinesByProperties, IfcSchema::IfcPropertySetDefinitionSet>
|
||||
(logger, object, &IfcSchema::IfcObject::IsDefinedBy, &IfcSchema::IfcRelDefinesByProperties::RelatingPropertyDefinition);
|
||||
|
||||
Reference in New Issue
Block a user