diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index f21f1b42ac..b517bc572b 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -48,6 +48,7 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3) VERSION_DATE:=$(shell date '+%y%m%d') LAST_COMMIT_HASH:=$(shell git rev-parse HEAD) LAST_COMMIT_DATE:=$(shell git show -s --format=%cI) +LAST_GIT_BRANCH:=$(shell git rev-parse --abbrev-ref HEAD) PYPI_IMP:=cp ifdef PYVERSION @@ -63,6 +64,7 @@ PYNUMBER:=3$(PYMINOR) PYPI_VERSION:=3.$(PYMINOR) endif # def PYVERSION +IFCMERGE_VERSION:=2026-04-02 ifdef PLATFORM SUPPORTED_PLATFORMS := linux macos macosm1 win @@ -239,10 +241,9 @@ endif # required for three-way git merging ifeq ($(PLATFORM), win) - cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/2025-01-26/ifcmerge.zip - cd build/bonsai/libs/bin && unzip ifcmerge.zip && rm ifcmerge.zip + cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/$(IFCMERGE_VERSION)/ifcmerge.exe else - cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/main/ifcmerge && chmod +x ifcmerge + cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/$(IFCMERGE_VERSION)/ifcmerge && chmod +x ifcmerge endif # Generate translations module for Bonsai build @@ -261,6 +262,7 @@ else $(SED) "s/0.0.0/$(VERSION)-alpha$(VERSION_DATE)/" build/bonsai/blender_manifest.toml $(SED) "s/8888888/$(LAST_COMMIT_HASH)/" build/bonsai/__init__.py $(SED) "s/9999999/$(LAST_COMMIT_DATE)/" build/bonsai/__init__.py + $(SED) "s/7777777/$(LAST_GIT_BRANCH)/" build/bonsai/__init__.py $(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml endif diff --git a/src/bonsai/bonsai/__init__.py b/src/bonsai/bonsai/__init__.py index 915071855f..c6ec6405c0 100644 --- a/src/bonsai/bonsai/__init__.py +++ b/src/bonsai/bonsai/__init__.py @@ -43,6 +43,7 @@ from typing import TYPE_CHECKING, Any, Union last_commit_hash = "8888888" last_commit_date = "9999999" +last_git_branch = "7777777" def get_last_commit_hash() -> Union[str, None]: @@ -60,6 +61,15 @@ def get_last_commit_date() -> Union[str, None]: return last_commit_date +def get_git_branch() -> Union[str, None]: + # Using this weird way to write 7777777, + # so makefile won't accidentally replace it here + # we'll be able to distinguish branch from placeholder value. + if last_git_branch == str(7_777777): + return None + return last_git_branch + + # Accessed from bonsai extension: bbim_semver: dict[str, Any] = {} @@ -125,6 +135,7 @@ def get_debug_info(*, bonsai_failed_to_load: bool = False) -> dict[str, Any]: "bonsai_version": bbim_version, "bonsai_commit_hash": get_last_commit_hash(), "bonsai_commit_date": get_last_commit_date(), + "bonsai_git_branch": get_git_branch(), "last_actions": last_actions, "last_error": last_error, } @@ -251,10 +262,12 @@ if IN_BLENDER: global last_commit_hash global last_commit_date + global last_git_branch path = Path(__file__).resolve().parent repo = git.Repo(str(path), search_parent_directories=True) last_commit_hash = repo.head.object.hexsha last_commit_date = repo.head.object.committed_datetime.isoformat() + last_git_branch = repo.active_branch.name except: pass diff --git a/src/bonsai/bonsai/bim/module/ifcgit/data.py b/src/bonsai/bonsai/bim/module/ifcgit/data.py index 92da517a1c..7dbd26b5e0 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/data.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/data.py @@ -21,65 +21,71 @@ class IfcGitData: @classmethod def load(cls): + repo = None + if bool(tool.Ifc.get()): + path_ifc = tool.Ifc.get_path() + if os.path.isfile(path_ifc): + repo = tool.IfcGit.repo_from_path(path_ifc) + cls.data = { - "repo": cls.repo(), - "remotes": cls.remotes(), - "branch_names": cls.branch_names(), - "remote_names": cls.remote_names(), - "remote_urls": cls.remote_urls(), + "repo": repo, + "remotes": repo.remotes if repo else None, + "branch_names": cls.branch_names(repo), + "tag_names": cls.tag_names(repo), + "remote_names": cls.remote_names(repo), + "remote_urls": {r.name: r.url for r in repo.remotes} if repo else {}, "path_ifc": cls.path_ifc(), "branches_by_hexsha": cls.branches_by_hexsha(), "tags_by_hexsha": cls.tags_by_hexsha(), - "name_ifc": cls.name_ifc(), + "name_ifc": cls.name_ifc(repo), "dir_name": cls.dir_name(), "base_name": cls.base_name(), - "working_dir": cls.working_dir(), - "untracked_files": cls.untracked_files(), - "is_detached": cls.is_detached(), - "active_branch_name": cls.active_branch_name(), - "is_dirty": cls.is_dirty(), - "commit": cls.commit(), - "current_revision": cls.current_revision(), + "working_dir": repo.working_dir if repo else None, + "ifc_is_untracked": cls.ifc_is_untracked(repo), + "is_detached": repo.head.is_detached if repo else None, + "active_branch_name": repo.active_branch.name if repo and not repo.head.is_detached else None, + "is_dirty": cls.is_dirty(repo), + "current_revision": cls.current_revision(repo), "git_exe": cls.git_exe(), "ifcmerge_exe": cls.ifcmerge_exe(), } cls.is_loaded = True @classmethod - def repo(cls): - if bool(tool.Ifc.get()): - path_ifc = tool.Ifc.get_path() - if os.path.isfile(path_ifc): - return tool.IfcGit.repo_from_path(path_ifc) - return None + def branch_names(cls, repo): + if not repo or not repo.heads: + return [] + names = sorted([b.name for b in repo.branches]) + if "main" in names: + names.remove("main") + names = ["main"] + names + if repo.remotes: + for remote in repo.remotes: + for ref in remote.refs: + names.append(ref.name) + return names @classmethod - def remotes(cls): - if cls.repo(): - return cls.repo().remotes - return None + def tag_names(cls, repo): + if not repo: + return [] + return [t.name for t in repo.tags] @classmethod - def branch_names(cls): - return [] - - @classmethod - def remote_names(cls): - return [] - - @classmethod - def remote_urls(cls): - result = {} - if cls.repo(): - for remote in cls.repo().remotes: - result[remote.name] = remote.url - return result + def remote_names(cls, repo): + if not repo: + return [] + names = sorted([r.name for r in repo.remotes]) + if "origin" in names: + names.remove("origin") + names = ["origin"] + names + return names @classmethod def path_ifc(cls): path_ifc = tool.Ifc.get_path() if os.path.isfile(path_ifc): - return tool.Ifc.get_path() + return path_ifc return None @classmethod @@ -88,7 +94,8 @@ class IfcGitData: if tool.IfcGitRepo.repo.branches: return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo) except AttributeError: - return {} + pass + return {} @classmethod def tags_by_hexsha(cls): @@ -97,12 +104,11 @@ class IfcGitData: return {} @classmethod - def name_ifc(cls): - if bool(tool.Ifc.get()): + def name_ifc(cls, repo): + if bool(tool.Ifc.get()) and repo: path_ifc = tool.Ifc.get_path() - if tool.IfcGitRepo.repo and os.path.isfile(path_ifc): - working_dir = tool.IfcGitRepo.repo.working_dir - return os.path.relpath(path_ifc, working_dir) + if os.path.isfile(path_ifc): + return os.path.relpath(path_ifc, repo.working_dir) return None @classmethod @@ -122,49 +128,28 @@ class IfcGitData: return None @classmethod - def working_dir(cls): - if cls.repo(): - return cls.repo().working_dir + def ifc_is_untracked(cls, repo): + """Return True if the IFC file exists in the repo but has not been added to git.""" + if not repo: + return False + path_ifc = tool.Ifc.get_path() + if not os.path.isfile(path_ifc): + return False + return not bool(repo.git.ls_files(path_ifc)) @classmethod - def untracked_files(cls): - if cls.repo(): - return cls.repo().untracked_files - return [] - - @classmethod - def is_detached(cls): - if cls.repo(): - return cls.repo().head.is_detached - - @classmethod - def active_branch_name(cls): - if cls.repo() and not cls.is_detached(): - return cls.repo().active_branch.name - - @classmethod - def is_dirty(cls): - if cls.repo() and cls.git_exe(): + def is_dirty(cls, repo): + if repo and cls.git_exe(): path_ifc = tool.Ifc.get_path() if os.path.isfile(path_ifc): - return cls.repo().is_dirty(path=path_ifc) + return repo.is_dirty(path=path_ifc) return False @classmethod - def commit(cls): + def current_revision(cls, repo): props = tool.IfcGit.get_ifcgit_props() - if cls.repo() and len(props.ifcgit_commits) > 0: - item = props.ifcgit_commits[props.commit_index] - try: - return cls.repo().commit(rev=item.hexsha) - except ValueError: - return - - @classmethod - def current_revision(cls): - props = tool.IfcGit.get_ifcgit_props() - if cls.repo() and cls.repo().head.is_valid() and len(props.ifcgit_commits) > 0: - return tool.IfcGitRepo.repo.commit() + if repo and repo.head.is_valid() and len(props.ifcgit_commits) > 0: + return repo.commit() @classmethod def git_exe(cls): diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 829a0a62a2..5f01ef78ea 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -120,11 +120,11 @@ class CommitChanges(bpy.types.Operator): if props.commit_message == "": return False if repo: - if props.new_branch_name in [branch.name for branch in repo.branches]: + if props.new_branch_name in IfcGitData.data["branch_names"]: cls.poll_message_set("Branch already exists!") return False elif not tool.IfcGit.is_valid_ref_format(props.new_branch_name): - if repo.head.is_detached: + if IfcGitData.data["is_detached"]: cls.poll_message_set("Branch name is invalid or empty!") return False elif props.new_branch_name != "": @@ -134,10 +134,17 @@ class CommitChanges(bpy.types.Operator): def execute(self, context): - repo = IfcGitData.data["repo"] - core.commit_changes(tool.IfcGit, tool.Ifc, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + commit_message = props.commit_message + new_branch_name = props.new_branch_name + core.commit_changes(tool.IfcGit, tool.Ifc, commit_message, new_branch_name) + props.new_branch_name = "" + props.commit_message = "" + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() + IfcGitData.load() + if new_branch_name: + props.display_branch = new_branch_name return {"FINISHED"} @@ -157,7 +164,7 @@ class AddTag(bpy.types.Operator): repo = IfcGitData.data["repo"] if repo and ( not tool.IfcGit.is_valid_ref_format(props.new_tag_name) - or props.new_tag_name in [tag.name for tag in repo.tags] + or props.new_tag_name in IfcGitData.data["tag_names"] ): return False return True @@ -165,8 +172,12 @@ class AddTag(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.add_tag(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + item = props.ifcgit_commits[props.commit_index] + core.add_tag(tool.IfcGit, repo, item.hexsha, props.new_tag_name, props.new_tag_message) + props.new_tag_name = "" + props.new_tag_message = "" + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -183,7 +194,7 @@ class DeleteTag(bpy.types.Operator): repo = IfcGitData.data["repo"] core.delete_tag(tool.IfcGit, repo, self.tag_name) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -205,8 +216,7 @@ class RefreshGit(bpy.types.Operator): def execute(self, context): - repo = IfcGitData.data["repo"] - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() tool.IfcGit.decolourise() return {"FINISHED"} @@ -284,7 +294,7 @@ class Merge(bpy.types.Operator): def execute(self, context): - if core.merge_branch(tool.IfcGit, tool.Ifc, self): + if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False: refresh() return {"FINISHED"} else: @@ -314,9 +324,9 @@ class Fetch(bpy.types.Operator): def execute(self, context): props = tool.IfcGit.get_ifcgit_props() - repo = IfcGitData.data["repo"] - remote = repo.remotes[props.select_remote] - remote.fetch() + core.fetch(tool.IfcGit, props.select_remote) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) + refresh() return {"FINISHED"} @@ -336,7 +346,7 @@ class AddRemote(bpy.types.Operator): not repo or not tool.IfcGit.is_valid_ref_format(props.remote_name) or not props.remote_url - or props.remote_name in [remote.name for remote in repo.remotes] + or props.remote_name in IfcGitData.data["remote_names"] ): return False return True @@ -344,8 +354,11 @@ class AddRemote(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.add_remote(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + core.add_remote(tool.IfcGit, repo, props.remote_name, props.remote_url) + props.remote_name = "" + props.remote_url = "" + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -360,8 +373,19 @@ class DeleteRemote(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.delete_remote(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + remote_name = props.select_remote + if props.display_branch.startswith(remote_name + "/"): + active = IfcGitData.data["active_branch_name"] + if active: + props.display_branch = active + else: + local_branches = [b for b in IfcGitData.data["branch_names"] if "/" not in b] + if local_branches: + props.display_branch = local_branches[0] + core.delete_remote(tool.IfcGit, repo, remote_name) + tool.IfcGit.select_first_remote() + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -375,8 +399,8 @@ class ObjectLog(bpy.types.Operator): @classmethod def poll(cls, context): - if not (obj := context.active_object): - cls.poll_message_set("No Active Object") + if not (obj := context.active_object) or not obj.select_get(): + cls.poll_message_set("No selected object") elif not tool.Blender.get_ifc_definition_id(obj): cls.poll_message_set("Active Object doesn't have an IFC definition") else: diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py index cba3ca632c..865cb4eacf 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py @@ -17,28 +17,14 @@ from bonsai.bim.module.ifcgit.data import IfcGitData def git_branches(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: # NOTE "Python must keep a reference to the strings returned by # the callback or Blender will misbehave or even crash" - IfcGitData.data["branch_names"] = sorted([branch.name for branch in IfcGitData.data["repo"].heads]) - - if "main" in IfcGitData.data["branch_names"]: - IfcGitData.data["branch_names"].remove("main") - IfcGitData.data["branch_names"] = ["main"] + IfcGitData.data["branch_names"] - - if IfcGitData.data["remotes"]: - for remote in IfcGitData.data["remotes"]: - for remote_branch in remote.refs: - IfcGitData.data["branch_names"].append(remote_branch.name) - - return [(myname, myname, myname) for myname in IfcGitData.data["branch_names"]] + # Branch list (local + remote, main first) is computed once in IfcGitData.load() + IfcGitData.make_sure_is_loaded() + return [(name, name, name) for name in IfcGitData.data["branch_names"]] def git_remotes(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: - IfcGitData.data["remote_names"] = sorted([remote.name for remote in IfcGitData.data["remotes"]]) - - if "origin" in IfcGitData.data["remote_names"]: - IfcGitData.data["remote_names"].remove("origin") - IfcGitData.data["remote_names"] = ["origin"] + IfcGitData.data["remote_names"] - - return [(myname, myname, myname) for myname in IfcGitData.data["remote_names"]] + IfcGitData.make_sure_is_loaded() + return [(name, name, name) for name in IfcGitData.data["remote_names"]] def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None: @@ -90,6 +76,7 @@ class IfcGitListItem(PropertyGroup): name="Commit Message", default="", ) + committed_date: IntProperty(name="Committed Date", default=0) tags: CollectionProperty(type=IfcGitTag, name="List of revision tags") if TYPE_CHECKING: @@ -98,6 +85,7 @@ class IfcGitListItem(PropertyGroup): author_name: str author_email: str message: str + committed_date: int tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag] diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index d8b7357e7b..6ac4aceb74 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -52,7 +52,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir): name_ifc = IfcGitData.data["name_ifc"] row.label(text=IfcGitData.data["working_dir"], icon="SYSTEM") - if name_ifc in IfcGitData.data["untracked_files"]: + if IfcGitData.data["ifc_is_untracked"]: row.operator( "ifcgit.addfile", text="Add '" + name_ifc + "' to repository", @@ -216,13 +216,7 @@ class COMMIT_UL_List(bpy.types.UIList): ): current_revision = IfcGitData.data["current_revision"] - - # TODO Figure how this "item" can be acesse in "data.py" - # so it's possible to move the ".commit" - try: - commit = IfcGitData.data["repo"].commit(rev=item.hexsha) - except ValueError: - return + current_hexsha = current_revision.hexsha if current_revision else None lookup = IfcGitData.data["branches_by_hexsha"] refs = "" @@ -236,11 +230,11 @@ class COMMIT_UL_List(bpy.types.UIList): for tag in lookup[item.hexsha]: refs += "{" + tag.name + "} " - if commit == current_revision: - layout.label(text="[HEAD] " + refs + commit.message.split("\n")[0], icon="DECORATE_KEYFRAME") + if item.hexsha == current_hexsha: + layout.label(text="[HEAD] " + refs + item.message.split("\n")[0], icon="DECORATE_KEYFRAME") else: - layout.label(text=refs + commit.message.split("\n")[0], icon="DECORATE_ANIMATE") - layout.label(text=time.strftime("%c", time.localtime(commit.committed_date))) + layout.label(text=refs + item.message.split("\n")[0], icon="DECORATE_ANIMATE") + layout.label(text=time.strftime("%c", time.localtime(item.committed_date))) def draw_filter(self, context, layout): diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index fb23652ebc..f14dda6cbb 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -56,42 +56,45 @@ def discard_uncommitted(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: ifcgit.load_project(path_ifc) -def commit_changes(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], repo: git.Repo) -> None: +def commit_changes( + ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], commit_message: str, new_branch_name: str = "" +) -> None: """Commit and create new branches as required""" path_ifc = ifc.get_path() - if repo.head.is_detached: - ifcgit.git_commit(path_ifc) - ifcgit.create_new_branch() + if ifcgit.is_head_detached(): + ifcgit.git_commit(path_ifc, commit_message) + ifcgit.create_new_branch(new_branch_name) else: - ifcgit.checkout_new_branch(path_ifc) - ifcgit.git_commit(path_ifc) + if new_branch_name: + ifcgit.checkout_new_branch(path_ifc, new_branch_name) + ifcgit.git_commit(path_ifc, commit_message) -def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.add_tag(repo) +def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None: + ifcgit.add_tag(repo, hexsha, tag_name, tag_message) def delete_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, tag_name: git.TagReference) -> None: ifcgit.delete_tag(repo, tag_name) -def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.add_remote(repo) +def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, remote_url: str) -> None: + ifcgit.add_remote(repo, remote_name, remote_url) -def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.delete_remote(repo) +def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -> None: + ifcgit.delete_remote(repo, remote_name) def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None: - error_message = ifcgit.push(repo, remote_name, repo.active_branch.name) + error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name()) if error_message: operator.report({"ERROR"}, error_message) -def refresh_revision_list(ifcgit: type[tool.IfcGit], repo: git.Repo, ifc: type[tool.Ifc]) -> None: - if repo.heads: +def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: + if ifcgit.repo_has_commits(): ifcgit.refresh_revision_list(ifc.get_path()) @@ -125,10 +128,32 @@ def switch_revision(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: ifcgit.decolourise() -def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: +def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> bool | None: path_ifc = ifc.get_path() ifcgit.config_ifcmerge() - ifcgit.execute_merge(path_ifc, operator) + + branch_name = ifcgit.get_selected_branch() + if branch_name is None: + return + + mergetool = ifcgit.get_merge_tool(branch_name) + merge_result = ifcgit.git_merge(branch_name) + + if merge_result == "error": + operator.report({"ERROR"}, "Unknown IFC Merge failure") + return False + elif merge_result == "conflict": + error = ifcgit.git_mergetool(mergetool) + if error: + ifcgit.git_merge_abort() + operator.report({"ERROR"}, "IFC Merge failed:" + error) + return False + ifcgit.commit_merge(path_ifc) + + ifcgit.set_display_branch() + ifcgit.load_project(path_ifc) + ifcgit.refresh_revision_list(path_ifc) + ifcgit.decolourise() def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None: @@ -145,5 +170,9 @@ def install_git(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator) -> None print("install_git() not implemented") +def fetch(ifcgit: type[tool.IfcGit], remote_name: str) -> None: + ifcgit.fetch(remote_name) + + def run_git_diff(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator, save_to_temp: bool) -> None: ifcgit.run_git_diff(operator, save_to_temp) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index f815bf0963..910ed79627 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -535,6 +535,56 @@ class Ifc: def get_all_element_occurrences(cls, element): pass +@interface +class IfcGit: + def add_file_to_repo(cls, repo, path_file): pass + def add_remote(cls, repo, remote_name, remote_url): pass + def add_tag(cls, repo, hexsha, tag_name, tag_message): pass + def branches_by_hexsha(cls, repo): pass + def checkout_new_branch(cls, path_file, branch_name): pass + def clear_commits_list(cls): pass + def clone_repo(cls, remote_url, local_folder): pass + def colourise(cls, step_ids): pass + def config_ifcmerge(cls): pass + def create_new_branch(cls, branch_name): pass + def decolourise(cls): pass + def delete_remote(cls, repo, remote_name): pass + def delete_tag(cls, repo, tag_name): pass + def dos2unix(cls, path_file): pass + def commit_merge(cls, path_ifc): pass + def entity_log(cls, path_ifc, step_id): pass + def fetch(cls, remote_name): pass + def get_commits_list(cls, path_ifc, lookup): pass + def get_merge_tool(cls, branch_name): pass + def get_selected_branch(cls): pass + def git_merge(cls, branch_name): pass + def git_merge_abort(cls): pass + def git_mergetool(cls, mergetool): pass + def set_display_branch(cls): pass + def get_active_branch_name(cls): pass + def get_ifcgit_props(cls): pass + def get_modified_step_ids(cls, step_ids): pass + def get_path_dir(cls, path_ifc): pass + def get_revisions_step_ids(cls): pass + def is_head_detached(cls): pass + def repo_has_commits(cls): pass + def git_checkout(cls, path_file): pass + def git_commit(cls, path_file, commit_message): pass + def ifc_diff_ids(cls, repo, hash_a, hash_b, path_ifc): pass + def init_repo(cls, path_dir): pass + def install_git_windows(cls, operator): pass + def is_valid_ref_format(cls, string): pass + def load_anyifc(cls, repo): pass + def load_project(cls, path_ifc): pass + def push(cls, repo, remote_name, branch_name): pass + def refresh_revision_list(cls, path_ifc): pass + def repo_from_path(cls, path): pass + def run_git_diff(cls, operator, save_to_temp): pass + def switch_to_revision_item(cls): pass + def tags_by_hexsha(cls, repo): pass + def update_step_ids(cls, step_ids, modified_step_ids): pass + + @interface class Layer: pass diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 64b3957169..1843716f58 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -24,7 +24,7 @@ import re import subprocess import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Union +from typing import TYPE_CHECKING, Any, Union import bpy @@ -128,39 +128,27 @@ class IfcGit: cls.dos2unix(path_file) repo.index.add(os.path.normpath(path_file)) repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir)) - bpy.ops.ifcgit.refresh() @classmethod def git_checkout(cls, path_file: str) -> None: IfcGitRepo.repo.git.checkout(path_file) @classmethod - def checkout_new_branch(cls, path_file: str) -> None: + def checkout_new_branch(cls, path_file: str, branch_name: str) -> None: """Create a branch and move uncommitted changes to this branch""" - props = cls.get_ifcgit_props() - if props.new_branch_name: - IfcGitRepo.repo.git.checkout(b=props.new_branch_name) - props.display_branch = props.new_branch_name - props.new_branch_name = "" - bpy.ops.ifcgit.refresh() + IfcGitRepo.repo.git.checkout(b=branch_name) @classmethod - def git_commit(cls, path_file: str) -> None: - props = cls.get_ifcgit_props() + def git_commit(cls, path_file: str, commit_message: str) -> None: repo = IfcGitRepo.repo if os.name == "nt": cls.dos2unix(path_file) repo.index.add(os.path.normpath(path_file)) - repo.index.commit(message=props.commit_message) - props.commit_message = "" + repo.index.commit(message=commit_message) @classmethod - def add_tag(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - item = props.ifcgit_commits[props.commit_index] - repo.create_tag(props.new_tag_name, ref=item.hexsha, message=props.new_tag_message) - props.new_tag_name = "" - props.new_tag_message = "" + def add_tag(cls, repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None: + repo.create_tag(tag_name, ref=hexsha, message=tag_message) @classmethod def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None: @@ -168,20 +156,13 @@ class IfcGit: repo.delete_tag(tag_name) @classmethod - def add_remote(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - repo.create_remote(name=props.remote_name, url=props.remote_url) - props.remote_name = "" - props.remote_url = "" + def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None: + repo.create_remote(name=remote_name, url=remote_url) @classmethod - def delete_remote(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - remote_name = props.select_remote + def delete_remote(cls, repo: git.Repo, remote_name: str) -> None: if remote_name in repo.remotes: repo.delete_remote(remote_name) - if repo.remotes: - props.select_remote = repo.remotes[0].name @classmethod def push(cls, repo: git.Repo, remote_name: str, branch_name: str) -> Union[str, None]: @@ -193,16 +174,25 @@ class IfcGit: return exc.stderr @classmethod - def create_new_branch(cls) -> None: - """Convert a detached HEAD into a branch""" - props = cls.get_ifcgit_props() - repo = IfcGitRepo.repo - new_branch = repo.create_head(props.new_branch_name) - new_branch.checkout() - props.display_branch = props.new_branch_name - props.new_branch_name = "" + def is_head_detached(cls) -> bool: + return bool(IfcGitRepo.repo.head.is_detached) - bpy.ops.ifcgit.refresh() + @classmethod + def repo_has_commits(cls) -> bool: + if IfcGitRepo.repo: + return bool(IfcGitRepo.repo.heads) + return False + + @classmethod + def get_active_branch_name(cls) -> str: + return IfcGitRepo.repo.active_branch.name + + @classmethod + def create_new_branch(cls, branch_name: str) -> None: + """Convert a detached HEAD into a branch""" + repo = IfcGitRepo.repo + new_branch = repo.create_head(branch_name) + new_branch.checkout() @classmethod def clear_commits_list(cls) -> None: @@ -243,6 +233,7 @@ class IfcGit: list_item.message = commit.message list_item.author_name = commit.author.name list_item.author_email = commit.author.email + list_item.committed_date = int(commit.committed_date) if commit in commits_relevant: list_item.relevant = True if commit.hexsha in lookup: @@ -286,6 +277,10 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] + from bonsai.bim.module.root.data import IfcClassData + + IfcClassData.is_loaded = False + settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC")) settings.should_setup_viewport_camera = False ifc_importer = import_ifc.IfcImporter(settings) @@ -469,15 +464,6 @@ class IfcGit: bpy.data.objects.remove(obj, do_unlink=True) bpy.data.collections.remove(blender_collection) - @classmethod - def is_valid_branch_name(cls, new_branch_name: str): - """Check if a branch name is valid and doesn't conflict with existing branches""" - if not cls.is_valid_ref_format(new_branch_name): - return False - if new_branch_name in [branch.name for branch in IfcGitRepo.repo.branches]: - return False - return True - @classmethod def config_ifcmerge(cls) -> None: config_reader = IfcGitRepo.repo.config_reader() @@ -519,49 +505,65 @@ class IfcGit: output.write(line + b"\n") @classmethod - def execute_merge(cls, path_ifc: str, operator: bpy.types.Operator) -> Union[None, Literal[False]]: + def get_selected_branch(cls) -> Union[str, None]: + """Return the name of the branch at the selected commit matching display_branch, or None.""" props = cls.get_ifcgit_props() repo = IfcGitRepo.repo item = props.ifcgit_commits[props.commit_index] lookup = cls.branches_by_hexsha(repo) - if item.hexsha in lookup: - for branch in lookup[item.hexsha]: - if branch.name == props.display_branch: - # this is a branch! - if re.match("^(origin/)?(HEAD|main|master)$", branch.name): - # preserve remote IDs in origin/main or main - mergetool = "ifcmerge" - else: - # rewrite remote IDs - mergetool = "ifcmerge-forward" - try: - # NOTE this is calling the git binary in a subprocess - repo.git.merge(branch) - except git.exc.GitCommandError: - # merge is expected to fail, run ifcmerge - try: - repo.git.mergetool(tool=mergetool) - except git.exc.GitCommandError as exc: - message = re.sub("( stderr: '|')", "", exc.stderr) - # ifcmerge failed, rollback - repo.git.merge(abort=True) + if item.hexsha not in lookup: + return None + for branch in lookup[item.hexsha]: + if branch.name == props.display_branch: + return branch.name + return None - operator.report({"ERROR"}, "IFC Merge failed:" + message) - return False - else: - if os.name == "nt": - cls.dos2unix(path_ifc) - repo.index.add(os.path.normpath(path_ifc)) - repo.git.commit("--no-edit") - except git.exc.GitError: - operator.report({"ERROR"}, "Unknown IFC Merge failure") - return False + @classmethod + def get_merge_tool(cls, branch_name: str) -> str: + if re.match("^(origin/)?(HEAD|main|master)$", branch_name): + return "ifcmerge" + return "ifcmerge-forward" - props.display_branch = repo.active_branch.name + @classmethod + def git_merge(cls, branch_name: str) -> Union[str, None]: + """Attempt a git merge. Returns None on clean merge, 'conflict' on expected + GitCommandError, or 'error' on an unknown GitError.""" + repo = IfcGitRepo.repo + branch = repo.branches[branch_name] + try: + repo.git.merge(branch) + return None + except git.exc.GitCommandError: + return "conflict" + except git.exc.GitError: + return "error" - cls.load_project(path_ifc) - cls.refresh_revision_list(path_ifc) - cls.decolourise() + @classmethod + def git_mergetool(cls, mergetool: str) -> Union[str, None]: + """Run ifcmerge tool. Returns None on success, error message string on failure.""" + repo = IfcGitRepo.repo + try: + repo.git.mergetool(tool=mergetool) + return None + except git.exc.GitCommandError as exc: + return re.sub("( stdout: '|')", "", exc.stdout) + + @classmethod + def git_merge_abort(cls) -> None: + IfcGitRepo.repo.git.merge(abort=True) + + @classmethod + def commit_merge(cls, path_ifc: str) -> None: + repo = IfcGitRepo.repo + if os.name == "nt": + cls.dos2unix(path_ifc) + repo.index.add(os.path.normpath(path_ifc)) + repo.git.commit("--no-edit") + + @classmethod + def set_display_branch(cls) -> None: + props = cls.get_ifcgit_props() + props.display_branch = IfcGitRepo.repo.active_branch.name @classmethod def entity_log(cls, path_ifc: str, step_id: int) -> str: @@ -589,6 +591,18 @@ class IfcGit: except FileNotFoundError: operator.report({"ERROR"}, "Winget is not available. Make sure Windows Package Manager is installed.") + @classmethod + def select_first_remote(cls) -> None: + props = cls.get_ifcgit_props() + repo = IfcGitRepo.repo + if repo and repo.remotes: + props.select_remote = repo.remotes[0].name + + @classmethod + def fetch(cls, remote_name: str) -> None: + repo = IfcGitRepo.repo + repo.remotes[remote_name].fetch() + @classmethod def run_git_diff(cls, operator: bpy.types.Operator, save_to_temp: bool) -> None: path = tool.Ifc.get_path() diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index c16bc55564..6b613aecaf 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -434,11 +434,8 @@ class Raycast(bonsai.core.tool.Raycast): seg_len_sq = sx * sx + sy * sy if seg_len_sq == 0.0: - # degenerate segment: return distance to p0 - dx = px - p0x - dy = py - p0y - dist = math.hypot(dx, dy) - return dist, (p0x, p0y), 0.0 + # degenerate segment: skip it + continue # project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2 apx = px - p0x diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py index 057ee9ffab..cd8371e1c3 100644 --- a/src/bonsai/test/core/bootstrap.py +++ b/src/bonsai/test/core/bootstrap.py @@ -102,6 +102,13 @@ def geometry(): prophet.verify() +@pytest.fixture +def ifcgit(): + prophet = Prophecy(bonsai.core.tool.IfcGit) + yield prophet + prophet.verify() + + @pytest.fixture def georeference(): prophet = Prophecy(bonsai.core.tool.Georeference) diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py new file mode 100644 index 0000000000..b4d80985b2 --- /dev/null +++ b/src/bonsai/test/core/test_ifcgit.py @@ -0,0 +1,274 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025 Dion Moult +# +# 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 . +# This file was generated with the assistance of an AI coding tool. + +import pytest + +import bonsai.core.ifcgit as subject +from test.core.bootstrap import ifcgit, ifc + + +class MockOperator: + def __init__(self): + self.reports = [] + + def report(self, level, message): + self.reports.append((level, message)) + + +class TestCreateRepo: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.get_path_dir("path/to/model.ifc").should_be_called().will_return("path/to") + ifcgit.init_repo("path/to").should_be_called() + subject.create_repo(ifcgit, ifc) + + +class TestAddFile: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.repo_from_path("path/to/model.ifc").should_be_called().will_return("repo") + ifcgit.add_file_to_repo("repo", "path/to/model.ifc").should_be_called() + subject.add_file(ifcgit, ifc) + + +class TestCloneRepo: + def test_successful_clone(self, ifcgit): + ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return("repo") + ifcgit.load_anyifc("repo").should_be_called() + op = MockOperator() + subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op) + assert op.reports == [({"INFO"}, "Repository cloned")] + + def test_failed_clone_reports_error(self, ifcgit): + ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return(None) + op = MockOperator() + subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op) + assert op.reports == [({"ERROR"}, "Clone failed")] + + +class TestDiscardUncommitted: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.git_checkout("path/to/model.ifc").should_be_called() + ifcgit.load_project("path/to/model.ifc").should_be_called() + subject.discard_uncommitted(ifcgit, ifc) + + +class TestCommitChanges: + def test_commit_on_branch_without_new_branch(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.is_head_detached().should_be_called().will_return(False) + ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called() + subject.commit_changes(ifcgit, ifc, "my message", "") + + def test_commit_on_branch_with_new_branch(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.is_head_detached().should_be_called().will_return(False) + ifcgit.checkout_new_branch("path/to/model.ifc", "feature").should_be_called() + ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called() + subject.commit_changes(ifcgit, ifc, "my message", "feature") + + def test_commit_on_detached_head(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.is_head_detached().should_be_called().will_return(True) + ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called() + ifcgit.create_new_branch("feature").should_be_called() + subject.commit_changes(ifcgit, ifc, "my message", "feature") + + +class TestAddTag: + def test_run(self, ifcgit): + ifcgit.add_tag("repo", "abc123", "v1.0", "Release notes").should_be_called() + subject.add_tag(ifcgit, "repo", "abc123", "v1.0", "Release notes") + + +class TestDeleteTag: + def test_run(self, ifcgit): + ifcgit.delete_tag("repo", "v1.0").should_be_called() + subject.delete_tag(ifcgit, "repo", "v1.0") + + +class TestAddRemote: + def test_run(self, ifcgit): + ifcgit.add_remote("repo", "origin", "http://example.com").should_be_called() + subject.add_remote(ifcgit, "repo", "origin", "http://example.com") + + +class TestDeleteRemote: + def test_run(self, ifcgit): + ifcgit.delete_remote("repo", "origin").should_be_called() + subject.delete_remote(ifcgit, "repo", "origin") + + +class TestPush: + def test_push_succeeds_silently(self, ifcgit): + ifcgit.get_active_branch_name().should_be_called().will_return("main") + ifcgit.push("repo", "origin", "main").should_be_called().will_return(None) + subject.push(ifcgit, "repo", "origin", operator=None) + + def test_push_failure_reports_error(self, ifcgit): + ifcgit.get_active_branch_name().should_be_called().will_return("main") + ifcgit.push("repo", "origin", "main").should_be_called().will_return("stderr: rejected") + op = MockOperator() + subject.push(ifcgit, "repo", "origin", operator=op) + assert op.reports == [({"ERROR"}, "stderr: rejected")] + + +class TestRefreshRevisionList: + def test_refreshes_when_repo_has_heads(self, ifcgit, ifc): + ifcgit.repo_has_commits().should_be_called().will_return(True) + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() + subject.refresh_revision_list(ifcgit, ifc) + + def test_skips_when_repo_has_no_heads(self, ifcgit, ifc): + ifcgit.repo_has_commits().should_be_called().will_return(False) + subject.refresh_revision_list(ifcgit, ifc) + # nothing else should be called — Prophecy will verify + + +class TestColouriseRevision: + def test_skips_when_no_step_ids(self, ifcgit): + ifcgit.get_revisions_step_ids().should_be_called().will_return(None) + subject.colourise_revision(ifcgit) + + def test_colourises_with_step_ids(self, ifcgit): + ifcgit.get_revisions_step_ids().should_be_called().will_return("step_ids") + ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids") + ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids") + ifcgit.colourise("final_step_ids").should_be_called() + subject.colourise_revision(ifcgit) + + +class TestColouriseUncommitted: + def test_skips_when_no_step_ids(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return(None) + subject.colourise_uncommitted(ifcgit, ifc, "repo") + + def test_colourises_with_step_ids(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return("step_ids") + ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids") + ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids") + ifcgit.colourise("final_step_ids").should_be_called() + subject.colourise_uncommitted(ifcgit, ifc, "repo") + + +class TestSwitchRevision: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.switch_to_revision_item().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.decolourise().should_be_called() + subject.switch_revision(ifcgit, ifc) + + +class TestMergeBranch: + 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.merge_branch(ifcgit, ifc, operator=None) + + def test_clean_merge(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("feature").should_be_called().will_return(None) + ifcgit.set_display_branch().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.decolourise().should_be_called() + subject.merge_branch(ifcgit, ifc, operator=None) + + def test_conflict_mergetool_success(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("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return(None) + ifcgit.commit_merge("path/to/model.ifc").should_be_called() + ifcgit.set_display_branch().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.decolourise().should_be_called() + subject.merge_branch(ifcgit, ifc, operator=None) + + def test_conflict_mergetool_failure(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("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return("merge error") + ifcgit.git_merge_abort().should_be_called() + op = MockOperator() + subject.merge_branch(ifcgit, ifc, op) + assert op.reports == [({"ERROR"}, "IFC Merge failed:merge error")] + + def test_unknown_merge_error(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("feature").should_be_called().will_return("error") + op = MockOperator() + subject.merge_branch(ifcgit, ifc, op) + assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")] + + +class TestEntityLog: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.entity_log("path/to/model.ifc", 42).should_be_called().will_return("log text") + op = MockOperator() + subject.entity_log(ifcgit, ifc, 42, op) + assert op.reports == [({"ERROR"}, "log text")] + + +class TestInstallGit: + def test_windows(self, ifcgit): + import unittest.mock as mock + + with mock.patch("platform.system", return_value="Windows"): + ifcgit.install_git_windows(operator="op").should_be_called() + subject.install_git(ifcgit, "op") + + def test_non_windows_does_nothing(self, ifcgit): + import unittest.mock as mock + + with mock.patch("platform.system", return_value="Linux"): + subject.install_git(ifcgit, "op") + # no tool method should be called — Prophecy will verify + + +class TestFetch: + def test_run(self, ifcgit): + ifcgit.fetch("origin").should_be_called() + subject.fetch(ifcgit, "origin") + + +class TestRunGitDiff: + def test_run(self, ifcgit): + ifcgit.run_git_diff("operator", False).should_be_called() + subject.run_git_diff(ifcgit, "operator", False) diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py new file mode 100644 index 0000000000..5cd051aa5f --- /dev/null +++ b/src/bonsai/test/tool/test_ifcgit.py @@ -0,0 +1,456 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025 Dion Moult +# +# 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 . +# This file was generated with the assistance of an AI coding tool. + +import os +import tempfile + +import bonsai.core.tool +from bonsai.tool.ifcgit import IfcGit, IfcGitRepo +from test.bim.bootstrap import NewFile + +try: + import git + import git.exc + + HAS_GIT = True +except ImportError: + HAS_GIT = False + +import pytest + +requires_git = pytest.mark.skipif(not HAS_GIT, reason="GitPython not available") + + +def _make_repo(tmpdir: str) -> "git.Repo": + """Initialise a git repo with user config (needed for commits).""" + repo = git.Repo.init(tmpdir) + with repo.config_writer() as cfg: + cfg.set_value("user", "name", "Test User") + cfg.set_value("user", "email", "test@example.com") + return repo + + +def _commit_ifc(repo: "git.Repo", tmpdir: str, content: str, message: str) -> str: + """Write content to model.ifc, stage and commit it. Returns commit hexsha.""" + ifc_path = os.path.join(tmpdir, "model.ifc") + with open(ifc_path, "w") as f: + f.write(content) + repo.index.add([os.path.normpath(ifc_path)]) + commit = repo.index.commit(message) + return commit.hexsha + + +# --------------------------------------------------------------------------- +# Interface conformance +# --------------------------------------------------------------------------- + + +class TestImplementsTool(NewFile): + def test_run(self): + assert isinstance(IfcGit(), bonsai.core.tool.IfcGit) + + +# --------------------------------------------------------------------------- +# Pure-logic (no git required) +# --------------------------------------------------------------------------- + + +class TestIsValidRefFormat(NewFile): + def test_simple_name(self): + assert IfcGit.is_valid_ref_format("main") + + def test_name_with_slash(self): + assert IfcGit.is_valid_ref_format("feature/my-feature") + + def test_name_with_numbers(self): + assert IfcGit.is_valid_ref_format("release-2024") + + def test_rejects_leading_dot(self): + assert not IfcGit.is_valid_ref_format(".hidden") + + def test_rejects_leading_dash(self): + assert not IfcGit.is_valid_ref_format("-branch") + + def test_rejects_space(self): + assert not IfcGit.is_valid_ref_format("my branch") + + def test_rejects_double_dot(self): + assert not IfcGit.is_valid_ref_format("my..branch") + + def test_rejects_tilde(self): + assert not IfcGit.is_valid_ref_format("my~branch") + + def test_rejects_caret(self): + assert not IfcGit.is_valid_ref_format("my^branch") + + def test_rejects_trailing_dot(self): + assert not IfcGit.is_valid_ref_format("branch.") + + def test_rejects_trailing_slash(self): + assert not IfcGit.is_valid_ref_format("branch/") + + def test_rejects_dot_lock_suffix(self): + assert not IfcGit.is_valid_ref_format("branch.lock") + + def test_rejects_empty_string(self): + assert not IfcGit.is_valid_ref_format("") + + def test_rejects_at_brace(self): + assert not IfcGit.is_valid_ref_format("branch@{upstream}") + + +class TestGetPathDir(NewFile): + def test_returns_parent_directory(self): + assert IfcGit.get_path_dir("/some/path/model.ifc") == "/some/path" + + def test_handles_nested_path(self): + assert IfcGit.get_path_dir("/a/b/c/d.ifc") == "/a/b/c" + + def test_returns_absolute_path(self): + result = IfcGit.get_path_dir("/a/b/model.ifc") + assert os.path.isabs(result) + + +# --------------------------------------------------------------------------- +# File I/O +# --------------------------------------------------------------------------- + + +class TestDos2Unix(NewFile): + def test_converts_crlf_to_lf(self): + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f: + f.write(b"line1\r\nline2\r\nline3\r\n") + path = f.name + try: + IfcGit.dos2unix(path) + with open(path, "rb") as f: + assert f.read() == b"line1\nline2\nline3\n" + finally: + os.unlink(path) + + def test_lf_only_file_is_unchanged(self): + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f: + f.write(b"line1\nline2\nline3\n") + path = f.name + try: + IfcGit.dos2unix(path) + with open(path, "rb") as f: + assert f.read() == b"line1\nline2\nline3\n" + finally: + os.unlink(path) + + def test_empty_file_unchanged(self): + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f: + f.write(b"") + path = f.name + try: + IfcGit.dos2unix(path) + with open(path, "rb") as f: + assert f.read() == b"" + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# Git repo initialisation +# --------------------------------------------------------------------------- + + +class TestInitRepo(NewFile): + @requires_git + def test_creates_git_repo(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + IfcGit.init_repo(tmpdir) + assert IfcGitRepo.repo is not None + assert os.path.isdir(IfcGitRepo.repo.git_dir) + IfcGitRepo.repo = None + + @requires_git + def test_creates_info_attributes_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + IfcGit.init_repo(tmpdir) + attrs_path = os.path.join(IfcGitRepo.repo.git_dir, "info", "attributes") + assert os.path.isfile(attrs_path) + with open(attrs_path) as f: + assert "*.ifc text" in f.read() + IfcGitRepo.repo = None + + +class TestRepoFromPath(NewFile): + @requires_git + def test_finds_repo_from_file_in_root(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + repo = _make_repo(tmpdir) + ifc_path = os.path.join(tmpdir, "model.ifc") + open(ifc_path, "w").close() + result = IfcGit.repo_from_path(ifc_path) + assert result is not None + assert os.path.abspath(result.working_dir) == os.path.abspath(tmpdir) + IfcGitRepo.repo = None + + @requires_git + def test_finds_repo_from_subdirectory(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + _make_repo(tmpdir) + subdir = os.path.join(tmpdir, "sub", "dir") + os.makedirs(subdir) + result = IfcGit.repo_from_path(subdir) + assert result is not None + IfcGitRepo.repo = None + + @requires_git + def test_returns_none_for_path_outside_any_repo(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + # a plain directory with no .git anywhere above it (using /tmp directly + # is safe since /tmp is not a git repo on this system) + result = IfcGit.repo_from_path("/nonexistent/path/that/does/not/exist") + assert result is None + IfcGitRepo.repo = None + + +# --------------------------------------------------------------------------- +# Git repo configuration +# --------------------------------------------------------------------------- + + +class TestConfigInfoAttributes(NewFile): + @requires_git + def test_creates_attributes_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGit.config_info_attributes(repo) + attrs_path = os.path.join(repo.git_dir, "info", "attributes") + assert os.path.isfile(attrs_path) + with open(attrs_path) as f: + assert "*.ifc text" in f.read() + + @requires_git + def test_does_not_overwrite_existing_attributes(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + attrs_path = os.path.join(repo.git_dir, "info", "attributes") + os.makedirs(os.path.dirname(attrs_path), exist_ok=True) + with open(attrs_path, "w") as f: + f.write("*.png binary\n") + IfcGit.config_info_attributes(repo) + with open(attrs_path) as f: + content = f.read() + assert "*.png binary" in content # original content preserved + + +class TestConfigPush(NewFile): + @requires_git + def test_sets_push_defaults(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGit.config_push(repo) + reader = repo.config_reader() + assert reader.get_value("push", "default") == "current" + assert reader.get_value("push", "autoSetupRemote") is True + + @requires_git + def test_does_not_overwrite_existing_push_section(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + with repo.config_writer() as w: + w.set_value("push", "default", "simple") + IfcGit.config_push(repo) + reader = repo.config_reader() + assert reader.get_value("push", "default") == "simple" + + +# --------------------------------------------------------------------------- +# Branch / tag lookups +# --------------------------------------------------------------------------- + + +class TestBranchesByHexsha(NewFile): + @requires_git + def test_maps_head_commit_to_active_branch(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + result = IfcGit.branches_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + assert head_sha in result + names = [b.name for b in result[head_sha]] + assert repo.active_branch.name in names + + @requires_git + def test_returns_empty_dict_when_no_commits(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + result = IfcGit.branches_by_hexsha(repo) + assert result == {} + + @requires_git + def test_includes_both_branches_when_pointing_to_same_commit(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_head("feature") + result = IfcGit.branches_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + names = [b.name for b in result[head_sha]] + assert len(names) == 2 + + +class TestTagsByHexsha(NewFile): + @requires_git + def test_returns_empty_dict_when_no_tags(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + assert IfcGit.tags_by_hexsha(repo) == {} + + @requires_git + def test_maps_commit_to_annotated_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_tag("v1.0", message="Release 1.0") + result = IfcGit.tags_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + assert head_sha in result + assert result[head_sha][0].name == "v1.0" + + @requires_git + def test_maps_commit_to_lightweight_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_tag("v0.1") + result = IfcGit.tags_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + assert head_sha in result + + +class TestDeleteTag(NewFile): + @requires_git + def test_removes_existing_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_tag("v1.0") + IfcGit.delete_tag(repo, "v1.0") + assert "v1.0" not in [t.name for t in repo.tags] + + @requires_git + def test_does_not_raise_for_nonexistent_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + IfcGit.delete_tag(repo, "does-not-exist") # must not raise + + +# --------------------------------------------------------------------------- +# IFC diff parsing +# --------------------------------------------------------------------------- + + +class TestIfcDiffIds(NewFile): + @requires_git + def test_detects_modified_entity(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n", "update") + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 1 in result["modified"] + assert result["added"] == set() + assert result["removed"] == set() + + @requires_git + def test_detects_added_entity(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc( + repo, + tmpdir, + "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('new',$,$,$,$,$,$,$,$,$,$,$,$);\n", + "add site", + ) + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 2 in result["added"] + assert 1 not in result["modified"] + assert result["removed"] == set() + + @requires_git + def test_detects_removed_entity(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc(repo, tmpdir, "", "clear") + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 1 in result["removed"] + assert result["added"] == set() + assert result["modified"] == set() + + @requires_git + def test_no_changes_between_identical_commits(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + result = IfcGit.ifc_diff_ids(repo, sha, sha, ifc_path) + assert result["modified"] == set() + assert result["added"] == set() + assert result["removed"] == set() + + @requires_git + def test_diff_against_working_tree_with_none_hash_a(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + # Make an uncommitted change in working tree + with open(ifc_path, "w") as f: + f.write("#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n") + result = IfcGit.ifc_diff_ids(repo, None, "HEAD", ifc_path) + assert 1 in result["modified"] + + @requires_git + def test_handles_multiple_changed_entities(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc( + repo, + tmpdir, + "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('s',$,$,$,$,$,$,$,$,$,$,$,$);\n", + "init", + ) + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc( + repo, + tmpdir, + "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n#2=IFCSITE('t',$,$,$,$,$,$,$,$,$,$,$,$);\n", + "update both", + ) + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 1 in result["modified"] + assert 2 in result["modified"] diff --git a/src/ifcchat/CNAME b/src/ifcchat/CNAME new file mode 100644 index 0000000000..6fbcc54661 --- /dev/null +++ b/src/ifcchat/CNAME @@ -0,0 +1 @@ +ai-chat.ifcopenshell.org \ No newline at end of file diff --git a/src/ifcchat/README.md b/src/ifcchat/README.md index f20aa8788d..5dc434143a 100644 --- a/src/ifcchat/README.md +++ b/src/ifcchat/README.md @@ -1,7 +1,7 @@ IfcOpenShell AI Assistant ========================= -A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application. +A web-based client-side (pyodide + OpenAI, Anthropic, Gemini, or OpenRouter API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application. ### Setup instructions diff --git a/src/ifcchat/api_anthropic.js b/src/ifcchat/api_anthropic.js new file mode 100644 index 0000000000..0359cde936 --- /dev/null +++ b/src/ifcchat/api_anthropic.js @@ -0,0 +1,177 @@ +// This file was generated with the assistance of an AI coding tool. + +function parseArguments(argumentsText) { + if (!argumentsText) return {}; + try { + return JSON.parse(argumentsText); + } catch { + return {}; + } +} + +function toAnthropicTools(tools = []) { + return tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters, + })); +} + +function toAnthropicAssistantContent(message) { + const content = []; + + if (message.content) { + content.push({ type: "text", text: message.content }); + } + + for (const toolCall of message.tool_calls ?? []) { + content.push({ + type: "tool_use", + id: toolCall.id, + name: toolCall.function.name, + input: parseArguments(toolCall.function.arguments), + }); + } + + if (content.length === 0) { + return ""; + } + + return content.length === 1 && content[0].type === "text" ? content[0].text : content; +} + +function toAnthropicUserContent(message) { + return typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""); +} + +function toAnthropicToolResult(message) { + return { + type: "tool_result", + tool_use_id: message.tool_call_id, + content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""), + }; +} + +function splitSystemAndMessages(messages = []) { + const system = []; + const anthropicMessages = []; + let pendingToolResults = []; + + const flushToolResults = () => { + if (pendingToolResults.length === 0) return; + anthropicMessages.push({ role: "user", content: pendingToolResults }); + pendingToolResults = []; + }; + + for (const message of messages) { + if (message.role === "system") { + if (message.content) { + system.push(message.content); + } + continue; + } + + if (message.role === "tool") { + pendingToolResults.push(toAnthropicToolResult(message)); + continue; + } + + flushToolResults(); + + if (message.role === "user") { + anthropicMessages.push({ + role: "user", + content: toAnthropicUserContent(message), + }); + continue; + } + + if (message.role === "assistant") { + anthropicMessages.push({ + role: "assistant", + content: toAnthropicAssistantContent(message), + }); + } + } + + flushToolResults(); + + return { + system: system.join("\n\n"), + messages: anthropicMessages, + }; +} + +function toChatCompletionResponse(response) { + const text = []; + const toolCalls = []; + + for (const block of response.content ?? []) { + if (block.type === "text") { + text.push(block.text); + continue; + } + + if (block.type === "tool_use") { + toolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }, + }); + } + } + + const message = { role: "assistant" }; + const content = text.join("\n").trim(); + + if (content) { + message.content = content; + } + + if (toolCalls.length) { + message.tool_calls = toolCalls; + } + + return { + choices: [ + { + message, + }, + ], + }; +} + +export async function chat({ apiKey, model, messages, tools }) { + const request = splitSystemAndMessages(messages); + const body = { + model, + max_tokens: 4096, + messages: request.messages, + tools: toAnthropicTools(tools), + }; + + if (request.system) { + body.system = request.system; + } + + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Anthropic error ${res.status}: ${text}`); + } + + return toChatCompletionResponse(await res.json()); +} diff --git a/src/ifcchat/api_openai.js b/src/ifcchat/api_openai.js new file mode 100644 index 0000000000..8903f897e4 --- /dev/null +++ b/src/ifcchat/api_openai.js @@ -0,0 +1,20 @@ +function getChatCompletionsUrl(baseURL) { + const root = (baseURL || "https://api.openai.com/v1").replace(/\/+$/, ""); + return `${root}/chat/completions`; +} + +export async function chat({ apiKey, baseURL, model, messages, tools }) { + const res = await fetch(getChatCompletionsUrl(baseURL), { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages, tools }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`OpenAI error ${res.status}: ${text}`); + } + return await res.json(); +} diff --git a/src/ifcchat/api_openrouter.js b/src/ifcchat/api_openrouter.js new file mode 100644 index 0000000000..38c11e36e8 --- /dev/null +++ b/src/ifcchat/api_openrouter.js @@ -0,0 +1,20 @@ +function getChatCompletionsUrl(baseURL) { + const root = (baseURL || "https://openrouter.ai/api/v1").replace(/\/+$/, ""); + return `${root}/chat/completions`; +} + +export async function chat({ apiKey, baseURL, model, messages, tools }) { + const res = await fetch(getChatCompletionsUrl(baseURL), { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages, tools }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`OpenRouter error ${res.status}: ${text}`); + } + return await res.json(); +} diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 516ec8f846..b268bf8256 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -1,4 +1,132 @@ // app.js +import * as openaiApi from "./api_openai.js"; +import * as anthropicApi from "./api_anthropic.js"; +import * as openrouterApi from "./api_openrouter.js"; + +const PROVIDERS = { + openai: { + api: openaiApi, + apiKeyLabel: "OpenAI API key", + apiKeyPlaceholder: "sk-...", + baseUrlLabel: "Base URL", + baseUrlPlaceholder: "https://api.openai.com/v1", + baseUrlDefault: "https://api.openai.com/v1", + models: [ + { + value: "gpt-5.2", + label: "gpt-5.2" + }, + { + value: "gpt-5.2-chat-latest", + label: "gpt-5.2-chat-latest" + }, + { + value: "gpt-5", + label: "gpt-5" + }, + { + value: "gpt-5-chat-latest", + label: "gpt-5-chat-latest" + }, + { + value: "gpt-5-mini", + label: "gpt-5-mini" + }, + { + value: "gpt-5-nano", + label: "gpt-5-nano" + }, + { + value: "gpt-4.1", + label: "gpt-4.1" + }, + { + value: "gpt-4.1-mini", + label: "gpt-4.1-mini" + }, + { + value: "gpt-4.1-nano", + label: "gpt-4.1-nano" + }, + ], + }, + anthropic: { + api: anthropicApi, + apiKeyLabel: "Anthropic API key", + apiKeyPlaceholder: "sk-ant-...", + models: [ + { + value: "claude-sonnet-4-6", + label: "claude-sonnet-4-6" + }, + { + value: "claude-opus-4-6", + label: "claude-opus-4-6" + }, + { + value: "claude-haiku-4-5-20251001", + label: "claude-haiku-4-5" + }, + ], + }, + gemini: { + api: openaiApi, + apiKeyLabel: "Gemini API key", + apiKeyPlaceholder: "AIza...", + baseUrlLabel: "Base URL", + baseUrlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/", + baseUrlDefault: "https://generativelanguage.googleapis.com/v1beta/openai/", + models: [ + { + value: "gemini-3-flash-preview", + label: "gemini-3-flash-preview" + }, + { + value: "gemini-2.5-flash", + label: "gemini-2.5-flash" + }, + { + value: "gemini-2.5-pro", + label: "gemini-2.5-pro" + }, + ], + }, + openrouter: { + api: openrouterApi, + apiKeyLabel: "OpenRouter API key", + apiKeyPlaceholder: "sk-or-v1-...", + baseUrlLabel: "Base URL", + baseUrlPlaceholder: "https://openrouter.ai/api/v1", + baseUrlDefault: "https://openrouter.ai/api/v1", + models: [ + { + value: "openai/gpt-oss-20b", + label: "gpt-oss-20b" + }, + { + value: "openai/gpt-oss-120b", + label: "gpt-oss-120b" + }, + { + value: "mistralai/mistral-small-3.2-24b-instruct", + label: "mistral-small-3.2" + }, + { + value: "openai/gpt-4.1", + label: "gpt-4.1" + }, + { + value: "anthropic/claude-sonnet-4-5", + label: "claude-sonnet-4-5" + }, + { + value: "google/gemini-2.5-pro-preview", + label: "gemini-2.5-pro" + }, + ], + }, +}; + const $ = (id) => document.getElementById(id); const statusEl = $("status"); @@ -6,11 +134,42 @@ const msgsEl = $("msgs"); const sendBtn = $("send"); const inputEl = $("input"); const apiKeyEl = $("apiKey"); +const apiKeyLabelEl = $("apiKeyLabel"); +const baseUrlRowEl = $("baseUrlRow"); +const baseUrlLabelEl = $("baseUrlLabel"); +const baseUrlEl = $("baseUrl"); +const thinkingIndicatorEl = $("thinkingIndicator"); const modelEl = $("model"); +const providerEls = document.querySelectorAll('input[name="provider"]'); const ifcFileEl = $("ifcFile"); const newBtn = $("newModel"); const downloadBtn = $("downloadIfc"); +function getProviderValue() { + return document.querySelector('input[name="provider"]:checked')?.value || "openai"; +} + +function onProviderChange() { + const provider = PROVIDERS[getProviderValue()]; + apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}stored in browser memory; only sent to provider servers`; + apiKeyEl.placeholder = provider.apiKeyPlaceholder; + baseUrlRowEl.hidden = !provider.baseUrlDefault; + if (provider.baseUrlDefault) { + baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}override the API endpoint for OpenAI-compatible providers`; + baseUrlEl.placeholder = provider.baseUrlPlaceholder; + baseUrlEl.value = provider.baseUrlDefault; + } else { + baseUrlEl.value = ""; + baseUrlEl.placeholder = ""; + } + modelEl.innerHTML = provider.models.map(m => ``).join(""); +} + +for (const providerEl of providerEls) { + providerEl.addEventListener("change", onProviderChange); +} +onProviderChange(); + function setBusy(isBusy, reason = "") { const controls = [ $("send"), @@ -30,32 +189,213 @@ function setBusy(isBusy, reason = "") { browseBtn.tabIndex = isBusy ? -1 : 0; } + sendBtn.innerHTML = isBusy + ? `` + : `Send send`; + setStatus(isBusy ? (reason || "Working…") : "Ready"); } +function escapeHtml(text) { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function sanitizeUrl(url) { + try { + const parsed = new URL(url, window.location.href); + if (["http:", "https:", "mailto:"].includes(parsed.protocol)) { + return parsed.href; + } + } catch { + } + return null; +} + +function renderInlineMarkdown(text) { + const placeholders = []; + const addPlaceholder = (html) => { + const token = `@@MD${placeholders.length}@@`; + placeholders.push({ token, html }); + return token; + }; + + let rendered = text; + + rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`${escapeHtml(code)}`)); + rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => { + const href = sanitizeUrl(url); + if (!href) { + return `${label} (${url})`; + } + return addPlaceholder( + `${escapeHtml(label)}` + ); + }); + + rendered = escapeHtml(rendered); + rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1"); + rendered = rendered.replace(/\*([^*]+)\*/g, "$1"); + rendered = rendered.replace(/_([^_]+)_/g, "$1"); + + for (const placeholder of placeholders) { + rendered = rendered.replaceAll(placeholder.token, placeholder.html); + } + + return rendered; +} + +function renderMarkdown(text) { + const lines = String(text).replace(/\r\n?/g, "\n").split("\n"); + const html = []; + let paragraphLines = []; + let quoteLines = []; + let listType = null; + let listItems = []; + + const flushParagraph = () => { + if (!paragraphLines.length) return; + html.push(`

${renderInlineMarkdown(paragraphLines.join(" "))}

`); + paragraphLines = []; + }; + + const flushQuote = () => { + if (!quoteLines.length) return; + const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("
"); + html.push(`

${quoteBody}

`); + quoteLines = []; + }; + + const flushList = () => { + if (!listItems.length || !listType) return; + const items = listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join(""); + html.push(`<${listType}>${items}`); + listType = null; + listItems = []; + }; + + const flushAll = () => { + flushParagraph(); + flushQuote(); + flushList(); + }; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmed = line.trim(); + + if (trimmed.startsWith("```")) { + flushAll(); + const language = trimmed.slice(3).trim(); + const codeLines = []; + index += 1; + while (index < lines.length && !lines[index].trim().startsWith("```")) { + codeLines.push(lines[index]); + index += 1; + } + const languageClass = language ? ` class="language-${escapeHtml(language)}"` : ""; + html.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + continue; + } + + if (!trimmed) { + flushAll(); + continue; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushAll(); + const level = headingMatch[1].length; + html.push(`${renderInlineMarkdown(headingMatch[2])}`); + continue; + } + + const quoteMatch = trimmed.match(/^>\s?(.*)$/); + if (quoteMatch) { + flushParagraph(); + flushList(); + quoteLines.push(quoteMatch[1]); + continue; + } + + if (quoteLines.length) { + flushQuote(); + } + + const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/); + if (unorderedListMatch) { + flushParagraph(); + if (listType && listType !== "ul") { + flushList(); + } + listType = "ul"; + listItems.push(unorderedListMatch[1]); + continue; + } + + const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/); + if (orderedListMatch) { + flushParagraph(); + if (listType && listType !== "ol") { + flushList(); + } + listType = "ol"; + listItems.push(orderedListMatch[1]); + continue; + } + + if (listItems.length) { + flushList(); + } + + paragraphLines.push(trimmed); + } + + flushAll(); + + return html.join(""); +} + function addMessage(role, text) { if (text.ok) { text = text.data; } + if (typeof text !== "string") { + text = JSON.stringify(text, null, 2); + } const wrap = document.createElement("div"); wrap.className = `msg ${role}`; wrap.innerHTML = ` -
    ${role}
    +
    ${role}${role === "tool" ? '' : ''}
    `; const bubble = wrap.querySelector(".bubble"); - bubble.textContent = text; + if (role === "assistant") { + bubble.classList.add("markdown-content"); + bubble.innerHTML = renderMarkdown(text); + } else { + bubble.textContent = text; + } bubble.onclick = function () { if (bubble.scrollHeight > 100 && role === "tool") { - bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none'; - bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : ''; + const expanded = bubble.style.maxHeight === 'none'; + bubble.style.maxHeight = expanded ? '' : 'none'; + bubble.style.borderBottom = expanded ? '' : 'dotted 2px gray'; + wrap.querySelector(".chevron").style.transform = expanded ? '' : 'rotate(90deg)'; } } - msgsEl.appendChild(wrap); + msgsEl.insertBefore(wrap, thinkingIndicatorEl); msgsEl.scrollTop = msgsEl.scrollHeight; } function setStatus(text) { statusEl.textContent = text; + thinkingIndicatorEl.hidden = text !== "Thinking…"; + msgsEl.scrollTop = msgsEl.scrollHeight; } const worker = new Worker("./ifc_worker.js", { type: "module" }); @@ -75,74 +415,73 @@ function callWorker(type, payload = {}) { }); } -// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ---- -// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4} +// ---- Tool schemas (should match ifcmcp.core openai_tools()) ---- const tools = [ { - type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.", - parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false } + type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory.", + parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false } } }, { - type: "function", name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.", - parameters: { type: "object", properties: {}, required: [], additionalProperties: false } + type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.", + parameters: { type: "object", properties: {}, required: [], additionalProperties: false } } }, { - type: "function", name: "ifc_tree", description: "Get the full spatial hierarchy tree.", - parameters: { type: "object", properties: {}, required: [], additionalProperties: false } + type: "function", function: { name: "ifc_tree", description: "Get the full spatial hierarchy tree.", + parameters: { type: "object", properties: {}, required: [], additionalProperties: false } } }, { - type: "function", name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').", - parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } + type: "function", function: { name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').", + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } } }, { - type: "function", name: "ifc_info", description: "Inspect an entity by STEP id.", - parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false } + type: "function", function: { name: "ifc_info", description: "Inspect an entity by STEP id.", + parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false } } }, { - type: "function", name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.", + type: "function", function: { name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.", parameters: { type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } }, required: ["element_id"], additionalProperties: false - } + } } }, { - type: "function", name: "ifc_clash", description: "Run clash/clearance checks for an element.", + type: "function", function: { name: "ifc_clash", description: "Run clash/clearance checks for an element.", parameters: { type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } }, required: ["element_id"], additionalProperties: false - } + } } }, { - type: "function", name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.", - parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false } + type: "function", function: { name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.", + parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false } } }, { - type: "function", name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.", - parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false } + type: "function", function: { name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.", + parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false } } }, { - type: "function", name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.", - parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false } + type: "function", function: { name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.", + parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false } } }, { - type: "function", name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.", - parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false } + type: "function", function: { name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.", + parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false } } }, { - type: "function", name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", - parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } + type: "function", function: { name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", + parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } } }, { - type: "function", name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", - parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } + type: "function", function: { name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", + parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } } }, { - type: "function", name: "ifc_schema", description: "Return IFC class documentation for an entity type.", - parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false } + type: "function", function: { name: "ifc_schema", description: "Return IFC class documentation for an entity type.", + parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false } } }, { - type: "function", name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.", - parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false } + type: "function", function: { name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.", + parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false } } }, ]; @@ -156,85 +495,53 @@ Rules: Be concise. Avoid dumping huge trees unless asked. `; -let inputItems = []; // running conversation state (Responses API style) - -async function openAIResponsesCreate({ apiKey, model, input, tools }) { - const res = await fetch("https://api.openai.com/v1/responses", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - instructions: SYSTEM_INSTRUCTIONS, - tools, - input, - }), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`OpenAI error ${res.status}: ${text}`); - } - return await res.json(); -} - -function extractAssistantText(response) { - const out = []; - for (const item of response.output ?? []) { - if (item.type === "message" && item.role === "assistant") { - for (const c of item.content ?? []) { - if (c.type === "output_text") out.push(c.text); - } - } - } - return out.join("\n").trim(); -} +let messages = []; // running conversation state (Chat Completions style) async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); - // Add user message - inputItems.push({ role: "user", content: userText }); + const provider = PROVIDERS[getProviderValue()]; + const { chat } = provider.api; + const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; + + messages.push({ role: "user", content: userText }); - // Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output. for (let i = 0; i < 64; i++) { - const response = await openAIResponsesCreate({ + const response = await chat({ apiKey, + baseURL, model: modelEl.value, - input: inputItems, + messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages], tools, }); - // Keep ALL output items (incl reasoning/tool calls) in the running state. - inputItems.push(...(response.output ?? [])); + const message = response.choices?.[0]?.message; + if (!message) throw new Error("No message in response"); - // Show any assistant text immediately - const text = extractAssistantText(response); - if (text) addMessage("assistant", text); + messages.push(message); - const calls = (response.output ?? []).filter((x) => x.type === "function_call"); + if (message.content) addMessage("assistant", message.content); + + const calls = message.tool_calls ?? []; if (calls.length === 0) return; for (const call of calls) { let args = {}; - try { args = call.arguments ? JSON.parse(call.arguments) : {}; } + try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; } catch { args = {}; } - addMessage("tool", `→ ${call.name}(${JSON.stringify(args)})`); + addMessage("tool", `→ ${call.function.name}(${JSON.stringify(args)})`); - const toolRes = await callWorker("toolCall", { name: call.name, args }); + const toolRes = await callWorker("toolCall", { name: call.function.name, args }); - // Feed tool result back to the model - inputItems.push({ - type: "function_call_output", - call_id: call.call_id, - output: JSON.stringify(toolRes.result), + messages.push({ + role: "tool", + tool_call_id: call.id, + content: JSON.stringify(toolRes.result), }); - addMessage("tool", `← ${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`); + addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`); } } @@ -251,7 +558,7 @@ sendBtn.onclick = async () => { await runAgentTurn(text); setBusy(false, "Ready"); } catch (e) { - setBusy(true, "Error"); + setBusy(false, "Error"); addMessage("assistant", `Error: ${e.message}`); } }; @@ -317,4 +624,4 @@ downloadBtn.onclick = async () => { setBusy(true, "Error"); addMessage("assistant", `Worker init failed: ${e.message}`); } -})(); \ No newline at end of file +})(); diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index ac878ae615..daf6146126 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -5,233 +5,7 @@ IfcOpenShell AI Assistant - + @@ -239,14 +13,46 @@
    - + +
    + + + + +
    +
    + +
    + + +
    + +
    +
    +
    + + +
    +
    - +