mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 10:57:49 +00:00
Merge branch 'v0.8.0' into ifcmax/initial-refresh
This commit is contained in:
+5
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import 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)
|
||||
@@ -0,0 +1,456 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
import os
|
||||
import 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"]
|
||||
@@ -0,0 +1 @@
|
||||
ai-chat.ifcopenshell.org
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
+401
-94
@@ -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}<span class="small">stored in browser memory; only sent to provider servers</span>`;
|
||||
apiKeyEl.placeholder = provider.apiKeyPlaceholder;
|
||||
baseUrlRowEl.hidden = !provider.baseUrlDefault;
|
||||
if (provider.baseUrlDefault) {
|
||||
baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}<span class="small">override the API endpoint for OpenAI-compatible providers</span>`;
|
||||
baseUrlEl.placeholder = provider.baseUrlPlaceholder;
|
||||
baseUrlEl.value = provider.baseUrlDefault;
|
||||
} else {
|
||||
baseUrlEl.value = "";
|
||||
baseUrlEl.placeholder = "";
|
||||
}
|
||||
modelEl.innerHTML = provider.models.map(m => `<option value="${m.value}">${m.label}</option>`).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
|
||||
? `<span class="spinner"></span>`
|
||||
: `Send <span class="material-icons">send</span>`;
|
||||
|
||||
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(`<code>${escapeHtml(code)}</code>`));
|
||||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
||||
const href = sanitizeUrl(url);
|
||||
if (!href) {
|
||||
return `${label} (${url})`;
|
||||
}
|
||||
return addPlaceholder(
|
||||
`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">${escapeHtml(label)}</a>`
|
||||
);
|
||||
});
|
||||
|
||||
rendered = escapeHtml(rendered);
|
||||
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
rendered = rendered.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||||
|
||||
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(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
|
||||
paragraphLines = [];
|
||||
};
|
||||
|
||||
const flushQuote = () => {
|
||||
if (!quoteLines.length) return;
|
||||
const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("<br />");
|
||||
html.push(`<blockquote><p>${quoteBody}</p></blockquote>`);
|
||||
quoteLines = [];
|
||||
};
|
||||
|
||||
const flushList = () => {
|
||||
if (!listItems.length || !listType) return;
|
||||
const items = listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("");
|
||||
html.push(`<${listType}>${items}</${listType}>`);
|
||||
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(`<pre><code${languageClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
flushAll();
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
flushAll();
|
||||
const level = headingMatch[1].length;
|
||||
html.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
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 = `
|
||||
<div class="role ${role}">${role}</div>
|
||||
<div class="role ${role}">${role}${role === "tool" ? '<span class="chevron">▶</span>' : ''}</div>
|
||||
<div class="bubble"></div>`;
|
||||
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}`);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
+43
-238
@@ -5,233 +5,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>IfcOpenShell AI Assistant</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
header input,
|
||||
header select {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.side {
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 100px 12px 12px 12px;
|
||||
overflow: auto;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.msgs {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.msg .role {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
|
||||
.role.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.msg .bubble {
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.user .bubble {
|
||||
padding: 10px 20px;
|
||||
background: #eee;
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
border: solid 1px #ddd;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 88px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.composer .inner {
|
||||
border: solid 1px #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
section>.row>label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
section>.row>label .small {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.side .row {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
#model {
|
||||
background: white;
|
||||
color: gray;
|
||||
border: solid 1px #eee;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: dashed 1px #ddd;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-row>.btn,
|
||||
.btn-row>button.btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #eee;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Disabled state for label-button + normal buttons */
|
||||
.btn.disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#input {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#input:focus,
|
||||
#input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 90%;
|
||||
background-color: #eee;
|
||||
border: solid 1px #ddd;
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
@@ -239,14 +13,46 @@
|
||||
<main>
|
||||
<section class="side">
|
||||
<div class="row">
|
||||
<label>OpenAI API key<span class="small">stored in browser memory; only sent to OpenAI servers</span></label>
|
||||
<label>Provider</label>
|
||||
<div class="provider-tabs" role="radiogroup" aria-label="Provider">
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="openai" checked />
|
||||
<span>OpenAI</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="anthropic" />
|
||||
<span>Claude</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="gemini" />
|
||||
<span>Gemini</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="openrouter" />
|
||||
<span>OpenRouter</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Model</label>
|
||||
<select id="model" style="width: 100%;"></select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label id="apiKeyLabel">API key<span class="small">stored in browser memory; only sent to provider servers</span></label>
|
||||
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="row" id="baseUrlRow">
|
||||
<label id="baseUrlLabel">Base URL<span class="small">override the API endpoint for OpenAI-compatible providers</span></label>
|
||||
<input id="baseUrl" type="text" placeholder="https://api.openai.com/v1" autocomplete="off" style="width: 100%;" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row" style="margin-bottom: 10px;">
|
||||
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to OpenAI servers</span></label>
|
||||
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to provider servers</span></label>
|
||||
|
||||
<div class="btn-row">
|
||||
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
|
||||
@@ -290,15 +96,9 @@
|
||||
|
||||
<section class="chat">
|
||||
<header>
|
||||
<img src="https://ifcopenshell.org/assets/images/logo.png" style="float: left; padding: 10px 20px 10px 0; ">
|
||||
<div class="row">
|
||||
|
||||
<img src="https://ifcopenshell.org/assets/images/logo.png" style="float: left; padding: 10px 20px 10px 0; ">
|
||||
|
||||
<strong>IfcOpenShell AI Assistant</strong>
|
||||
<select id="model">
|
||||
<option value="gpt-5">gpt-5</option>
|
||||
<option value="gpt-4.1">gpt-4.1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -306,7 +106,12 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="msgs" id="msgs"></div>
|
||||
<div class="msgs" id="msgs">
|
||||
<div class="thinking-indicator" id="thinkingIndicator" hidden>
|
||||
<span class="spinner"></span>
|
||||
<span>thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="inner">
|
||||
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
|
||||
@@ -319,4 +124,4 @@
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
header input,
|
||||
header select {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.side {
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.msgs {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.thinking-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #555;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.thinking-indicator[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinking-indicator .spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.msg .role {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
|
||||
.role.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.msg .bubble {
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.assistant .bubble {
|
||||
padding: 10px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-content p,
|
||||
.markdown-content ul,
|
||||
.markdown-content ol,
|
||||
.markdown-content blockquote,
|
||||
.markdown-content pre {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid #ddd;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: #f2f2f2;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
overflow-x: auto;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.msg.user .bubble {
|
||||
padding: 10px 20px;
|
||||
background: #eee;
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
border: solid 1px #ddd;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 60%;
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
transition: transform 0.2s;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 88px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.composer .inner {
|
||||
border: solid 1px #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #aaa;
|
||||
border-top-color: #333;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
section > .row > label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
section > .row > label .small {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.side .row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.provider-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
background: #00000010;
|
||||
padding: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.provider-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.provider-tab input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.provider-tab span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: #e8e8e8;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.provider-tab input:checked + span {
|
||||
background: #f9f9f9;
|
||||
border-color: #999;
|
||||
color: #111;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.provider-tab input:focus-visible + span {
|
||||
outline: 2px solid #666;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
|
||||
hr {
|
||||
border: dashed 1px #ddd;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-row > .btn,
|
||||
.btn-row > button.btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #eee;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn.disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#input {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#input:focus,
|
||||
#input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 90%;
|
||||
background-color: #eee;
|
||||
border: solid 1px #ddd;
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
header .row:nth-child(2) {
|
||||
padding: 15px 0 0 0;
|
||||
}
|
||||
@@ -52,6 +52,15 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i
|
||||
if (inst->OffsetVertical().has_value()) {
|
||||
auto offset_vertical = inst->OffsetVertical().get() * length_unit_;
|
||||
o += offset_vertical * z;
|
||||
|
||||
auto tmp1 = (z * offset_vertical).eval();
|
||||
auto tmp2 = (Eigen::Vector3d(0, 0, 1) * offset_vertical).eval();
|
||||
auto tmp3 = (tmp1 - tmp2).eval();
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << "local z: " << z.x() << "," << z.y() << "," << z.z() << "; delta: " << tmp3.x() << "," << tmp3.y() << "," << tmp3.z();
|
||||
auto osss = oss.str();
|
||||
std::wcout << osss.c_str() << std::endl;
|
||||
}
|
||||
|
||||
if (inst->OffsetLongitudinal().has_value()) {
|
||||
|
||||
@@ -42,6 +42,7 @@ WHITE = numpy.array((1.0, 1.0, 1.0))
|
||||
|
||||
DO_NOTHING = lambda *args: None
|
||||
|
||||
ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, 'arrange_polygon_settings') else None
|
||||
|
||||
@dataclass
|
||||
class draw_settings:
|
||||
@@ -536,7 +537,7 @@ def main(
|
||||
*(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i))
|
||||
)
|
||||
|
||||
arranged = W.arrange_polygons(polies)
|
||||
arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies)
|
||||
svg_data_3 = W.polygons_to_svg(arranged, False)
|
||||
dom3 = parseString(svg_data_3)
|
||||
svg3 = dom3.childNodes[0]
|
||||
|
||||
@@ -358,6 +358,7 @@ class IFC_PARSE_API entity : public declaration {
|
||||
|
||||
const std::vector<const entity*>& subtypes() const { return subtypes_; }
|
||||
const std::vector<const attribute*>& attributes() const { return attributes_; }
|
||||
const std::vector<const inverse_attribute*>& inverse_attributes() const { return inverse_attributes_; }
|
||||
const std::vector<bool>& derived() const { return derived_; }
|
||||
|
||||
const std::vector<const attribute*> all_attributes() const {
|
||||
|
||||
@@ -1166,6 +1166,7 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
|
||||
%ignore svgfill::line_segments_to_polygons;
|
||||
%ignore svgfill::svg_to_polygons;
|
||||
%ignore svgfill::arrange_polygons;
|
||||
%ignore svgfill::abstract_arrangement;
|
||||
|
||||
%template(svg_line_segments) std::vector<std::array<svgfill::point_2, 2>>;
|
||||
%template(svg_groups_of_line_segments) std::vector<std::vector<std::array<svgfill::point_2, 2>>>;
|
||||
@@ -1287,9 +1288,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<svgfill::polygon_2> arrange_polygons(const std::vector<svgfill::polygon_2>& polygons) {
|
||||
std::vector<svgfill::polygon_2> arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons) {
|
||||
std::vector<svgfill::polygon_2> r;
|
||||
if (svgfill::arrange_polygons(polygons, r)) {
|
||||
if (svgfill::arrange_polygons(settings, polygons, r)) {
|
||||
return r;
|
||||
} else {
|
||||
throw std::runtime_error("Failed to arrange polygons");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -483,6 +483,18 @@ public:
|
||||
return ps;
|
||||
}
|
||||
|
||||
size_t delete_same_facet_edge_pairs() {
|
||||
size_t n_deleted = 0;
|
||||
for (auto it = arr.edges_begin(); it != arr.edges_end();) {
|
||||
decltype(it) current = it++;
|
||||
if (current->face() == current->twin()->face()) {
|
||||
arr.remove_edge(current);
|
||||
n_deleted++;
|
||||
}
|
||||
}
|
||||
return n_deleted;
|
||||
}
|
||||
|
||||
void merge(const std::vector<int>& edge_indices) {
|
||||
if (edge_indices.empty()) {
|
||||
return;
|
||||
|
||||
@@ -67,6 +67,7 @@ namespace svgfill {
|
||||
virtual std::vector<int> get_face_pairs() = 0;
|
||||
virtual size_t num_edges() = 0;
|
||||
virtual size_t num_faces() = 0;
|
||||
virtual size_t delete_same_facet_edge_pairs() = 0;
|
||||
};
|
||||
|
||||
class SVGFILL_API context {
|
||||
@@ -101,6 +102,7 @@ namespace svgfill {
|
||||
void write(std::vector<std::vector<polygon_2>>&);
|
||||
size_t num_edges() { return arr_->num_edges(); }
|
||||
size_t num_faces() { return arr_->num_faces(); }
|
||||
size_t delete_same_facet_edge_pairs() { return arr_->delete_same_facet_edge_pairs(); }
|
||||
|
||||
~context() {
|
||||
delete arr_;
|
||||
@@ -113,7 +115,22 @@ namespace svgfill {
|
||||
SVGFILL_API std::string polygons_to_svg(const std::vector<std::vector<polygon_2>>& polygons, bool random_color=false);
|
||||
SVGFILL_API std::string polygons_to_svg(const std::vector<polygon_2>& polygons, bool random_color = false);
|
||||
SVGFILL_API bool svg_to_polygons(const std::string& data, const boost::optional<std::string>& class_name, std::vector<polygon_2>& polygons);
|
||||
SVGFILL_API bool arrange_polygons(const std::vector<polygon_2>& polygons, std::vector<polygon_2>& arranged);
|
||||
}
|
||||
|
||||
struct SVGFILL_API arrange_polygon_settings {
|
||||
bool debug_output = false;
|
||||
// -1: compute from average edge length
|
||||
double polygon_offset_distance = -1.;
|
||||
// 0: use offset - union - negative offset to find the outer perimeter
|
||||
// 1: radial walk along vertices; exact, but can only reuse vertices, not create new positions by means of intersections
|
||||
int outer_perimiter_algo = 0;
|
||||
// 0: outer perimiter and corridor center lines
|
||||
// 1: input polygons, corridor center lines and segments connecting corridor center lines to input polygons
|
||||
int topology_reconstruction_algo = 0;
|
||||
bool perform_cleanup = true;
|
||||
double subdivision_factor = 16.;
|
||||
};
|
||||
|
||||
SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector<polygon_2>& polygons, std::vector<polygon_2>& arranged);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user