mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 15:53:00 +00:00
Merge branch 'v0.8.0' into ifcmax/initial-refresh
This commit is contained in:
@@ -109,7 +109,7 @@ jobs:
|
|||||||
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
|
# Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo.
|
||||||
|
|
||||||
# Download Blender.
|
# Download Blender.
|
||||||
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.1.0-linux-x64.tar.xz
|
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz
|
||||||
tar -xf blender.tar.xz
|
tar -xf blender.tar.xz
|
||||||
|
|
||||||
# Setup Blender.
|
# Setup Blender.
|
||||||
@@ -122,7 +122,7 @@ jobs:
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
python setup_extensions_repo.py --last-tag
|
python setup_extensions_repo.py --last-tag
|
||||||
cd ..
|
cd ..
|
||||||
bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py311*-linux-x64.zip)"
|
bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py313*-linux-x64.zip)"
|
||||||
|
|
||||||
# Install Bonsai.
|
# Install Bonsai.
|
||||||
blender --command extension install-file -r user_default -e $bonsai_zip
|
blender --command extension install-file -r user_default -e $bonsai_zip
|
||||||
|
|||||||
@@ -73,6 +73,22 @@ def poll_related_object(self: "BIMObjectAggregateProperties", related_obj: bpy.t
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def update_relating_object(self, context):
|
||||||
|
if self.relating_object:
|
||||||
|
ifc_id = tool.Blender.get_object_bim_props(self.relating_object).ifc_definition_id
|
||||||
|
if ifc_id:
|
||||||
|
bpy.ops.bim.aggregate_assign_object(relating_object=ifc_id)
|
||||||
|
bpy.ops.bim.disable_editing_aggregate()
|
||||||
|
|
||||||
|
|
||||||
|
def update_related_object(self, context):
|
||||||
|
if self.related_object:
|
||||||
|
ifc_id = tool.Blender.get_object_bim_props(self.related_object).ifc_definition_id
|
||||||
|
if ifc_id:
|
||||||
|
bpy.ops.bim.aggregate_assign_object(related_object=ifc_id)
|
||||||
|
bpy.ops.bim.disable_editing_aggregate()
|
||||||
|
|
||||||
|
|
||||||
def update_aggregate_decorator(self, context):
|
def update_aggregate_decorator(self, context):
|
||||||
if self.aggregate_decorator:
|
if self.aggregate_decorator:
|
||||||
AggregateDecorator.install(bpy.context)
|
AggregateDecorator.install(bpy.context)
|
||||||
@@ -89,12 +105,15 @@ def update_aggregate_mode_decorator(self, context):
|
|||||||
|
|
||||||
class BIMObjectAggregateProperties(PropertyGroup):
|
class BIMObjectAggregateProperties(PropertyGroup):
|
||||||
is_editing: BoolProperty(name="Is Editing")
|
is_editing: BoolProperty(name="Is Editing")
|
||||||
relating_object: PointerProperty(name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object)
|
relating_object: PointerProperty(
|
||||||
|
name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object, update=update_relating_object
|
||||||
|
)
|
||||||
related_object: PointerProperty(
|
related_object: PointerProperty(
|
||||||
name="Related Part",
|
name="Related Part",
|
||||||
description="Related Part, will be used to derive the Relating Object",
|
description="Related Part, will be used to derive the Relating Object",
|
||||||
type=bpy.types.Object,
|
type=bpy.types.Object,
|
||||||
poll=poll_related_object,
|
poll=poll_related_object,
|
||||||
|
update=update_related_object,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ classes = (
|
|||||||
operator.Fetch,
|
operator.Fetch,
|
||||||
operator.Merge,
|
operator.Merge,
|
||||||
operator.ObjectLog,
|
operator.ObjectLog,
|
||||||
|
operator.SelectConflictEntity,
|
||||||
operator.Push,
|
operator.Push,
|
||||||
operator.RefreshGit,
|
operator.RefreshGit,
|
||||||
|
operator.RenameBranch,
|
||||||
operator.SwitchRevision,
|
operator.SwitchRevision,
|
||||||
operator.InstallGit,
|
operator.InstallGit,
|
||||||
operator.RunGitDiff,
|
operator.RunGitDiff,
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ class DeleteTag(bpy.types.Operator):
|
|||||||
class RefreshGit(bpy.types.Operator):
|
class RefreshGit(bpy.types.Operator):
|
||||||
"""Refresh revision list"""
|
"""Refresh revision list"""
|
||||||
|
|
||||||
bl_label = ""
|
bl_label = "Refresh"
|
||||||
bl_idname = "ifcgit.refresh"
|
bl_idname = "ifcgit.refresh"
|
||||||
bl_options = {"REGISTER"}
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ class RefreshGit(bpy.types.Operator):
|
|||||||
class DisplayRevision(bpy.types.Operator):
|
class DisplayRevision(bpy.types.Operator):
|
||||||
"""Colourise objects by selected revision"""
|
"""Colourise objects by selected revision"""
|
||||||
|
|
||||||
bl_label = ""
|
bl_label = "Colourise Revision"
|
||||||
bl_idname = "ifcgit.display_revision"
|
bl_idname = "ifcgit.display_revision"
|
||||||
bl_options = {"REGISTER"}
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
@@ -260,7 +260,7 @@ class DisplayUncommitted(bpy.types.Operator):
|
|||||||
class SwitchRevision(bpy.types.Operator):
|
class SwitchRevision(bpy.types.Operator):
|
||||||
"""Switches the repository to the selected revision and reloads the IFC file"""
|
"""Switches the repository to the selected revision and reloads the IFC file"""
|
||||||
|
|
||||||
bl_label = ""
|
bl_label = "Switch Revision"
|
||||||
bl_idname = "ifcgit.switch_revision"
|
bl_idname = "ifcgit.switch_revision"
|
||||||
bl_options = {"REGISTER"}
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ class SwitchRevision(bpy.types.Operator):
|
|||||||
|
|
||||||
|
|
||||||
class Merge(bpy.types.Operator):
|
class Merge(bpy.types.Operator):
|
||||||
"""Merges the selected branch into working branch"""
|
"""Merges the selected branch into working branch.\nCtrl+click to preview without merging"""
|
||||||
|
|
||||||
bl_label = "Merge this branch"
|
bl_label = "Merge this branch"
|
||||||
bl_idname = "ifcgit.merge"
|
bl_idname = "ifcgit.merge"
|
||||||
@@ -292,8 +292,14 @@ class Merge(bpy.types.Operator):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def execute(self, context):
|
def invoke(self, context, event):
|
||||||
|
if event.ctrl:
|
||||||
|
core.dry_run_merge(tool.IfcGit, tool.Ifc, self)
|
||||||
|
refresh()
|
||||||
|
return {"FINISHED"}
|
||||||
|
return self.execute(context)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False:
|
if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False:
|
||||||
refresh()
|
refresh()
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
@@ -301,6 +307,69 @@ class Merge(bpy.types.Operator):
|
|||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
|
||||||
|
class SelectConflictEntity(bpy.types.Operator):
|
||||||
|
"""Select the conflicting entity in the viewport"""
|
||||||
|
|
||||||
|
bl_label = "Select Conflict Entity"
|
||||||
|
bl_idname = "ifcgit.select_conflict_entity"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
step_id: int
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
model = tool.Ifc.get()
|
||||||
|
if not model:
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
entity = model.by_id(self.step_id)
|
||||||
|
except Exception:
|
||||||
|
self.report({"WARNING"}, f"Entity #{self.step_id} not found (may have been deleted locally)")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
obj = tool.Ifc.get_object(entity)
|
||||||
|
if obj is None:
|
||||||
|
# Walk inverse references up to 5 hops to find nearest entity with a Blender object
|
||||||
|
visited = {entity.id()}
|
||||||
|
queue = [entity]
|
||||||
|
for _ in range(5):
|
||||||
|
next_queue = []
|
||||||
|
for ent in queue:
|
||||||
|
for inv in model.get_inverse(ent):
|
||||||
|
if inv.id() in visited:
|
||||||
|
continue
|
||||||
|
visited.add(inv.id())
|
||||||
|
obj = tool.Ifc.get_object(inv)
|
||||||
|
if obj is not None:
|
||||||
|
break
|
||||||
|
next_queue.append(inv)
|
||||||
|
if obj is not None:
|
||||||
|
break
|
||||||
|
if obj is not None:
|
||||||
|
break
|
||||||
|
queue = next_queue
|
||||||
|
|
||||||
|
if obj is None:
|
||||||
|
self.report({"INFO"}, f"No viewport representation found for #{self.step_id} ({entity.is_a()})")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
obj.select_set(True)
|
||||||
|
context.view_layer.objects.active = obj
|
||||||
|
for area in context.screen.areas:
|
||||||
|
if area.type == "VIEW_3D":
|
||||||
|
region = next((r for r in area.regions if r.type == "WINDOW"), None)
|
||||||
|
if region:
|
||||||
|
with context.temp_override(area=area, region=region):
|
||||||
|
bpy.ops.view3d.view_selected()
|
||||||
|
break
|
||||||
|
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
class Push(bpy.types.Operator):
|
class Push(bpy.types.Operator):
|
||||||
"""Pushes the working branch to selected remote"""
|
"""Pushes the working branch to selected remote"""
|
||||||
|
|
||||||
@@ -469,3 +538,37 @@ class RunGitDiff(bpy.types.Operator):
|
|||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
core.run_git_diff(tool.IfcGit, self, self.save_to_temp)
|
core.run_git_diff(tool.IfcGit, self, self.save_to_temp)
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
class RenameBranch(bpy.types.Operator):
|
||||||
|
"""Rename the current branch"""
|
||||||
|
|
||||||
|
bl_label = "Rename Branch"
|
||||||
|
bl_idname = "ifcgit.rename_branch"
|
||||||
|
bl_options = {"REGISTER"}
|
||||||
|
|
||||||
|
new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
new_name: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
IfcGitData.make_sure_is_loaded()
|
||||||
|
if not IfcGitData.data["repo"]:
|
||||||
|
return False
|
||||||
|
if IfcGitData.data["is_detached"]:
|
||||||
|
return False
|
||||||
|
if IfcGitData.data["is_dirty"]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def invoke(self, context, event):
|
||||||
|
self.new_name = IfcGitData.data["active_branch_name"]
|
||||||
|
return context.window_manager.invoke_props_dialog(self)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
repo = IfcGitData.data["repo"]
|
||||||
|
core.rename_branch(tool.IfcGit, repo, self.new_name)
|
||||||
|
refresh()
|
||||||
|
return {"FINISHED"}
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ class IfcGitProperties(PropertyGroup):
|
|||||||
],
|
],
|
||||||
update=update_revlist,
|
update=update_revlist,
|
||||||
)
|
)
|
||||||
|
merge_conflicts: StringProperty(
|
||||||
|
name="Merge Conflicts",
|
||||||
|
description="JSON report from last failed merge attempt",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem]
|
ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem]
|
||||||
@@ -153,3 +158,4 @@ class IfcGitProperties(PropertyGroup):
|
|||||||
display_branch: str
|
display_branch: str
|
||||||
select_remote: str
|
select_remote: str
|
||||||
ifcgit_filter: Literal["all", "tagged", "relevant"]
|
ifcgit_filter: Literal["all", "tagged", "relevant"]
|
||||||
|
merge_conflicts: str
|
||||||
|
|||||||
@@ -112,15 +112,13 @@ class IFCGIT_PT_panel(bpy.types.Panel):
|
|||||||
row.label(text="Working branch: Detached HEAD")
|
row.label(text="Working branch: Detached HEAD")
|
||||||
else:
|
else:
|
||||||
row.label(text="Working branch: " + IfcGitData.data["active_branch_name"])
|
row.label(text="Working branch: " + IfcGitData.data["active_branch_name"])
|
||||||
|
row.operator("ifcgit.rename_branch", icon="GREASEPENCIL", text="")
|
||||||
|
|
||||||
grouped = layout.row()
|
row = layout.row()
|
||||||
column = grouped.column()
|
|
||||||
row = column.row()
|
|
||||||
row.prop(props, "display_branch", text="Browse branch")
|
row.prop(props, "display_branch", text="Browse branch")
|
||||||
row.prop(props, "ifcgit_filter", text="Filter revisions")
|
row.prop(props, "ifcgit_filter", text="Filter revisions")
|
||||||
|
|
||||||
row = column.row()
|
layout.template_list(
|
||||||
row.template_list(
|
|
||||||
"COMMIT_UL_List",
|
"COMMIT_UL_List",
|
||||||
"The_List",
|
"The_List",
|
||||||
props,
|
props,
|
||||||
@@ -128,20 +126,64 @@ class IFCGIT_PT_panel(bpy.types.Panel):
|
|||||||
props,
|
props,
|
||||||
"commit_index",
|
"commit_index",
|
||||||
)
|
)
|
||||||
column = grouped.column()
|
|
||||||
row = column.row()
|
row = layout.row(align=True)
|
||||||
row.operator("ifcgit.refresh", icon="FILE_REFRESH")
|
row.operator("ifcgit.refresh", icon="FILE_REFRESH")
|
||||||
|
|
||||||
if not is_dirty:
|
if not is_dirty:
|
||||||
|
|
||||||
row = column.row()
|
|
||||||
row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE")
|
row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE")
|
||||||
|
|
||||||
row = column.row()
|
|
||||||
row.operator("ifcgit.switch_revision", icon="CURRENT_FILE")
|
row.operator("ifcgit.switch_revision", icon="CURRENT_FILE")
|
||||||
|
row.operator("ifcgit.merge", icon="SYSTEM")
|
||||||
|
|
||||||
row = column.row()
|
conflicts = tool.IfcGit.get_merge_conflicts()
|
||||||
row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="")
|
if conflicts is not None:
|
||||||
|
box = layout.box()
|
||||||
|
box.alert = True
|
||||||
|
row = box.row()
|
||||||
|
row.label(
|
||||||
|
text=f"Merge failed \u2014 {len(conflicts)} conflict(s)",
|
||||||
|
icon="ERROR",
|
||||||
|
)
|
||||||
|
for conflict in conflicts:
|
||||||
|
col = box.column(align=True)
|
||||||
|
conflict_type = conflict.get("type", "")
|
||||||
|
entity_id = conflict.get("entity_id", "?")
|
||||||
|
local_id = conflict.get("original_local_id")
|
||||||
|
|
||||||
|
if conflict_type == "attribute_conflict":
|
||||||
|
entity_class = conflict.get("entity_class", "Entity")
|
||||||
|
attr_idx = conflict.get("attribute_index", "?")
|
||||||
|
desc = f"#{entity_id} {entity_class}: attribute {attr_idx} conflict"
|
||||||
|
elif conflict_type == "entity_deleted_and_modified":
|
||||||
|
entity_class = conflict.get("entity_class", "Entity")
|
||||||
|
desc = f"#{entity_id} {entity_class}: " + conflict.get("message", "deleted/modified conflict")
|
||||||
|
elif conflict_type == "class_changed":
|
||||||
|
desc = (
|
||||||
|
f"#{entity_id}: class changed "
|
||||||
|
+ conflict.get("base_class", "?")
|
||||||
|
+ " \u2192 "
|
||||||
|
+ conflict.get("modified_class", "?")
|
||||||
|
)
|
||||||
|
elif conflict_type == "required_entity_deleted":
|
||||||
|
desc = f"#{entity_id}: " + conflict.get("message", "required entity deleted")
|
||||||
|
else:
|
||||||
|
desc = f"#{entity_id}: {conflict_type}"
|
||||||
|
|
||||||
|
row = col.row(align=True)
|
||||||
|
row.label(text=desc)
|
||||||
|
if local_id:
|
||||||
|
op = row.operator(
|
||||||
|
"ifcgit.select_conflict_entity",
|
||||||
|
text="",
|
||||||
|
icon="RESTRICT_SELECT_OFF",
|
||||||
|
)
|
||||||
|
op.step_id = local_id
|
||||||
|
|
||||||
|
if conflict_type == "attribute_conflict":
|
||||||
|
sub = col.column(align=True)
|
||||||
|
sub.scale_y = 0.75
|
||||||
|
sub.label(text=f" Base: {conflict.get('base_value', '')}")
|
||||||
|
sub.label(text=f" Local: {conflict.get('local_value', '')}")
|
||||||
|
sub.label(text=f" Remote: {conflict.get('remote_value', '')}")
|
||||||
|
|
||||||
if not props.ifcgit_commits:
|
if not props.ifcgit_commits:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
@@ -384,6 +385,18 @@ class BIM_PT_new_project_wizard(Panel):
|
|||||||
row = self.layout.row()
|
row = self.layout.row()
|
||||||
row.operator("bim.create_project")
|
row.operator("bim.create_project")
|
||||||
|
|
||||||
|
if shutil.which("git"):
|
||||||
|
git_props = context.scene.IfcGitProperties
|
||||||
|
box = self.layout.box()
|
||||||
|
row = box.row()
|
||||||
|
row.label(text="Clone a remote Git repository")
|
||||||
|
row = box.row()
|
||||||
|
row.prop(git_props, "remote_url")
|
||||||
|
row = box.row()
|
||||||
|
row.prop(git_props, "local_folder")
|
||||||
|
row = box.row()
|
||||||
|
row.operator("ifcgit.clone_repo", icon="IMPORT")
|
||||||
|
|
||||||
|
|
||||||
class BIM_PT_project_library(Panel):
|
class BIM_PT_project_library(Panel):
|
||||||
bl_label = "Project Library"
|
bl_label = "Project Library"
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -
|
|||||||
ifcgit.delete_remote(repo, remote_name)
|
ifcgit.delete_remote(repo, remote_name)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_branch(ifcgit: type[tool.IfcGit], repo: git.Repo, new_name: str) -> None:
|
||||||
|
ifcgit.rename_branch(repo, new_name)
|
||||||
|
|
||||||
|
|
||||||
def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None:
|
def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None:
|
||||||
error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name())
|
error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name())
|
||||||
if error_message:
|
if error_message:
|
||||||
@@ -94,6 +98,7 @@ def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator:
|
|||||||
|
|
||||||
|
|
||||||
def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
|
def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
|
||||||
|
ifcgit.clear_merge_conflicts()
|
||||||
if ifcgit.repo_has_commits():
|
if ifcgit.repo_has_commits():
|
||||||
ifcgit.refresh_revision_list(ifc.get_path())
|
ifcgit.refresh_revision_list(ifc.get_path())
|
||||||
|
|
||||||
@@ -143,19 +148,60 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t
|
|||||||
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
||||||
return False
|
return False
|
||||||
elif merge_result == "conflict":
|
elif merge_result == "conflict":
|
||||||
error = ifcgit.git_mergetool(mergetool)
|
conflicts = ifcgit.git_mergetool(mergetool, path_ifc)
|
||||||
if error:
|
if conflicts is not None:
|
||||||
ifcgit.git_merge_abort()
|
ifcgit.git_merge_abort()
|
||||||
operator.report({"ERROR"}, "IFC Merge failed:" + error)
|
ifcgit.store_merge_conflicts(conflicts)
|
||||||
|
operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below")
|
||||||
return False
|
return False
|
||||||
ifcgit.commit_merge(path_ifc)
|
ifcgit.commit_merge(path_ifc)
|
||||||
|
|
||||||
|
ifcgit.clear_merge_conflicts()
|
||||||
ifcgit.set_display_branch()
|
ifcgit.set_display_branch()
|
||||||
|
ifcgit.git_checkout(path_ifc)
|
||||||
ifcgit.load_project(path_ifc)
|
ifcgit.load_project(path_ifc)
|
||||||
ifcgit.refresh_revision_list(path_ifc)
|
ifcgit.refresh_revision_list(path_ifc)
|
||||||
ifcgit.decolourise()
|
ifcgit.decolourise()
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None:
|
||||||
|
path_ifc = ifc.get_path()
|
||||||
|
ifcgit.config_ifcmerge()
|
||||||
|
|
||||||
|
branch_name = ifcgit.get_selected_branch()
|
||||||
|
if branch_name is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
mergetool = ifcgit.get_merge_tool(branch_name)
|
||||||
|
merge_result = ifcgit.git_merge_no_commit(branch_name)
|
||||||
|
|
||||||
|
if merge_result == "error":
|
||||||
|
try:
|
||||||
|
ifcgit.git_merge_abort()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
operator.report({"ERROR"}, "Unknown IFC Merge failure")
|
||||||
|
return
|
||||||
|
|
||||||
|
if merge_result == "conflict":
|
||||||
|
conflicts = ifcgit.git_mergetool(mergetool, path_ifc)
|
||||||
|
ifcgit.git_merge_abort()
|
||||||
|
if conflicts is not None:
|
||||||
|
ifcgit.store_merge_conflicts(conflicts)
|
||||||
|
operator.report({"WARNING"}, "Merge preview: conflicts found — see the panel below")
|
||||||
|
else:
|
||||||
|
ifcgit.clear_merge_conflicts()
|
||||||
|
operator.report({"INFO"}, "Merge preview: no conflicts")
|
||||||
|
else:
|
||||||
|
# Clean merge or already up to date — abort the pending merge state if any
|
||||||
|
try:
|
||||||
|
ifcgit.git_merge_abort()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ifcgit.clear_merge_conflicts()
|
||||||
|
operator.report({"INFO"}, "Merge preview: no conflicts")
|
||||||
|
|
||||||
|
|
||||||
def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None:
|
def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None:
|
||||||
path_ifc = ifc.get_path()
|
path_ifc = ifc.get_path()
|
||||||
log_text = ifcgit.entity_log(path_ifc, step_id)
|
log_text = ifcgit.entity_log(path_ifc, step_id)
|
||||||
|
|||||||
@@ -559,7 +559,11 @@ class IfcGit:
|
|||||||
def get_selected_branch(cls): pass
|
def get_selected_branch(cls): pass
|
||||||
def git_merge(cls, branch_name): pass
|
def git_merge(cls, branch_name): pass
|
||||||
def git_merge_abort(cls): pass
|
def git_merge_abort(cls): pass
|
||||||
def git_mergetool(cls, mergetool): pass
|
def git_merge_no_commit(cls, branch_name): pass
|
||||||
|
def git_mergetool(cls, mergetool, path_ifc): pass
|
||||||
|
def store_merge_conflicts(cls, conflicts): pass
|
||||||
|
def clear_merge_conflicts(cls): pass
|
||||||
|
def get_merge_conflicts(cls): pass
|
||||||
def set_display_branch(cls): pass
|
def set_display_branch(cls): pass
|
||||||
def get_active_branch_name(cls): pass
|
def get_active_branch_name(cls): pass
|
||||||
def get_ifcgit_props(cls): pass
|
def get_ifcgit_props(cls): pass
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -155,6 +156,10 @@ class IfcGit:
|
|||||||
if tag_name in repo.tags:
|
if tag_name in repo.tags:
|
||||||
repo.delete_tag(tag_name)
|
repo.delete_tag(tag_name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rename_branch(cls, repo: git.Repo, new_name: str) -> None:
|
||||||
|
repo.active_branch.rename(new_name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None:
|
def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None:
|
||||||
repo.create_remote(name=remote_name, url=remote_url)
|
repo.create_remote(name=remote_name, url=remote_url)
|
||||||
@@ -278,6 +283,10 @@ class IfcGit:
|
|||||||
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
|
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
|
||||||
|
|
||||||
from bonsai.bim.module.root.data import IfcClassData
|
from bonsai.bim.module.root.data import IfcClassData
|
||||||
|
from bonsai.bim.module.model.data import AuthoringData
|
||||||
|
import bonsai.bim.handler
|
||||||
|
|
||||||
|
AuthoringData.type_thumbnails = {}
|
||||||
|
|
||||||
IfcClassData.is_loaded = False
|
IfcClassData.is_loaded = False
|
||||||
|
|
||||||
@@ -285,10 +294,11 @@ class IfcGit:
|
|||||||
settings.should_setup_viewport_camera = False
|
settings.should_setup_viewport_camera = False
|
||||||
ifc_importer = import_ifc.IfcImporter(settings)
|
ifc_importer = import_ifc.IfcImporter(settings)
|
||||||
ifc_importer.execute()
|
ifc_importer.execute()
|
||||||
tool.Project.load_project_pset_templates()
|
|
||||||
tool.Project.load_default_thumbnails()
|
tool.Project.load_default_thumbnails()
|
||||||
tool.Project.set_default_context()
|
tool.Project.set_default_context()
|
||||||
tool.Project.set_default_modeling_dimensions()
|
tool.Project.set_default_modeling_dimensions()
|
||||||
|
tool.Root.reload_grid_decorator()
|
||||||
|
bonsai.bim.handler.refresh_ui_data()
|
||||||
bpy.ops.object.select_all(action="DESELECT")
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -388,20 +398,43 @@ class IfcGit:
|
|||||||
model = tool.Ifc.get()
|
model = tool.Ifc.get()
|
||||||
modified_step_ids = {"modified": set()}
|
modified_step_ids = {"modified": set()}
|
||||||
|
|
||||||
for step_id in step_ids["modified"] | step_ids["added"]:
|
def collect(entity, depth=0):
|
||||||
try:
|
if depth > 2:
|
||||||
entity = model.by_id(step_id)
|
return
|
||||||
except:
|
if entity.is_a("IfcProduct"):
|
||||||
continue
|
modified_step_ids["modified"].add(entity.id())
|
||||||
if entity.is_a("IfcProductDefinitionShape"):
|
elif entity.is_a("IfcProductDefinitionShape"):
|
||||||
for product in entity.ShapeOfProduct:
|
for product in entity.ShapeOfProduct:
|
||||||
modified_step_ids["modified"].add(product.id())
|
modified_step_ids["modified"].add(product.id())
|
||||||
elif entity.is_a("IfcObjectPlacement"):
|
elif entity.is_a("IfcObjectPlacement"):
|
||||||
for product in entity.PlacesObject:
|
for product in entity.PlacesObject:
|
||||||
modified_step_ids["modified"].add(product.id())
|
modified_step_ids["modified"].add(product.id())
|
||||||
elif entity.is_a("IfcTypeProduct") and entity.Types:
|
elif entity.is_a("IfcTypeProduct"):
|
||||||
for related_object in entity.Types[0].RelatedObjects:
|
for rel in entity.Types:
|
||||||
modified_step_ids["modified"].add(related_object.id())
|
for obj in rel.RelatedObjects:
|
||||||
|
modified_step_ids["modified"].add(obj.id())
|
||||||
|
elif entity.is_a("IfcShapeRepresentation"):
|
||||||
|
for prod_rep in entity.OfProductRepresentation:
|
||||||
|
for product in prod_rep.ShapeOfProduct:
|
||||||
|
modified_step_ids["modified"].add(product.id())
|
||||||
|
elif entity.is_a("IfcRepresentationItem"):
|
||||||
|
for referencing in model.get_inverse(entity):
|
||||||
|
if referencing.is_a("IfcShapeRepresentation"):
|
||||||
|
collect(referencing, depth + 1)
|
||||||
|
elif entity.is_a("IfcPropertySet"):
|
||||||
|
for rel in entity.DefinesOccurrence:
|
||||||
|
for obj in rel.RelatedObjects:
|
||||||
|
modified_step_ids["modified"].add(obj.id())
|
||||||
|
elif entity.is_a("IfcProperty"):
|
||||||
|
for pset in entity.PartOfPset:
|
||||||
|
collect(pset, depth + 1)
|
||||||
|
|
||||||
|
for step_id in step_ids["modified"] | step_ids["added"]:
|
||||||
|
try:
|
||||||
|
entity = model.by_id(step_id)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
collect(entity)
|
||||||
|
|
||||||
return modified_step_ids
|
return modified_step_ids
|
||||||
|
|
||||||
@@ -453,11 +486,28 @@ class IfcGit:
|
|||||||
if item.hexsha in lookup:
|
if item.hexsha in lookup:
|
||||||
for branch in lookup[item.hexsha]:
|
for branch in lookup[item.hexsha]:
|
||||||
if branch.name == props.display_branch:
|
if branch.name == props.display_branch:
|
||||||
|
if isinstance(branch, git.RemoteReference):
|
||||||
|
# Checking out a remote branch tip goes to detached HEAD.
|
||||||
|
# Pre-fill the new branch name field with the local equivalent
|
||||||
|
# so the user isn't blocked from committing without a hint.
|
||||||
|
local_name = branch.remote_head
|
||||||
|
props.new_branch_name = cls._unique_branch_name(repo, local_name)
|
||||||
branch.checkout()
|
branch.checkout()
|
||||||
return
|
return
|
||||||
# NOTE this is calling the git binary in a subprocess
|
# NOTE this is calling the git binary in a subprocess
|
||||||
repo.git.checkout(item.hexsha)
|
repo.git.checkout(item.hexsha)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _unique_branch_name(cls, repo: git.Repo, name: str) -> str:
|
||||||
|
"""Return name if unused, otherwise name-2, name-3, etc."""
|
||||||
|
existing = {h.name for h in repo.heads}
|
||||||
|
if name not in existing:
|
||||||
|
return name
|
||||||
|
i = 2
|
||||||
|
while f"{name}-{i}" in existing:
|
||||||
|
i += 1
|
||||||
|
return f"{name}-{i}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def delete_collection(cls, blender_collection: bpy.types.Collection) -> None:
|
def delete_collection(cls, blender_collection: bpy.types.Collection) -> None:
|
||||||
for obj in blender_collection.objects:
|
for obj in blender_collection.objects:
|
||||||
@@ -468,14 +518,24 @@ class IfcGit:
|
|||||||
def config_ifcmerge(cls) -> None:
|
def config_ifcmerge(cls) -> None:
|
||||||
config_reader = IfcGitRepo.repo.config_reader()
|
config_reader = IfcGitRepo.repo.config_reader()
|
||||||
section = 'mergetool "ifcmerge"'
|
section = 'mergetool "ifcmerge"'
|
||||||
|
new_cmd = "ifcmerge $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge"
|
||||||
if not config_reader.has_section(section):
|
if not config_reader.has_section(section):
|
||||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||||
config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED")
|
config_writer.set_value(section, "cmd", new_cmd)
|
||||||
|
config_writer.set_value(section, "trustExitCode", True)
|
||||||
|
elif config_reader.get_value(section, "cmd") != new_cmd:
|
||||||
|
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||||
|
config_writer.set_value(section, "cmd", new_cmd)
|
||||||
config_writer.set_value(section, "trustExitCode", True)
|
config_writer.set_value(section, "trustExitCode", True)
|
||||||
section = 'mergetool "ifcmerge-forward"'
|
section = 'mergetool "ifcmerge-forward"'
|
||||||
|
new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge"
|
||||||
if not config_reader.has_section(section):
|
if not config_reader.has_section(section):
|
||||||
with IfcGitRepo.repo.config_writer() as config_writer:
|
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||||
config_writer.set_value(section, "cmd", "ifcmerge $BASE $REMOTE $LOCAL $MERGED")
|
config_writer.set_value(section, "cmd", new_cmd)
|
||||||
|
config_writer.set_value(section, "trustExitCode", True)
|
||||||
|
elif config_reader.get_value(section, "cmd") != new_cmd:
|
||||||
|
with IfcGitRepo.repo.config_writer() as config_writer:
|
||||||
|
config_writer.set_value(section, "cmd", new_cmd)
|
||||||
config_writer.set_value(section, "trustExitCode", True)
|
config_writer.set_value(section, "trustExitCode", True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -539,14 +599,62 @@ class IfcGit:
|
|||||||
return "error"
|
return "error"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def git_mergetool(cls, mergetool: str) -> Union[str, None]:
|
def git_merge_no_commit(cls, branch_name: str) -> Union[str, None]:
|
||||||
"""Run ifcmerge tool. Returns None on success, error message string on failure."""
|
"""Attempt a git merge without committing (always leaves a merge state to abort).
|
||||||
|
Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure."""
|
||||||
repo = IfcGitRepo.repo
|
repo = IfcGitRepo.repo
|
||||||
|
branch = repo.branches[branch_name]
|
||||||
|
try:
|
||||||
|
repo.git.merge(branch, no_commit=True, no_ff=True)
|
||||||
|
return None
|
||||||
|
except git.exc.GitCommandError:
|
||||||
|
return "conflict"
|
||||||
|
except git.exc.GitError:
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def git_mergetool(cls, mergetool: str, path_ifc: str) -> Union[list, None]:
|
||||||
|
"""Run ifcmerge tool. Returns None on success, list of conflict dicts on failure."""
|
||||||
|
repo = IfcGitRepo.repo
|
||||||
|
report_path = path_ifc + ".ifcmerge"
|
||||||
try:
|
try:
|
||||||
repo.git.mergetool(tool=mergetool)
|
repo.git.mergetool(tool=mergetool)
|
||||||
|
except git.exc.GitCommandError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
conflicts = None
|
||||||
|
if os.path.exists(report_path):
|
||||||
|
try:
|
||||||
|
with open(report_path) as f:
|
||||||
|
content = f.read().strip()
|
||||||
|
if content:
|
||||||
|
data = json.loads(content)
|
||||||
|
conflicts = data.get("conflicts", [])
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.remove(report_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def store_merge_conflicts(cls, conflicts: list) -> None:
|
||||||
|
cls.get_ifcgit_props().merge_conflicts = json.dumps(conflicts)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_merge_conflicts(cls) -> None:
|
||||||
|
cls.get_ifcgit_props().merge_conflicts = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_merge_conflicts(cls) -> Union[list, None]:
|
||||||
|
raw = cls.get_ifcgit_props().merge_conflicts
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
return None
|
return None
|
||||||
except git.exc.GitCommandError as exc:
|
|
||||||
return re.sub("( stdout: '|')", "", exc.stdout)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def git_merge_abort(cls) -> None:
|
def git_merge_abort(cls) -> None:
|
||||||
|
|||||||
@@ -889,8 +889,19 @@ class Raycast(bonsai.core.tool.Raycast):
|
|||||||
def create_snap_obj(cls, obj):
|
def create_snap_obj(cls, obj):
|
||||||
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
|
if obj.data is None or not isinstance(obj.data, bpy.types.Mesh):
|
||||||
return None
|
return None
|
||||||
for snap_obj in cls.snap_objs:
|
for i, snap_obj in enumerate(cls.snap_objs):
|
||||||
if obj.name == snap_obj.obj.name:
|
if obj.name == snap_obj.obj.name:
|
||||||
|
# Handle objects modified while a modal operator is active.
|
||||||
|
# Example: adding a door or window alters the wall geometry.
|
||||||
|
if len(obj.data.vertices) != len(snap_obj.verts_3d):
|
||||||
|
cls.snap_objs.pop(i)
|
||||||
|
snap_obj = SnapObj(obj)
|
||||||
|
cls.snap_objs.append(snap_obj)
|
||||||
|
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
|
||||||
|
if (obj.matrix_world @ v1.co) != v2:
|
||||||
|
cls.snap_objs.pop(i)
|
||||||
|
snap_obj = SnapObj(obj)
|
||||||
|
cls.snap_objs.append(snap_obj)
|
||||||
return snap_obj
|
return snap_obj
|
||||||
snap_obj = SnapObj(obj)
|
snap_obj = SnapObj(obj)
|
||||||
cls.snap_objs.append(snap_obj)
|
cls.snap_objs.append(snap_obj)
|
||||||
|
|||||||
@@ -132,12 +132,14 @@ class TestPush:
|
|||||||
|
|
||||||
class TestRefreshRevisionList:
|
class TestRefreshRevisionList:
|
||||||
def test_refreshes_when_repo_has_heads(self, ifcgit, ifc):
|
def test_refreshes_when_repo_has_heads(self, ifcgit, ifc):
|
||||||
|
ifcgit.clear_merge_conflicts().should_be_called()
|
||||||
ifcgit.repo_has_commits().should_be_called().will_return(True)
|
ifcgit.repo_has_commits().should_be_called().will_return(True)
|
||||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||||
subject.refresh_revision_list(ifcgit, ifc)
|
subject.refresh_revision_list(ifcgit, ifc)
|
||||||
|
|
||||||
def test_skips_when_repo_has_no_heads(self, ifcgit, ifc):
|
def test_skips_when_repo_has_no_heads(self, ifcgit, ifc):
|
||||||
|
ifcgit.clear_merge_conflicts().should_be_called()
|
||||||
ifcgit.repo_has_commits().should_be_called().will_return(False)
|
ifcgit.repo_has_commits().should_be_called().will_return(False)
|
||||||
subject.refresh_revision_list(ifcgit, ifc)
|
subject.refresh_revision_list(ifcgit, ifc)
|
||||||
# nothing else should be called — Prophecy will verify
|
# nothing else should be called — Prophecy will verify
|
||||||
@@ -194,7 +196,9 @@ class TestMergeBranch:
|
|||||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
ifcgit.git_merge("feature").should_be_called().will_return(None)
|
ifcgit.git_merge("feature").should_be_called().will_return(None)
|
||||||
|
ifcgit.clear_merge_conflicts().should_be_called()
|
||||||
ifcgit.set_display_branch().should_be_called()
|
ifcgit.set_display_branch().should_be_called()
|
||||||
|
ifcgit.git_checkout("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.decolourise().should_be_called()
|
ifcgit.decolourise().should_be_called()
|
||||||
@@ -206,25 +210,29 @@ class TestMergeBranch:
|
|||||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
||||||
ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return(None)
|
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None)
|
||||||
ifcgit.commit_merge("path/to/model.ifc").should_be_called()
|
ifcgit.commit_merge("path/to/model.ifc").should_be_called()
|
||||||
|
ifcgit.clear_merge_conflicts().should_be_called()
|
||||||
ifcgit.set_display_branch().should_be_called()
|
ifcgit.set_display_branch().should_be_called()
|
||||||
|
ifcgit.git_checkout("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
ifcgit.load_project("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called()
|
||||||
ifcgit.decolourise().should_be_called()
|
ifcgit.decolourise().should_be_called()
|
||||||
subject.merge_branch(ifcgit, ifc, operator=None)
|
subject.merge_branch(ifcgit, ifc, operator=None)
|
||||||
|
|
||||||
def test_conflict_mergetool_failure(self, ifcgit, ifc):
|
def test_conflict_mergetool_failure(self, ifcgit, ifc):
|
||||||
|
conflicts = [{"type": "attribute_conflict", "entity_id": 42}]
|
||||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
ifcgit.config_ifcmerge().should_be_called()
|
ifcgit.config_ifcmerge().should_be_called()
|
||||||
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||||
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
ifcgit.git_merge("feature").should_be_called().will_return("conflict")
|
||||||
ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return("merge error")
|
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts)
|
||||||
ifcgit.git_merge_abort().should_be_called()
|
ifcgit.git_merge_abort().should_be_called()
|
||||||
|
ifcgit.store_merge_conflicts(conflicts).should_be_called()
|
||||||
op = MockOperator()
|
op = MockOperator()
|
||||||
subject.merge_branch(ifcgit, ifc, op)
|
subject.merge_branch(ifcgit, ifc, op)
|
||||||
assert op.reports == [({"ERROR"}, "IFC Merge failed:merge error")]
|
assert op.reports == [({"WARNING"}, "Merge failed — see the conflict report in the panel below")]
|
||||||
|
|
||||||
def test_unknown_merge_error(self, ifcgit, ifc):
|
def test_unknown_merge_error(self, ifcgit, ifc):
|
||||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
@@ -237,6 +245,53 @@ class TestMergeBranch:
|
|||||||
assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")]
|
assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDryRunMerge:
|
||||||
|
def test_no_branch_at_selected_commit(self, ifcgit, ifc):
|
||||||
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
|
ifcgit.config_ifcmerge().should_be_called()
|
||||||
|
ifcgit.get_selected_branch().should_be_called().will_return(None)
|
||||||
|
subject.dry_run_merge(ifcgit, ifc, operator=None)
|
||||||
|
|
||||||
|
def test_clean_merge_preview(self, ifcgit, ifc):
|
||||||
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
|
ifcgit.config_ifcmerge().should_be_called()
|
||||||
|
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||||
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
|
ifcgit.git_merge_no_commit("feature").should_be_called().will_return(None)
|
||||||
|
ifcgit.git_merge_abort().should_be_called()
|
||||||
|
ifcgit.clear_merge_conflicts().should_be_called()
|
||||||
|
op = MockOperator()
|
||||||
|
subject.dry_run_merge(ifcgit, ifc, op)
|
||||||
|
assert op.reports == [({"INFO"}, "Merge preview: no conflicts")]
|
||||||
|
|
||||||
|
def test_conflict_preview_shows_report(self, ifcgit, ifc):
|
||||||
|
conflicts = [{"type": "attribute_conflict", "entity_id": 42}]
|
||||||
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
|
ifcgit.config_ifcmerge().should_be_called()
|
||||||
|
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||||
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
|
ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict")
|
||||||
|
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts)
|
||||||
|
ifcgit.git_merge_abort().should_be_called()
|
||||||
|
ifcgit.store_merge_conflicts(conflicts).should_be_called()
|
||||||
|
op = MockOperator()
|
||||||
|
subject.dry_run_merge(ifcgit, ifc, op)
|
||||||
|
assert op.reports == [({"WARNING"}, "Merge preview: conflicts found — see the panel below")]
|
||||||
|
|
||||||
|
def test_conflict_preview_mergetool_succeeds(self, ifcgit, ifc):
|
||||||
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
|
ifcgit.config_ifcmerge().should_be_called()
|
||||||
|
ifcgit.get_selected_branch().should_be_called().will_return("feature")
|
||||||
|
ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward")
|
||||||
|
ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict")
|
||||||
|
ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None)
|
||||||
|
ifcgit.git_merge_abort().should_be_called()
|
||||||
|
ifcgit.clear_merge_conflicts().should_be_called()
|
||||||
|
op = MockOperator()
|
||||||
|
subject.dry_run_merge(ifcgit, ifc, op)
|
||||||
|
assert op.reports == [({"INFO"}, "Merge preview: no conflicts")]
|
||||||
|
|
||||||
|
|
||||||
class TestEntityLog:
|
class TestEntityLog:
|
||||||
def test_run(self, ifcgit, ifc):
|
def test_run(self, ifcgit, ifc):
|
||||||
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
ifc.get_path().should_be_called().will_return("path/to/model.ifc")
|
||||||
|
|||||||
@@ -454,3 +454,128 @@ class TestIfcDiffIds(NewFile):
|
|||||||
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path)
|
||||||
assert 1 in result["modified"]
|
assert 1 in result["modified"]
|
||||||
assert 2 in result["modified"]
|
assert 2 in result["modified"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Merge conflict report — store / clear / get
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStoreClearGetMergeConflicts(NewFile):
|
||||||
|
def test_round_trip(self):
|
||||||
|
conflicts = [{"type": "attribute_conflict", "entity_id": 42}]
|
||||||
|
IfcGit.store_merge_conflicts(conflicts)
|
||||||
|
result = IfcGit.get_merge_conflicts()
|
||||||
|
assert result == conflicts
|
||||||
|
|
||||||
|
def test_get_returns_none_when_empty(self):
|
||||||
|
IfcGit.clear_merge_conflicts()
|
||||||
|
assert IfcGit.get_merge_conflicts() is None
|
||||||
|
|
||||||
|
def test_clear_removes_stored_conflicts(self):
|
||||||
|
IfcGit.store_merge_conflicts([{"type": "class_changed"}])
|
||||||
|
IfcGit.clear_merge_conflicts()
|
||||||
|
assert IfcGit.get_merge_conflicts() is None
|
||||||
|
|
||||||
|
def test_get_returns_none_on_corrupt_json(self):
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
bpy.context.scene.IfcGitProperties.merge_conflicts = "not valid json {"
|
||||||
|
assert IfcGit.get_merge_conflicts() is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# git_mergetool — report file reading
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitMergetool:
|
||||||
|
@requires_git
|
||||||
|
def test_returns_none_when_report_file_absent(self):
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||||
|
mock_repo = mock.MagicMock()
|
||||||
|
IfcGitRepo.repo = mock_repo
|
||||||
|
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||||
|
assert result is None
|
||||||
|
IfcGitRepo.repo = None
|
||||||
|
|
||||||
|
@requires_git
|
||||||
|
def test_returns_none_when_report_file_empty(self):
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||||
|
report_path = ifc_path + ".ifcmerge"
|
||||||
|
open(report_path, "w").close()
|
||||||
|
mock_repo = mock.MagicMock()
|
||||||
|
IfcGitRepo.repo = mock_repo
|
||||||
|
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||||
|
assert result is None
|
||||||
|
assert not os.path.exists(report_path)
|
||||||
|
IfcGitRepo.repo = None
|
||||||
|
|
||||||
|
@requires_git
|
||||||
|
def test_parses_conflict_report_and_deletes_file(self):
|
||||||
|
import json
|
||||||
|
import unittest.mock as mock
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ifc_path = os.path.join(tmpdir, "model.ifc")
|
||||||
|
report_path = ifc_path + ".ifcmerge"
|
||||||
|
conflicts = [{"type": "attribute_conflict", "entity_id": 5}]
|
||||||
|
with open(report_path, "w") as f:
|
||||||
|
json.dump({"status": "failed", "conflicts": conflicts}, f)
|
||||||
|
mock_repo = mock.MagicMock()
|
||||||
|
mock_repo.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1)
|
||||||
|
IfcGitRepo.repo = mock_repo
|
||||||
|
result = IfcGit.git_mergetool("ifcmerge", ifc_path)
|
||||||
|
assert result == conflicts
|
||||||
|
assert not os.path.exists(report_path)
|
||||||
|
IfcGitRepo.repo = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# config_ifcmerge — cmd format and update
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigIfcmerge:
|
||||||
|
@requires_git
|
||||||
|
def test_writes_redirect_cmd_on_first_call(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
repo = _make_repo(tmpdir)
|
||||||
|
IfcGitRepo.repo = repo
|
||||||
|
IfcGit.config_ifcmerge()
|
||||||
|
reader = repo.config_reader()
|
||||||
|
cmd = reader.get_value('mergetool "ifcmerge"', "cmd")
|
||||||
|
assert "> $MERGED.ifcmerge" in cmd
|
||||||
|
IfcGitRepo.repo = None
|
||||||
|
|
||||||
|
@requires_git
|
||||||
|
def test_updates_cmd_missing_redirect(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
repo = _make_repo(tmpdir)
|
||||||
|
IfcGitRepo.repo = repo
|
||||||
|
with repo.config_writer() as w:
|
||||||
|
w.set_value('mergetool "ifcmerge"', "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED")
|
||||||
|
w.set_value('mergetool "ifcmerge"', "trustExitCode", True)
|
||||||
|
IfcGit.config_ifcmerge()
|
||||||
|
reader = repo.config_reader()
|
||||||
|
cmd = reader.get_value('mergetool "ifcmerge"', "cmd")
|
||||||
|
assert "> $MERGED.ifcmerge" in cmd
|
||||||
|
IfcGitRepo.repo = None
|
||||||
|
|
||||||
|
@requires_git
|
||||||
|
def test_forward_tool_writes_redirect_cmd(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
repo = _make_repo(tmpdir)
|
||||||
|
IfcGitRepo.repo = repo
|
||||||
|
IfcGit.config_ifcmerge()
|
||||||
|
reader = repo.config_reader()
|
||||||
|
cmd = reader.get_value('mergetool "ifcmerge-forward"', "cmd")
|
||||||
|
assert "--prioritise-local" in cmd
|
||||||
|
assert "> $MERGED.ifcmerge" in cmd
|
||||||
|
IfcGitRepo.repo = None
|
||||||
|
|||||||
@@ -146,15 +146,22 @@ function toChatCompletionResponse(response) {
|
|||||||
|
|
||||||
export async function chat({ apiKey, model, messages, tools }) {
|
export async function chat({ apiKey, model, messages, tools }) {
|
||||||
const request = splitSystemAndMessages(messages);
|
const request = splitSystemAndMessages(messages);
|
||||||
|
const anthropicTools = toAnthropicTools(tools);
|
||||||
|
|
||||||
|
// Mark the last tool with cache_control so the entire tool list is cached
|
||||||
|
if (anthropicTools.length > 0) {
|
||||||
|
anthropicTools[anthropicTools.length - 1].cache_control = { type: "ephemeral" };
|
||||||
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
model,
|
model,
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
messages: request.messages,
|
messages: request.messages,
|
||||||
tools: toAnthropicTools(tools),
|
tools: anthropicTools,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.system) {
|
if (request.system) {
|
||||||
body.system = request.system;
|
body.system = [{ type: "text", text: request.system, cache_control: { type: "ephemeral" } }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||||
|
|||||||
+29
-4
@@ -418,8 +418,8 @@ function callWorker(type, payload = {}) {
|
|||||||
// ---- Tool schemas (should match ifcmcp.core openai_tools()) ----
|
// ---- Tool schemas (should match ifcmcp.core openai_tools()) ----
|
||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory.",
|
type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory. Valid schemas: IFC4, IFC2X3, IFC4X3 (for IFC 4.3).",
|
||||||
parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false } }
|
parameters: { type: "object", properties: { schema: { type: "string", enum: ["IFC4", "IFC2X3", "IFC4X3"] } }, required: [], additionalProperties: false } }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
|
type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
|
||||||
@@ -497,6 +497,27 @@ Be concise. Avoid dumping huge trees unless asked.
|
|||||||
|
|
||||||
let messages = []; // running conversation state (Chat Completions style)
|
let messages = []; // running conversation state (Chat Completions style)
|
||||||
|
|
||||||
|
const MAX_TOOL_RESULT_CHARS = 0;
|
||||||
|
const MAX_HISTORY_MESSAGES = 40;
|
||||||
|
|
||||||
|
function truncateToolResult(text) {
|
||||||
|
if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text;
|
||||||
|
return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimHistory() {
|
||||||
|
if (messages.length <= MAX_HISTORY_MESSAGES) return;
|
||||||
|
// Find a safe cut point — don't break mid-tool-call sequence.
|
||||||
|
// Walk forward from the trim target to find a user message boundary.
|
||||||
|
let cut = messages.length - MAX_HISTORY_MESSAGES;
|
||||||
|
while (cut < messages.length && messages[cut].role !== "user") {
|
||||||
|
cut++;
|
||||||
|
}
|
||||||
|
if (cut > 0 && cut < messages.length) {
|
||||||
|
messages.splice(0, cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runAgentTurn(userText) {
|
async function runAgentTurn(userText) {
|
||||||
const apiKey = apiKeyEl.value.trim();
|
const apiKey = apiKeyEl.value.trim();
|
||||||
if (!apiKey) throw new Error("Missing API key");
|
if (!apiKey) throw new Error("Missing API key");
|
||||||
@@ -506,6 +527,7 @@ async function runAgentTurn(userText) {
|
|||||||
const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined;
|
const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined;
|
||||||
|
|
||||||
messages.push({ role: "user", content: userText });
|
messages.push({ role: "user", content: userText });
|
||||||
|
trimHistory();
|
||||||
|
|
||||||
for (let i = 0; i < 64; i++) {
|
for (let i = 0; i < 64; i++) {
|
||||||
const response = await chat({
|
const response = await chat({
|
||||||
@@ -535,12 +557,15 @@ async function runAgentTurn(userText) {
|
|||||||
|
|
||||||
const toolRes = await callWorker("toolCall", { name: call.function.name, args });
|
const toolRes = await callWorker("toolCall", { name: call.function.name, args });
|
||||||
|
|
||||||
|
const fullResult = JSON.stringify(toolRes.result);
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
tool_call_id: call.id,
|
tool_call_id: call.id,
|
||||||
content: JSON.stringify(toolRes.result),
|
content: truncateToolResult(fullResult),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show full result in UI, but only truncated version goes to the LLM
|
||||||
addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
|
addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -588,7 +613,7 @@ ifcFileEl.onchange = async () => {
|
|||||||
newBtn.onclick = async () => {
|
newBtn.onclick = async () => {
|
||||||
try {
|
try {
|
||||||
setBusy(true, "Creating new model…");
|
setBusy(true, "Creating new model…");
|
||||||
const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } });
|
const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4X3" } });
|
||||||
addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
|
addMessage("assistant", `New model: ${JSON.stringify(r.result)}`);
|
||||||
setBusy(false, "Ready");
|
setBusy(false, "Ready");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user