From 6c18ac96053de87e1b24881184bc4734b6f0030f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:42:29 +0100 Subject: [PATCH 01/76] ifcgit: use --prioritise-local flag for ifcmerge-forward mergetool --- src/bonsai/bonsai/tool/ifcgit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 1843716f58..91bbf1ac47 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -473,10 +473,15 @@ class IfcGit: config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") config_writer.set_value(section, "trustExitCode", True) section = 'mergetool "ifcmerge-forward"' + new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED" + old_cmd = "ifcmerge $BASE $REMOTE $LOCAL $MERGED" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $REMOTE $LOCAL $MERGED") + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") == old_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) @classmethod def config_push(cls, repo: git.Repo) -> None: From bcc631bf5fa3a2bb4dd4ed604f7994a3f8146a25 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:44:56 +0100 Subject: [PATCH 02/76] ifcgit: improve colourise to find products via geometry and property changes Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/ifcgit.py | 41 +++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 91bbf1ac47..3cb9a7f227 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -388,20 +388,43 @@ class IfcGit: model = tool.Ifc.get() modified_step_ids = {"modified": set()} - for step_id in step_ids["modified"] | step_ids["added"]: - try: - entity = model.by_id(step_id) - except: - continue - if entity.is_a("IfcProductDefinitionShape"): + def collect(entity, depth=0): + if depth > 2: + return + if entity.is_a("IfcProduct"): + modified_step_ids["modified"].add(entity.id()) + elif entity.is_a("IfcProductDefinitionShape"): for product in entity.ShapeOfProduct: modified_step_ids["modified"].add(product.id()) elif entity.is_a("IfcObjectPlacement"): for product in entity.PlacesObject: modified_step_ids["modified"].add(product.id()) - elif entity.is_a("IfcTypeProduct") and entity.Types: - for related_object in entity.Types[0].RelatedObjects: - modified_step_ids["modified"].add(related_object.id()) + elif entity.is_a("IfcTypeProduct"): + for rel in entity.Types: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcShapeRepresentation"): + for prod_rep in entity.OfProductRepresentation: + for product in prod_rep.ShapeOfProduct: + modified_step_ids["modified"].add(product.id()) + elif entity.is_a("IfcRepresentationItem"): + for referencing in model.get_inverse(entity): + if referencing.is_a("IfcShapeRepresentation"): + collect(referencing, depth + 1) + elif entity.is_a("IfcPropertySet"): + for rel in entity.DefinesOccurrence: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcProperty"): + for pset in entity.PartOfPset: + collect(pset, depth + 1) + + for step_id in step_ids["modified"] | step_ids["added"]: + try: + entity = model.by_id(step_id) + except: + continue + collect(entity) return modified_step_ids From be3aef59fa0b5eed9f76ff71e547add6c6820025 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 22:45:33 +0100 Subject: [PATCH 03/76] ifcgit: move action buttons below revision list in a labelled row Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/operator.py | 6 +++--- src/bonsai/bonsai/bim/module/ifcgit/ui.py | 20 +++++-------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 5f01ef78ea..32b49b7e57 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -202,7 +202,7 @@ class DeleteTag(bpy.types.Operator): class RefreshGit(bpy.types.Operator): """Refresh revision list""" - bl_label = "" + bl_label = "Refresh" bl_idname = "ifcgit.refresh" bl_options = {"REGISTER"} @@ -225,7 +225,7 @@ class RefreshGit(bpy.types.Operator): class DisplayRevision(bpy.types.Operator): """Colourise objects by selected revision""" - bl_label = "" + bl_label = "Colourise Revision" bl_idname = "ifcgit.display_revision" bl_options = {"REGISTER"} @@ -260,7 +260,7 @@ class DisplayUncommitted(bpy.types.Operator): class SwitchRevision(bpy.types.Operator): """Switches the repository to the selected revision and reloads the IFC file""" - bl_label = "" + bl_label = "Switch Revision" bl_idname = "ifcgit.switch_revision" bl_options = {"REGISTER"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 6ac4aceb74..1d03d37b74 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -113,14 +113,11 @@ class IFCGIT_PT_panel(bpy.types.Panel): else: row.label(text="Working branch: " + IfcGitData.data["active_branch_name"]) - grouped = layout.row() - column = grouped.column() - row = column.row() + row = layout.row() row.prop(props, "display_branch", text="Browse branch") row.prop(props, "ifcgit_filter", text="Filter revisions") - row = column.row() - row.template_list( + layout.template_list( "COMMIT_UL_List", "The_List", props, @@ -128,20 +125,13 @@ class IFCGIT_PT_panel(bpy.types.Panel): props, "commit_index", ) - column = grouped.column() - row = column.row() + + row = layout.row(align=True) row.operator("ifcgit.refresh", icon="FILE_REFRESH") - if not is_dirty: - - row = column.row() row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE") - - row = column.row() row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") - - row = column.row() - row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="") + row.operator("ifcgit.merge", icon="SYSTEM") if not props.ifcgit_commits: return From d5b551874d9f96971aaf221888b037532a1bfbb9 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:16:40 +0100 Subject: [PATCH 04/76] ifcgit: pre-fill branch name when switching to a remote branch tip When a remote branch tip is checked out (resulting in detached HEAD), the new-branch name field is now pre-filled with the local equivalent of the remote branch name (generating a unique suffix if that name is already taken), so the commit button is immediately usable. See #7580 Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/ifcgit.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 3cb9a7f227..7bfda6cc57 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -476,11 +476,28 @@ class IfcGit: if item.hexsha in lookup: for branch in lookup[item.hexsha]: if branch.name == props.display_branch: + if isinstance(branch, git.RemoteReference): + # Checking out a remote branch tip goes to detached HEAD. + # Pre-fill the new branch name field with the local equivalent + # so the user isn't blocked from committing without a hint. + local_name = branch.remote_head + props.new_branch_name = cls._unique_branch_name(repo, local_name) branch.checkout() return # NOTE this is calling the git binary in a subprocess repo.git.checkout(item.hexsha) + @classmethod + def _unique_branch_name(cls, repo: git.Repo, name: str) -> str: + """Return name if unused, otherwise name-2, name-3, etc.""" + existing = {h.name for h in repo.heads} + if name not in existing: + return name + i = 2 + while f"{name}-{i}" in existing: + i += 1 + return f"{name}-{i}" + @classmethod def delete_collection(cls, blender_collection: bpy.types.Collection) -> None: for obj in blender_collection.objects: From e3cadab40656b92e80b0af88bf83cfaae54cd081 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:42:28 +0100 Subject: [PATCH 05/76] ifcgit: add clone widget to new project wizard (#7579) --- src/bonsai/bonsai/bim/module/project/ui.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index dfe8e44987..5e828ddd9b 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -19,6 +19,7 @@ from __future__ import annotations import os +import shutil from typing import TYPE_CHECKING import bpy @@ -384,6 +385,18 @@ class BIM_PT_new_project_wizard(Panel): row = self.layout.row() row.operator("bim.create_project") + if shutil.which("git"): + git_props = context.scene.IfcGitProperties + box = self.layout.box() + row = box.row() + row.label(text="Clone a remote Git repository") + row = box.row() + row.prop(git_props, "remote_url") + row = box.row() + row.prop(git_props, "local_folder") + row = box.row() + row.operator("ifcgit.clone_repo", icon="IMPORT") + class BIM_PT_project_library(Panel): bl_label = "Project Library" From c5210a4f8205116c9bf7f95d8927bef1b5ffea25 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 2 Apr 2026 23:54:52 +0100 Subject: [PATCH 06/76] ifcgit: sync load_project post-import steps with project operator See #7578 --- src/bonsai/bonsai/tool/ifcgit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 7bfda6cc57..1a69158918 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -278,6 +278,7 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] from bonsai.bim.module.root.data import IfcClassData + import bonsai.bim.handler IfcClassData.is_loaded = False @@ -285,10 +286,11 @@ class IfcGit: settings.should_setup_viewport_camera = False ifc_importer = import_ifc.IfcImporter(settings) ifc_importer.execute() - tool.Project.load_project_pset_templates() tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() + tool.Root.reload_grid_decorator() + bonsai.bim.handler.refresh_ui_data() bpy.ops.object.select_all(action="DESELECT") @classmethod From 3a6881ca17903576746e18ee004f187a002d6544 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 3 Apr 2026 00:20:05 +0100 Subject: [PATCH 07/76] ifcgit: add rename branch button next to working branch label See #7577 Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/__init__.py | 1 + .../bonsai/bim/module/ifcgit/operator.py | 34 +++++++++++++++++++ src/bonsai/bonsai/bim/module/ifcgit/ui.py | 1 + src/bonsai/bonsai/core/ifcgit.py | 4 +++ src/bonsai/bonsai/tool/ifcgit.py | 4 +++ 5 files changed, 44 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index df47b7774c..077a08b062 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -36,6 +36,7 @@ classes = ( operator.ObjectLog, operator.Push, operator.RefreshGit, + operator.RenameBranch, operator.SwitchRevision, operator.InstallGit, operator.RunGitDiff, diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 32b49b7e57..8e86a5b18e 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -469,3 +469,37 @@ class RunGitDiff(bpy.types.Operator): def execute(self, context): core.run_git_diff(tool.IfcGit, self, self.save_to_temp) return {"FINISHED"} + + +class RenameBranch(bpy.types.Operator): + """Rename the current branch""" + + bl_label = "Rename Branch" + bl_idname = "ifcgit.rename_branch" + bl_options = {"REGISTER"} + + new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + new_name: str + + @classmethod + def poll(cls, context): + IfcGitData.make_sure_is_loaded() + if not IfcGitData.data["repo"]: + return False + if IfcGitData.data["is_detached"]: + return False + if IfcGitData.data["is_dirty"]: + return False + return True + + def invoke(self, context, event): + self.new_name = IfcGitData.data["active_branch_name"] + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + repo = IfcGitData.data["repo"] + core.rename_branch(tool.IfcGit, repo, self.new_name) + refresh() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 1d03d37b74..3b8bc45000 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -112,6 +112,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): row.label(text="Working branch: Detached HEAD") else: row.label(text="Working branch: " + IfcGitData.data["active_branch_name"]) + row.operator("ifcgit.rename_branch", icon="GREASEPENCIL", text="") row = layout.row() row.prop(props, "display_branch", text="Browse branch") diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index f14dda6cbb..3fb8a79055 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -87,6 +87,10 @@ def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) - ifcgit.delete_remote(repo, remote_name) +def rename_branch(ifcgit: type[tool.IfcGit], repo: git.Repo, new_name: str) -> None: + ifcgit.rename_branch(repo, new_name) + + def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None: error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name()) if error_message: diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 1a69158918..74cef0f522 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -155,6 +155,10 @@ class IfcGit: if tag_name in repo.tags: repo.delete_tag(tag_name) + @classmethod + def rename_branch(cls, repo: git.Repo, new_name: str) -> None: + repo.active_branch.rename(new_name) + @classmethod def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None: repo.create_remote(name=remote_name, url=remote_url) From 95851ff94c82f647313f9a98843dcf0d3cdd69f7 Mon Sep 17 00:00:00 2001 From: DesertSpringsCivil Date: Thu, 2 Apr 2026 18:50:27 -0600 Subject: [PATCH 08/76] feat: Add Anthropic Claude API support to ifcchat Add a provider selector (OpenAI / Anthropic) to the ifcchat web UI, allowing users to use their Anthropic API key with Claude models instead of only OpenAI. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ifcchat/app.js | 198 +++++++++++++++++++++++++++++++++++++---- src/ifcchat/index.html | 26 ++++-- 2 files changed, 203 insertions(+), 21 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 516ec8f846..541f3aef43 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -7,10 +7,57 @@ const sendBtn = $("send"); const inputEl = $("input"); const apiKeyEl = $("apiKey"); const modelEl = $("model"); +const providerEl = $("provider"); const ifcFileEl = $("ifcFile"); const newBtn = $("newModel"); const downloadBtn = $("downloadIfc"); +// ---- Provider switching ---- + +const PROVIDER_MODELS = { + openai: ["gpt-5", "gpt-4.1"], + anthropic: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"], +}; + +const PROVIDER_LABELS = { + openai: "OpenAI API key", + anthropic: "Anthropic API key", +}; + +const PROVIDER_PLACEHOLDERS = { + openai: "sk-...", + anthropic: "sk-ant-...", +}; + +function getProvider() { + return providerEl.value; +} + +function updateProviderUI() { + const provider = getProvider(); + $("apiKeyLabel").innerHTML = `${PROVIDER_LABELS[provider]}stored in browser memory; only sent to provider servers`; + apiKeyEl.placeholder = PROVIDER_PLACEHOLDERS[provider]; + + // Enable/disable model optgroups and select first available model + for (const [key, models] of Object.entries(PROVIDER_MODELS)) { + const group = $(`modelGroup${key.charAt(0).toUpperCase() + key.slice(1)}`); + if (!group) continue; + const isActive = key === provider; + group.disabled = !isActive; + for (const opt of group.querySelectorAll("option")) { + opt.disabled = !isActive; + } + } + + modelEl.value = PROVIDER_MODELS[provider][0]; + + // Reset conversation on provider switch + openaiInputItems = []; + anthropicMessages = []; +} + +providerEl.addEventListener("change", updateProviderUI); + function setBusy(isBusy, reason = "") { const controls = [ $("send"), @@ -75,8 +122,7 @@ 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 (OpenAI Responses API format) ---- const tools = [ { type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.", @@ -146,6 +192,15 @@ const tools = [ }, ]; +// Convert OpenAI tool format to Anthropic tool format +function toolsForAnthropic() { + return tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.parameters, + })); +} + const SYSTEM_INSTRUCTIONS = ` You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model. Rules: @@ -156,7 +211,15 @@ Rules: Be concise. Avoid dumping huge trees unless asked. `; -let inputItems = []; // running conversation state (Responses API style) +// ---- OpenAI state ---- +let openaiInputItems = []; + +// ---- Anthropic state ---- +let anthropicMessages = []; + +// ============================================================ +// OpenAI Responses API +// ============================================================ async function openAIResponsesCreate({ apiKey, model, input, tools }) { const res = await fetch("https://api.openai.com/v1/responses", { @@ -180,7 +243,7 @@ async function openAIResponsesCreate({ apiKey, model, input, tools }) { return await res.json(); } -function extractAssistantText(response) { +function extractAssistantTextOpenAI(response) { const out = []; for (const item of response.output ?? []) { if (item.type === "message" && item.role === "assistant") { @@ -192,27 +255,23 @@ function extractAssistantText(response) { return out.join("\n").trim(); } -async function runAgentTurn(userText) { +async function runOpenAITurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); - // Add user message - inputItems.push({ role: "user", content: userText }); + openaiInputItems.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({ apiKey, model: modelEl.value, - input: inputItems, + input: openaiInputItems, tools, }); - // Keep ALL output items (incl reasoning/tool calls) in the running state. - inputItems.push(...(response.output ?? [])); + openaiInputItems.push(...(response.output ?? [])); - // Show any assistant text immediately - const text = extractAssistantText(response); + const text = extractAssistantTextOpenAI(response); if (text) addMessage("assistant", text); const calls = (response.output ?? []).filter((x) => x.type === "function_call"); @@ -227,8 +286,7 @@ async function runAgentTurn(userText) { const toolRes = await callWorker("toolCall", { name: call.name, args }); - // Feed tool result back to the model - inputItems.push({ + openaiInputItems.push({ type: "function_call_output", call_id: call.call_id, output: JSON.stringify(toolRes.result), @@ -241,6 +299,113 @@ async function runAgentTurn(userText) { addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request."); } +// ============================================================ +// Anthropic Messages API +// ============================================================ + +async function anthropicMessagesCreate({ apiKey, model, system, messages, tools }) { + 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({ + model, + max_tokens: 4096, + system, + tools, + messages, + }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Anthropic error ${res.status}: ${text}`); + } + return await res.json(); +} + +function extractAssistantTextAnthropic(response) { + const out = []; + for (const block of response.content ?? []) { + if (block.type === "text") out.push(block.text); + } + return out.join("\n").trim(); +} + +async function runAnthropicTurn(userText) { + const apiKey = apiKeyEl.value.trim(); + if (!apiKey) throw new Error("Missing API key"); + + anthropicMessages.push({ role: "user", content: userText }); + + const claudeTools = toolsForAnthropic(); + + for (let i = 0; i < 64; i++) { + const response = await anthropicMessagesCreate({ + apiKey, + model: modelEl.value, + system: SYSTEM_INSTRUCTIONS, + messages: anthropicMessages, + tools: claudeTools, + }); + + // Show any assistant text + const text = extractAssistantTextAnthropic(response); + if (text) addMessage("assistant", text); + + // Append the full assistant response to conversation history + anthropicMessages.push({ role: "assistant", content: response.content }); + + // Check for tool use + const toolUseBlocks = (response.content ?? []).filter((b) => b.type === "tool_use"); + if (response.stop_reason !== "tool_use" || toolUseBlocks.length === 0) return; + + // Execute each tool call and collect results + const toolResultBlocks = []; + for (const toolUse of toolUseBlocks) { + const args = toolUse.input ?? {}; + + addMessage("tool", `→ ${toolUse.name}(${JSON.stringify(args)})`); + + let resultContent; + try { + const toolRes = await callWorker("toolCall", { name: toolUse.name, args }); + resultContent = JSON.stringify(toolRes.result); + } catch (e) { + resultContent = JSON.stringify({ error: e.message }); + } + + toolResultBlocks.push({ + type: "tool_result", + tool_use_id: toolUse.id, + content: resultContent, + }); + + addMessage("tool", `← ${toolUse.name}: ${resultContent}`); + } + + // Feed all tool results back as a single user message + anthropicMessages.push({ role: "user", content: toolResultBlocks }); + } + + addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request."); +} + +// ============================================================ +// Unified agent turn +// ============================================================ + +async function runAgentTurn(userText) { + if (getProvider() === "anthropic") { + return runAnthropicTurn(userText); + } + return runOpenAITurn(userText); +} + sendBtn.onclick = async () => { const text = inputEl.value.trim(); if (!text) return; @@ -312,9 +477,10 @@ downloadBtn.onclick = async () => { try { setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…"); await callWorker("init", {}); + updateProviderUI(); setBusy(false, "Ready"); } catch (e) { setBusy(true, "Error"); addMessage("assistant", `Worker init failed: ${e.message}`); } -})(); \ No newline at end of file +})(); diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index ac878ae615..0697bfa790 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -143,7 +143,8 @@ padding: 8px 10px; } - #model { + #model, + #provider { background: white; color: gray; border: solid 1px #eee; @@ -239,14 +240,22 @@
- + + +
+ +
+

- +
From eaf7950677005f6916ab4ebc9ae1e847f15b0071 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 2 Apr 2026 10:56:56 -0500 Subject: [PATCH 09/76] Fix TypeError in ray_cast_by_proximity_2d degenerate edge A degenerate edge (zero-length segment) caused an early `return` of a tuple instead of continuing the loop, resulting in a TypeError when snap.py iterated the result and tried to assign `point["group"]` on a float. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/tool/raycast.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index c16bc55564..6b613aecaf 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -434,11 +434,8 @@ class Raycast(bonsai.core.tool.Raycast): seg_len_sq = sx * sx + sy * sy if seg_len_sq == 0.0: - # degenerate segment: return distance to p0 - dx = px - p0x - dy = py - p0y - dist = math.hypot(dx, dy) - return dist, (p0x, p0y), 0.0 + # degenerate segment: skip it + continue # project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2 apx = px - p0x From 1d7a8ca2491226549603f828787113ac3ac37131 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 13:19:16 +0200 Subject: [PATCH 10/76] separate API calls --- src/ifcchat/api_openai.js | 16 ++++++++++++++++ src/ifcchat/api_openrouter.js | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/ifcchat/api_openai.js create mode 100644 src/ifcchat/api_openrouter.js diff --git a/src/ifcchat/api_openai.js b/src/ifcchat/api_openai.js new file mode 100644 index 0000000000..0fa6d7a88f --- /dev/null +++ b/src/ifcchat/api_openai.js @@ -0,0 +1,16 @@ + +export async function chat({ apiKey, model, messages, tools }) { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages, tools }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`OpenAI error ${res.status}: ${text}`); + } + return await res.json(); +} diff --git a/src/ifcchat/api_openrouter.js b/src/ifcchat/api_openrouter.js new file mode 100644 index 0000000000..9a02ee3025 --- /dev/null +++ b/src/ifcchat/api_openrouter.js @@ -0,0 +1,16 @@ + +export async function chat({ apiKey, model, messages, tools }) { + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + 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(); +} From b7fc5daf828794297ffc30767591ad52119b284f Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 13:19:28 +0200 Subject: [PATCH 11/76] ui: choose openai/openrouter --- src/ifcchat/index.html | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index ac878ae615..2bf0b6c4f1 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -239,7 +239,15 @@
- + + +
+ +
+
@@ -295,10 +303,7 @@ IfcOpenShell AI Assistant - +
From 0f7f960b29ab47204f9b6f533696a2acf6689a07 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 13:25:16 +0200 Subject: [PATCH 12/76] let claude code openrouter compatibility --- src/ifcchat/app.js | 169 ++++++++++++++++++++++----------------------- 1 file changed, 82 insertions(+), 87 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 516ec8f846..642ef275df 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -1,4 +1,26 @@ // app.js +import * as openaiApi from "./api_openai.js"; +import * as openrouterApi from "./api_openrouter.js"; + +const PROVIDERS = { + openai: { + api: openaiApi, + models: [ + { value: "gpt-5", label: "gpt-5" }, + { value: "gpt-4.1", label: "gpt-4.1" }, + ], + }, + openrouter: { + api: openrouterApi, + models: [ + { value: "openai/gpt-oss-20b", label: "gpt-oss-20b" }, + { value: "anthropic/claude-sonnet-4-5", label: "claude-sonnet-4-5" }, + { value: "openai/gpt-4.1", label: "gpt-4.1" }, + { value: "google/gemini-2.5-pro-preview", label: "gemini-2.5-pro" }, + ], + }, +}; + const $ = (id) => document.getElementById(id); const statusEl = $("status"); @@ -7,10 +29,19 @@ const sendBtn = $("send"); const inputEl = $("input"); const apiKeyEl = $("apiKey"); const modelEl = $("model"); +const providerEl = $("provider"); const ifcFileEl = $("ifcFile"); const newBtn = $("newModel"); const downloadBtn = $("downloadIfc"); +function onProviderChange() { + const p = PROVIDERS[providerEl.value]; + modelEl.innerHTML = p.models.map(m => ``).join(""); +} + +providerEl.addEventListener("change", onProviderChange); +onProviderChange(); + function setBusy(isBusy, reason = "") { const controls = [ $("send"), @@ -75,74 +106,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 +186,50 @@ 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 { chat } = PROVIDERS[providerEl.value].api; + + 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, 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)}`); } } From fcbec74521088b5295493de475d83fb50f4628fa Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 13:27:54 +0200 Subject: [PATCH 13/76] spinner --- src/ifcchat/app.js | 4 ++++ src/ifcchat/index.html | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 642ef275df..d9c6d6bcd6 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -61,6 +61,10 @@ function setBusy(isBusy, reason = "") { browseBtn.tabIndex = isBusy ? -1 : 0; } + sendBtn.innerHTML = isBusy + ? `` + : `Send send`; + setStatus(isBusy ? (reason || "Working…") : "Ready"); } diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index 2bf0b6c4f1..77a57c07a8 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -113,7 +113,18 @@ border-radius: 20px; padding: 10px; display: flex; - width: 50%; + 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 { From 3b28c92414d53780f5f651cea263504075b9fb86 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 13:29:35 +0200 Subject: [PATCH 14/76] html too big -> styles into sep. file --- src/ifcchat/index.html | 239 +---------------------------------------- src/ifcchat/style.css | 232 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 238 deletions(-) create mode 100644 src/ifcchat/style.css diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index 77a57c07a8..686dce5663 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -5,244 +5,7 @@ IfcOpenShell AI Assistant - + diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css new file mode 100644 index 0000000000..6c3af2f56e --- /dev/null +++ b/src/ifcchat/style.css @@ -0,0 +1,232 @@ +* { + 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%; + max-height: 100px; + overflow: hidden; +} + +.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: 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; +} + +.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; +} From 252bd6f4f6a1a171ed4630d22111b69fca555a09 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 21:33:54 +0200 Subject: [PATCH 15/76] openai by default --- src/ifcchat/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index 686dce5663..9c89d15715 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -15,8 +15,8 @@
From d7de4f8df07905cb9d6fac239b6b7019f83c4186 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 21:35:43 +0200 Subject: [PATCH 16/76] dont freeze UI on error --- src/ifcchat/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index d9c6d6bcd6..ef0e9bce5b 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -250,7 +250,7 @@ sendBtn.onclick = async () => { await runAgentTurn(text); setBusy(false, "Ready"); } catch (e) { - setBusy(true, "Error"); + setBusy(false, "Error"); addMessage("assistant", `Error: ${e.message}`); } }; From 29e5e0fd1ad1689f20e830c1f4eccaae2ef80582 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 21:51:45 +0200 Subject: [PATCH 17/76] chevrons for tool result expansion --- src/ifcchat/app.js | 8 +++++--- src/ifcchat/style.css | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index ef0e9bce5b..8f2b1f3b97 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -75,14 +75,16 @@ function addMessage(role, text) { const wrap = document.createElement("div"); wrap.className = `msg ${role}`; wrap.innerHTML = ` -
${role}
+
${role}${role === "tool" ? '' : ''}
`; const bubble = wrap.querySelector(".bubble"); 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); diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css index 6c3af2f56e..90fd417501 100644 --- a/src/ifcchat/style.css +++ b/src/ifcchat/style.css @@ -82,6 +82,15 @@ main { 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 { From 7af0ec13e51139e64dd7c6b1f5f844a9549459b1 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 21:52:59 +0200 Subject: [PATCH 18/76] move model to sidebar --- src/ifcchat/index.html | 8 ++++++-- src/ifcchat/style.css | 6 ------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index 9c89d15715..f4e922dca5 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -17,7 +17,12 @@ + +
+ +
+ +
@@ -77,7 +82,6 @@ IfcOpenShell AI Assistant -
diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css index 90fd417501..ef5f6add8f 100644 --- a/src/ifcchat/style.css +++ b/src/ifcchat/style.css @@ -157,12 +157,6 @@ section > .row > label .small { padding: 8px 10px; } -#model { - background: white; - color: gray; - border: solid 1px #eee; - border-radius: 6px; -} hr { border: dashed 1px #ddd; From fbf2946b698d5f762beb2470a3fd7d71ef5291b6 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 21:53:44 +0200 Subject: [PATCH 19/76] Update index.html --- src/ifcchat/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index f4e922dca5..20129d1937 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -26,7 +26,7 @@
- +
From 434ea74f22f7d3a930544893d53afc2b2e951336 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 21:56:55 +0200 Subject: [PATCH 20/76] format this mess --- src/ifcchat/app.js | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 8f2b1f3b97..1833cc2e9d 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -6,17 +6,35 @@ const PROVIDERS = { openai: { api: openaiApi, models: [ - { value: "gpt-5", label: "gpt-5" }, - { value: "gpt-4.1", label: "gpt-4.1" }, + { + value: "gpt-5", + label: "gpt-5" + }, + { + value: "gpt-4.1", + label: "gpt-4.1" + }, ], }, openrouter: { api: openrouterApi, models: [ - { value: "openai/gpt-oss-20b", label: "gpt-oss-20b" }, - { value: "anthropic/claude-sonnet-4-5", label: "claude-sonnet-4-5" }, - { value: "openai/gpt-4.1", label: "gpt-4.1" }, - { value: "google/gemini-2.5-pro-preview", label: "gemini-2.5-pro" }, + { + value: "openai/gpt-oss-20b", + label: "gpt-oss-20b" + }, + { + value: "anthropic/claude-sonnet-4-5", + label: "claude-sonnet-4-5" + }, + { + value: "openai/gpt-4.1", + label: "gpt-4.1" + }, + { + value: "google/gemini-2.5-pro-preview", + label: "gemini-2.5-pro" + }, ], }, }; From 8f64f75abb15886e5292939fef8f89643f8a67c8 Mon Sep 17 00:00:00 2001 From: geronimi73 Date: Thu, 2 Apr 2026 22:06:19 +0200 Subject: [PATCH 21/76] add favourite models --- src/ifcchat/app.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 1833cc2e9d..b367359516 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -20,17 +20,25 @@ const PROVIDERS = { api: openrouterApi, models: [ { - value: "openai/gpt-oss-20b", + value: "openai/gpt-oss-20b", label: "gpt-oss-20b" }, { - value: "anthropic/claude-sonnet-4-5", - label: "claude-sonnet-4-5" + 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" From 5d748c5b048ba959fe6aa14dabdf49a8785646b9 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 10:49:36 +0200 Subject: [PATCH 22/76] Add Gemini option --- src/ifcchat/README.md | 2 +- src/ifcchat/api_openai.js | 8 +++++-- src/ifcchat/api_openrouter.js | 8 +++++-- src/ifcchat/app.js | 45 ++++++++++++++++++++++++++++++++++- src/ifcchat/index.html | 6 +++++ 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/ifcchat/README.md b/src/ifcchat/README.md index b2cceecc52..5dc434143a 100644 --- a/src/ifcchat/README.md +++ b/src/ifcchat/README.md @@ -1,7 +1,7 @@ IfcOpenShell AI Assistant ========================= -A web-based client-side (pyodide + OpenAI, Anthropic, or OpenRouter API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application. +A web-based client-side (pyodide + OpenAI, Anthropic, Gemini, or OpenRouter API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application. ### Setup instructions diff --git a/src/ifcchat/api_openai.js b/src/ifcchat/api_openai.js index 0fa6d7a88f..8903f897e4 100644 --- a/src/ifcchat/api_openai.js +++ b/src/ifcchat/api_openai.js @@ -1,6 +1,10 @@ +function getChatCompletionsUrl(baseURL) { + const root = (baseURL || "https://api.openai.com/v1").replace(/\/+$/, ""); + return `${root}/chat/completions`; +} -export async function chat({ apiKey, model, messages, tools }) { - const res = await fetch("https://api.openai.com/v1/chat/completions", { +export async function chat({ apiKey, baseURL, model, messages, tools }) { + const res = await fetch(getChatCompletionsUrl(baseURL), { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/ifcchat/api_openrouter.js b/src/ifcchat/api_openrouter.js index 9a02ee3025..38c11e36e8 100644 --- a/src/ifcchat/api_openrouter.js +++ b/src/ifcchat/api_openrouter.js @@ -1,6 +1,10 @@ +function getChatCompletionsUrl(baseURL) { + const root = (baseURL || "https://openrouter.ai/api/v1").replace(/\/+$/, ""); + return `${root}/chat/completions`; +} -export async function chat({ apiKey, model, messages, tools }) { - const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { +export async function chat({ apiKey, baseURL, model, messages, tools }) { + const res = await fetch(getChatCompletionsUrl(baseURL), { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 22a9b0a055..28a20f1cb7 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -8,6 +8,9 @@ const PROVIDERS = { 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", @@ -38,10 +41,35 @@ const PROVIDERS = { }, ], }, + 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", @@ -79,6 +107,9 @@ const sendBtn = $("send"); const inputEl = $("input"); const apiKeyEl = $("apiKey"); const apiKeyLabelEl = $("apiKeyLabel"); +const baseUrlRowEl = $("baseUrlRow"); +const baseUrlLabelEl = $("baseUrlLabel"); +const baseUrlEl = $("baseUrl"); const modelEl = $("model"); const providerEl = $("provider"); const ifcFileEl = $("ifcFile"); @@ -89,6 +120,15 @@ function onProviderChange() { const provider = PROVIDERS[providerEl.value]; apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}stored in browser memory; only sent to provider servers`; apiKeyEl.placeholder = provider.apiKeyPlaceholder; + baseUrlRowEl.hidden = !provider.baseUrlDefault; + if (provider.baseUrlDefault) { + baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}override the API endpoint for OpenAI-compatible providers`; + baseUrlEl.placeholder = provider.baseUrlPlaceholder; + baseUrlEl.value = provider.baseUrlDefault; + } else { + baseUrlEl.value = ""; + baseUrlEl.placeholder = ""; + } modelEl.innerHTML = provider.models.map(m => ``).join(""); } @@ -251,13 +291,16 @@ async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); - const { chat } = PROVIDERS[providerEl.value].api; + const provider = PROVIDERS[providerEl.value]; + const { chat } = provider.api; + const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; messages.push({ role: "user", content: userText }); for (let i = 0; i < 64; i++) { const response = await chat({ apiKey, + baseURL, model: modelEl.value, messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages], tools, diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index 1ad3bc198d..d7f348d282 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -17,6 +17,7 @@
@@ -31,6 +32,11 @@ +
+ + +
+
From 24acfeaf454f0a7ca0318c4741dcd4634f5f88f2 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 10:59:09 +0200 Subject: [PATCH 23/76] Thinking indicator under chat --- src/ifcchat/app.js | 5 +- src/ifcchat/index.html | 7 +- src/ifcchat/style.css | 17 + .../mapping/IfcPointByDistanceExpression.cpp | 9 + src/ifcopenshell-python/ifcopenshell/draw.py | 3 +- src/ifcparse/IfcSchema.h | 1 + src/ifcwrap/IfcGeomWrapper.i | 5 +- src/svgfill/src/arrange_polygons.cpp | 1050 +++++++++++++---- src/svgfill/src/svgfill.cpp | 12 + src/svgfill/src/svgfill.h | 21 +- 10 files changed, 889 insertions(+), 241 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 28a20f1cb7..ae1d7b0ae9 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -110,6 +110,7 @@ const apiKeyLabelEl = $("apiKeyLabel"); const baseUrlRowEl = $("baseUrlRow"); const baseUrlLabelEl = $("baseUrlLabel"); const baseUrlEl = $("baseUrl"); +const thinkingIndicatorEl = $("thinkingIndicator"); const modelEl = $("model"); const providerEl = $("provider"); const ifcFileEl = $("ifcFile"); @@ -180,12 +181,14 @@ function addMessage(role, text) { 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" }); diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index d7f348d282..1048831657 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -96,7 +96,12 @@
-
+
+ +
diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css index ef5f6add8f..9d3ba064c9 100644 --- a/src/ifcchat/style.css +++ b/src/ifcchat/style.css @@ -53,6 +53,23 @@ main { 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; diff --git a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp index 180226fb82..f07234a8f1 100644 --- a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp +++ b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp @@ -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()) { diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 5f6d761ceb..962dbbb34f 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -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] diff --git a/src/ifcparse/IfcSchema.h b/src/ifcparse/IfcSchema.h index 3dedd47a8e..349a81532d 100644 --- a/src/ifcparse/IfcSchema.h +++ b/src/ifcparse/IfcSchema.h @@ -358,6 +358,7 @@ class IFC_PARSE_API entity : public declaration { const std::vector& subtypes() const { return subtypes_; } const std::vector& attributes() const { return attributes_; } + const std::vector& inverse_attributes() const { return inverse_attributes_; } const std::vector& derived() const { return derived_; } const std::vector all_attributes() const { diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index e155c2837e..e992e4beab 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -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>; %template(svg_groups_of_line_segments) std::vector>>; @@ -1287,9 +1288,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type } } - std::vector arrange_polygons(const std::vector& polygons) { + std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons) { std::vector 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"); diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 4fa20c68dc..3ce49011f7 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -350,6 +350,8 @@ find_overlaps(const std::vector& polygons) { class DebugWriter { public: + DebugWriter() : enabled_(false) {} + DebugWriter(bool enabled, const std::string& filename_prefix) : enabled_(enabled) { if (enabled_) { @@ -368,6 +370,43 @@ class DebugWriter { } } + DebugWriter(const DebugWriter&) = delete; + + DebugWriter(DebugWriter&& other) noexcept + : obj(std::move(other.obj)), vi(other.vi), svg(std::move(other.svg)), enabled_(other.enabled_), last_segment_name_(std::move(other.last_segment_name_)) + { + other.enabled_ = false; + other.vi = 1; + other.last_segment_name_.clear(); + } + + DebugWriter& operator=(const DebugWriter&) = delete; + + DebugWriter& operator=(DebugWriter&& other) noexcept { + if (this == &other) { + return *this; + } + + if (enabled_) { + svg << "\n"; + obj << std::flush; + obj.close(); + svg.close(); + } + + obj = std::move(other.obj); + svg = std::move(other.svg); + vi = other.vi; + enabled_ = other.enabled_; + last_segment_name_ = std::move(other.last_segment_name_); + + other.enabled_ = false; + other.vi = 1; + other.last_segment_name_.clear(); + + return *this; + } + void write_polygon(const Polygon_2& polygon, const std::string& name) { if (enabled_) { write_polygon_to_obj_(obj, vi, true, polygon, name); @@ -387,7 +426,7 @@ class DebugWriter { obj << "l " << vi++; obj << " " << vi++ << "\n"; - svg << ""; + svg << "\n"; obj << std::flush; } @@ -468,7 +507,7 @@ class DebugWriter { } }; -void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { +void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { // solve overlaps by means of subtraction // loop over overlaps and subtract the smaller polygon from the larger one @@ -576,11 +615,40 @@ void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vectorarea() << " " << poly2->area() << std::endl; + + bool is_ = edge == std::make_pair(25, 27); + bool success = false; if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) { + if (is_) { + debug_writer.write_polygon(*mp1, "mp1"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp1); + if (is_) { + debug_writer.write_polygon(*mp1, "mp1b"); + } if ((mp2 = subtract_retain_largest(*poly1, *mp1))) { + if (is_) { + debug_writer.write_polygon(*mp2, "mp2"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp2); + if (is_) { + debug_writer.write_polygon(*mp2, "mp2b"); + } if ((mp3 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE * 2, *mp2)))) { + if (is_) { + debug_writer.write_polygon(*mp3, "mp3"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp3); + if (is_) { + debug_writer.write_polygon(*mp3, "mp3b"); + } if ((mp4 = subtract_retain_largest(*poly2, *mp3))) { + if (is_) { + debug_writer.write_polygon(*mp4, "mp4"); + } *poly1 = *mp2; *poly2 = *mp4; success = true; @@ -589,6 +657,8 @@ void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vectorarea() << " " << poly2->area() << std::endl; + if (!success) { eliminated_polies.insert(swap ? edge.first : edge.second); continue; @@ -777,14 +847,19 @@ Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_h std::tuple< std::map>, std::map>, - std::map, std::vector*>>> -build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) { + std::map, std::vector*>>, + std::map +> +build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) +{ + // Build maps of triangle -> edge and edge -> triangle in order to do traversal on the 'corridor mesh' std::map, std::vector*>> segment_to_facet; std::map, std::vector*>> segment_to_input_facet; std::map, Point_2> segment_to_midpoint; std::map> midpoint_to_segment; std::map*, std::vector>> facet_to_segment; + std::map midpoint_to_edge_length; for (auto& tri : triangular_polygons) { for (size_t i = 0; i < 3; ++i) { @@ -813,6 +888,7 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se if (p1index != input_polygons.end() && p2index != input_polygons.end() && p1index != p2index) { segment_to_midpoint[p.first] = center; midpoint_to_segment[center] = p.first; + midpoint_to_edge_length[center] = std::sqrt(CGAL::to_double(CGAL::squared_distance(p.first.first, p.first.second))); } } @@ -832,7 +908,7 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se } } - return {line_graph, midpoint_to_segment, segment_to_input_facet}; + return {line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length}; } std::set> find_triangles(const std::map>& line_graph) { @@ -1118,66 +1194,91 @@ std::list> extend_end_vertices_based_on_input( const Graph2D& G, const std::map>& midpoint_to_segment, const std::map, std::vector*>>& segment_to_input_facet, - const Polygon_list& inner_offset, - const SegmentLookup& segment_lookup + const Polygon_list& outer_perimiter, + const SegmentLookup& segment_lookup, + const K::FT& max_projection_distance ){ std::list> constructed_segments; - for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { - if (it->second.size() == 1) { - auto& M = it->first; + std::set processed_vertices; - const std::pair* q = nullptr; + while (true) { + // The idea was to peal off 1-degree vertices when projecting them did not result into + // nearby intersections with the outer perimiter. This in case there would be turns near + // the perimeter, which would be eliminated by pealing off the vertices, which would then + // require out of the loop because of invalidated iterators. For now we decided to stick + // to a projection of the vertex onto the perimeter segment when the projection distance + // exceeds a threshold. + bool broke_out = false; - if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { - typename K::FT min_sq_distance = std::numeric_limits::infinity(); - for (auto& pa : midpoint_to_segment) { - if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { - q = &pa.second; - min_sq_distance = CGAL::squared_distance(pa.first, M); - } + for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { + if (it->second.size() == 1) { + auto& M = it->first; + + if (processed_vertices.find(M) != processed_vertices.end()) { + continue; } - } else { - q = &midpoint_to_segment.find(M)->second; - } - if (q == nullptr) { - continue; - } + const std::pair* q = nullptr; - bool handled_as_graph_path = false; + if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { + typename K::FT min_sq_distance = std::numeric_limits::infinity(); + for (auto& pa : midpoint_to_segment) { + if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { + q = &pa.second; + min_sq_distance = CGAL::squared_distance(pa.first, M); + } + } + } else { + q = &midpoint_to_segment.find(M)->second; + } - // distance from unioned - shoot ray? - if (segment_to_input_facet.find(*q)->second.size() == 2) { - for (auto& bnd : inner_offset) { - // if point M is contained in bnd interior: - // if (!bnd.has_on_unbounded_side(M)) { - if (bnd.has_on_bounded_side(M)) { - auto& incoming = *it->second.begin(); - // create ray incoming -> M - CGAL::Ray_2 ray(incoming, M - incoming); - // intersect ray with boundary - boost::optional> closest_segment; - boost::optional> closest_intersection_point; - K::FT sq_distance_along_ray = std::numeric_limits::infinity(); - for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { - const auto& seg = *jt; - auto x = CGAL::intersection(ray, seg); - if (x) { - if (auto* xp = variant_get>(&*x)) { - auto dist = ((*xp) - M).squared_length(); - if (dist < sq_distance_along_ray) { - closest_segment = seg; - closest_intersection_point = *xp; - sq_distance_along_ray = dist; + if (q == nullptr) { + continue; + } + + bool handled_as_graph_path = false; + + // distance from unioned - shoot ray? + if (segment_to_input_facet.find(*q)->second.size() == 2) { + for (auto& bnd : outer_perimiter) { + // if point M is contained in bnd interior: + // if (!bnd.has_on_unbounded_side(M)) { + if (bnd.has_on_bounded_side(M)) { + auto& incoming = *it->second.begin(); + // create ray incoming -> M + CGAL::Ray_2 ray(incoming, M - incoming); + + std::cerr << "Extending end vertex " << M << " along ray " << ray << " to boundary of input polygon" << std::endl; + + // intersect ray with boundary + boost::optional> closest_segment; + boost::optional> closest_intersection_point; + K::FT sq_distance_along_ray = std::numeric_limits::infinity(); + for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { + const auto& seg = *jt; + auto x = CGAL::intersection(ray, seg); + if (x) { + if (auto* xp = variant_get>(&*x)) { + auto dist = ((*xp) - M).squared_length(); + std::cerr << " - found " << *xp << " on segment " << seg << " with distance " << std::sqrt(CGAL::to_double(dist)) << std::endl; + if (dist < sq_distance_along_ray) { + if (dist < (max_projection_distance * max_projection_distance)) { + closest_segment = seg; + closest_intersection_point = *xp; + sq_distance_along_ray = dist; + } else { + + } + } } } } - } - if (closest_intersection_point) { - constructed_segments.push_front({M, *closest_intersection_point}); - break; + if (closest_intersection_point) { + constructed_segments.push_front({M, *closest_intersection_point}); + processed_vertices.insert(M); + break; #if 0 Graph2D GGG(bnd); GGG.refine(*GGG.query(*closest_intersection_point, 0.01), *closest_intersection_point); @@ -1217,12 +1318,33 @@ std::list> extend_end_vertices_based_on_input( break; } #endif - } else { - std::cerr << "Warning: no intersection found when extending end vertex, this will likely result in invalid topology" << std::endl; + } else { + + // Loop over boundary segments, and project point onto it, take the closest + K::FT closest_distance = std::numeric_limits::infinity(); + boost::optional> closest_point; + for (auto& poly : outer_perimiter) { + for (auto jt = poly.edges_begin(); jt != poly.edges_end(); ++jt) { + auto seg = *jt; + auto Pp = seg.supporting_line().projection(M); + if (seg.has_on(Pp)) { + auto d = CGAL::squared_distance(Pp, M); + if (d < closest_distance) { + closest_distance = d; + closest_point = Pp; + } + } + } + } + + if (closest_point) { + constructed_segments.push_front({M, *closest_point}); + processed_vertices.insert(M); + } + } } } } - } #if 0 if (!handled_as_graph_path) { @@ -1267,6 +1389,11 @@ std::list> extend_end_vertices_based_on_input( constructed_segments.push_front({avg, R}); } #endif + } + } + + if (!broke_out) { + break; } } @@ -1355,8 +1482,6 @@ void fuse_corridor_halves_with_input(Arrangement_2& arr, Graph2D& G, SegmentL } } -#include - class Segment_2_less { public: bool operator()(const Segment_2& a, const Segment_2& b) const { @@ -1367,7 +1492,112 @@ class Segment_2_less { } }; -void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { +std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& right) { + + using Walk_pl = CGAL::Arr_walk_along_line_point_location; + Walk_pl walk_pl(right); + + std::set visited_faces_on_right; + + std::vector return_values; + + for (auto it = left.faces_begin(); it != left.faces_end(); ++it) { + if (!it->is_unbounded()) { + // convert arr facet to polygon with holes + auto polygon_exterior = circ_to_poly(it->outer_ccb()); + Polygon_with_holes_2 pwh(polygon_exterior); + for (auto hit = it->inner_ccbs_begin(); hit != it->inner_ccbs_end(); ++hit) { + pwh.add_hole(circ_to_poly(*hit)); + } + + CGAL::Polygon_triangulation_decomposition_2 decompositor; + std::vector temp; + decompositor(pwh, std::back_inserter(temp)); + + std::set visited_points; + + while (true) { + // select triangle edge that has largest squared edge length times distance from polygon exterior + K::FT max_score = -std::numeric_limits::infinity(); + Point_2 best_point; + for (auto& tri : temp) { + for (size_t i = 0; i < 3; ++i) { + size_t j = (i + 1) % 3; + auto& pi = tri.vertex(i); + auto& pj = tri.vertex(j); + + auto center_point = CGAL::ORIGIN + (((pi - CGAL::ORIGIN) + (pj - CGAL::ORIGIN)) / 2); + + K::FT min_dist = std::numeric_limits::infinity(); + for (auto eit = polygon_exterior.edges_begin(); eit != polygon_exterior.edges_end(); ++eit) { + auto ep = eit->source(); + auto eq = eit->target(); + Segment_2 seg(ep, eq); + auto dist = CGAL::squared_distance(center_point, seg); + if (dist < min_dist) { + min_dist = dist; + } + } + + auto sq_length = CGAL::squared_distance(pi, pj); + + auto score = sq_length * min_dist; + if (score > max_score && visited_points.count(center_point) == 0) { + max_score = score; + best_point = center_point; + } + } + } + + auto res = walk_pl.locate(best_point); + if (auto* v = boost::get(&res)) { + if (visited_faces_on_right.count(*v) > 0) { + return_values.push_back(0); + } else { + // convert arr facet to polygon with holes + auto polygon_exterior = circ_to_poly((*v)->outer_ccb()); + Polygon_with_holes_2 pwh_right(polygon_exterior); + for (auto hit = (*v)->inner_ccbs_begin(); hit != (*v)->inner_ccbs_end(); ++hit) { + pwh_right.add_hole(circ_to_poly(*hit)); + } + + // compute intersection over union of pwh and the original polygon + if (CGAL::do_intersect(pwh, pwh_right)) { + std::vector result; + CGAL::intersection(pwh, pwh_right, std::back_inserter(result)); + typename K::FT intersection_area = 0; + for (auto& r : result) { + auto poly_area = r.outer_boundary().area(); + for (auto& h : r.holes()) { + poly_area -= h.area(); + } + intersection_area += poly_area; + } + CGAL::Polygon_with_holes_2 poly12; + CGAL::join(pwh, pwh_right, poly12); + typename K::FT union_area = poly12.outer_boundary().area(); + for (auto& h : poly12.holes()) { + union_area -= h.area(); + } + return_values.push_back(intersection_area / union_area); + } else { + return_values.push_back(0); + } + } + visited_faces_on_right.insert(*v); + break; + } else { + // Not in facet on right, retry another point + continue; + } + } + } + } + + return return_values; +} + +void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) { using SK = CGAL::Simple_cartesian; CGAL::Cartesian_converter C{}; @@ -1418,15 +1648,17 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { auto [dv, dl] = get_dir(s); best = std::min(best, angle(dv)); } - return (best + 0.1) / own_length; + return (best + 0.01) / own_length; }; + std::cerr << "badnesses:"; std::map badnesses; for (auto& e : edges) { badnesses[e] = edge_badness(e); + std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses[e] << ";"; } + std::cerr << std::endl; - double thr; { std::vector tmp; tmp.reserve(badnesses.size()); @@ -1435,12 +1667,14 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { } std::nth_element(tmp.begin(), tmp.begin() + tmp.size() / 2, tmp.end()); double med = tmp[tmp.size() / 2]; - thr = 10.0 * med; + threshold = 4.0 * med; } + std::cerr << "badness threshold: " << threshold << std::endl; + std::set bad_edges; for (auto& p : badnesses) { - if (p.second > thr) { + if (p.second > threshold) { bad_edges.insert(p.first); } } @@ -1568,10 +1802,57 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { return best_x; }; + auto process_modifications = [&]( + Arrangement_2& arr_, + const std::set>& to_remove_, + const std::vector>& to_insert_) { + for (auto& e : to_remove_) { + bool removed = false; + for (auto he = arr_.edges_begin(); he != arr_.edges_end(); ++he) { + auto a = he->source()->point(); + auto b = he->target()->point(); + if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { + CGAL::remove_edge(arr_, he); + removed = true; + break; + } + } + if (!removed) { + std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; + } + } + + for (auto& pq : to_insert_) { + if (pq.first == pq.second) { + continue; + } + CGAL::insert(arr_, Segment_2(pq.first, pq.second)); + } + }; + + size_t path_index = 0; for (auto& path : bad_paths) { + decltype(to_remove) to_remove_this_path; + decltype(to_insert) to_insert_this_path; + + std::cerr << "Processing bad path:"; + for (size_t i = 0; i < path.size() - 1; ++i) { + auto& a = path[i]; + auto& b = path[i + 1]; + std::cerr << " (" << a.x() << "," << a.y() << ") - (" << b.x() << "," << b.y() << ");"; + } + std::cerr << std::endl; + + for (size_t i = 0; i < path.size() - 1; ++i) { + auto& a = path[i]; + auto& b = path[i + 1]; + + debug_output.write_segment(a, b, "arr_bad_path path_nr_" + std::to_string(path_index)); + } + auto x = collapse_path(path); if (!x) { - // std::cerr << "Unable to collapse path, skipping" << std::endl; + std::cerr << "Unable to collapse path, skipping" << std::endl; continue; } @@ -1585,7 +1866,7 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { double new_length = std::sqrt(CGAL::to_double((path.front() - *x).squared_length())) + std::sqrt(CGAL::to_double((path.back() - *x).squared_length())); if (new_length > orig_length * 2 || orig_length > new_length * 2) { - // std::cerr << "Collapsing path would increase length too much, skipping" << std::endl; + std::cerr << "Collapsing path would increase length too much, skipping" << std::endl; continue; } @@ -1604,75 +1885,326 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { auto& b = path[i + 1]; if (a < b) { to_remove.insert({a, b}); + to_remove_this_path.insert({a, b}); } else { to_remove.insert({b, a}); + to_remove_this_path.insert({b, a}); } } auto s = path.front(); auto t = path.back(); if (s != *x) { to_insert.push_back({s, *x}); + to_insert_this_path.push_back({s, *x}); + + debug_output.write_segment(s, *x, "corrected_path path_nr_" + std::to_string(path_index)); } if (t != *x) { to_insert.push_back({t, *x}); + to_insert_this_path.push_back({t, *x}); + + debug_output.write_segment(t, *x, "corrected_path path_nr_" + std::to_string(path_index)); } + + path_index += 1; + +#if 1 + process_modifications(arr, to_remove_this_path, to_insert_this_path); +#else + auto arr_copy = arr; + process_modifications(arr_copy, to_remove_this_path, to_insert_this_path); + auto ious = arrangement_cell_iou(arr, arr_copy); + for (auto& iou : ious) { + std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl; + } + std::swap(arr_copy, arr); +#endif } - /* - using Walk_pl = CGAL::Arr_walk_along_line_point_location; - Walk_pl walk_pl(arr); + process_modifications(arr, to_remove, to_insert); +} - for (auto& e : to_remove) { - // debug_output.write_segment(e->source()->point(), e->target()->point(), "arr_bad_remove"); - auto res = walk_pl.locate(e.first); - if (auto* v = boost::get(&res)) { - Arrangement_2::Halfedge_around_vertex_circulator first, curr; - first = curr = (*v)->incident_halfedges(); - size_t i = 0; - std::array pts; - std::array hes; - do { - Arrangement_2::Vertex_const_handle u = curr->source(); - hes[i] = curr; - pts[i++] = u->point(); - } while (++curr != first); +template +void next_circular(typename Vec::const_iterator& it, const Vec& vec) { + std::advance(it, 1); + if (it == vec.end()) { + it = vec.begin(); + } +} +template +void previous_circular(typename Vec::const_iterator& it, const Vec& vec) { + if (it == vec.begin()) { + it = vec.end(); + } + std::advance(it, -1); +} - if ((*v)->point() != e.first) { - std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; - continue; +template +std::size_t circular_distance(typename Vec::const_iterator first, + typename Vec::const_iterator last, + const Vec& vec) { + if (first <= last) { + return static_cast(last - first); + } + return static_cast(vec.end() - first) + static_cast(last - vec.begin()); +} + +template +std::pair +longest_wrapping_true_run(const Vec& v, Pred pred) { + using It = typename Vec::const_iterator; + + const auto n = v.size(); + if (n == 0) { + return {v.end(), v.end()}; + } + + // Find best non-wrapping run + std::size_t best_len = 0; + std::size_t best_start = 0; + + std::size_t curr_len = 0; + std::size_t curr_start = 0; + + for (std::size_t i = 0; i < n; ++i) { + if (pred(v[i])) { + if (curr_len == 0) { + curr_start = i; + } + ++curr_len; + if (curr_len > best_len) { + best_len = curr_len; + best_start = curr_start; } } else { - std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; - continue; + curr_len = 0; } } - */ - for (auto& e : to_remove) { - bool removed = false; - for (auto he = arr.edges_begin(); he != arr.edges_end(); ++he) { - auto a = he->source()->point(); - auto b = he->target()->point(); - if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { - CGAL::remove_edge(arr, he); - removed = true; - break; + // Count leading true + std::size_t leading = 0; + while (leading < n && pred(v[leading])) { + ++leading; + } + + // All true + if (leading == n) { + return {v.begin(), v.end()}; + } + + // Count trailing true + std::size_t trailing = 0; + while (trailing < n && pred(v[n - 1 - trailing])) { + ++trailing; + } + + // Wrapped run = [n - trailing, n) + [0, leading) + const std::size_t wrapped_len = leading + trailing; + + if (wrapped_len > best_len) { + It first = v.begin() + static_cast(n - trailing); + It last = v.begin() + static_cast(leading); + return {first, last}; + } + + It first = v.begin() + static_cast(best_start); + It last = first + static_cast(best_len); + return {first, last}; +} + +void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double threshold) { + using SK = CGAL::Simple_cartesian; + CGAL::Cartesian_converter C{}; + + auto other = [](const Segment_2& e, const Point_2& v) { + return (e.source() == v) ? e.target() : e.source(); + }; + + auto edge_badness = [&](const Segment_2& e) -> double { + auto closest = segment_lookup.n_closest_input_segments(e, 2); + if (closest.size() != 2) { + throw std::runtime_error("Unable to locate two nearby edges"); + } + + auto get_dir = [&](const Segment_2& s) { + auto a = C(s.source()); + auto b = C(s.target()); + SK::Vector_2 v = b - a; + double l = std::sqrt(v.squared_length()); + if (l <= 1e-12) { + return std::make_pair(SK::Vector_2(0, 0), 0.); + } + return std::make_pair(v / l, l); + }; + + auto [own_dir, own_length] = get_dir(e); + + auto angle = [&](const SK::Vector_2& ov) { + double d = std::abs(own_dir * ov); + if (d > 1.0) { + d = 1.0; + } + return std::acos(d); + }; + + double best = std::numeric_limits::infinity(); + for (auto& s : closest) { + auto [dv, dl] = get_dir(s); + best = std::min(best, angle(dv)); + } + return (best + 0.01) / own_length; + }; + + size_t facet_index = 0; + for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it, ++facet_index) { + std::cout << "facet_index " << facet_index << std::endl; + if (!it->is_unbounded()) { + std::set> to_remove; + std::vector> to_insert; + + std::vector segs; + std::vector vertices; + std::vector halfedges; + + auto circ = it->outer_ccb(); + do { + auto a = circ->source()->point(); + auto b = circ->target()->point(); + segs.emplace_back(a, b); + vertices.push_back(circ->source()); + halfedges.push_back(circ); + ++circ; + } while (circ != it->outer_ccb()); + + std::cerr << "badnesses:"; + std::vector badnesses; + for (auto& e : segs) { + badnesses.push_back(edge_badness(e)); + std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses.back() << ";"; + } + std::cerr << std::endl; + + auto bit = std::min_element(badnesses.begin(), badnesses.end()); + if (*bit > threshold) { + std::cerr << "All edges are good, skipping" << std::endl; + continue; + } + + auto it_pair = longest_wrapping_true_run(badnesses, [&](double d) { return d > threshold; }); + auto N = circular_distance(it_pair.first, it_pair.second, badnesses); + + if (N == 0) { + std::cerr << "Unable to find run of bad edges, skipping" << std::endl; + continue; + } + + std::vector> incoming_paths; + + std::cout << "range " << std::distance(badnesses.cbegin(), it_pair.first) << " to " << std::distance(badnesses.cbegin(), it_pair.second) << " length " << N << std::endl; + + auto jt = it_pair.first; + for (std::size_t k = 0; k < N; ++k, next_circular(jt, badnesses)) { + + std::cout << " at " << std::distance(badnesses.cbegin(), jt) << " badness: " << *jt << std::endl; + + auto he = halfedges[std::distance(badnesses.cbegin(), jt)]; + to_remove.insert({he->source()->point(), he->target()->point()}); + debug_output.write_segment(he->source()->point(), he->target()->point(), "arr_bad_bound facet_" + std::to_string(facet_index)); + + Arrangement_2::Vertex_handle v = he->source(); + + // circle around other edges onto v + Arrangement_2::Halfedge_around_vertex_circulator first, curr; + first = curr = v->incident_halfedges(); + do { + Arrangement_2::Vertex_handle u = curr->source(); + if (curr->face() != it && curr->twin()->face() != it) { + + // loop until we find a 3-degree vertex, or we come back to the start + std::vector path{v->point(), u->point()}; + auto he = curr; + + while (u->degree() == 2 && u != v && path.size() < 10) { + std::vector hes; + + { + Arrangement_2::Halfedge_around_vertex_circulator first, curr; + first = curr = u->incident_halfedges(); + do { + hes.push_back(curr); + curr++; + } while (curr != first); + } + + auto next_he = hes.front() != he && hes.front() != he->twin() ? hes.front() : hes.back(); + auto next_v = next_he->target() != u ? next_he->target() : next_he->source(); + + path.push_back(next_v->point()); + u = next_v; + } + incoming_paths.push_back(std::move(path)); + } + } while (++curr != first); + } + + const std::size_t start = + static_cast(std::distance(badnesses.cbegin(), it_pair.first)); + + auto n = badnesses.size(); + + auto wrap = [n](std::ptrdiff_t i) -> std::size_t { + i %= static_cast(n); + if (i < 0) { + i += static_cast(n); + } + return static_cast(i); + }; + + const std::size_t ib = start; + const std::size_t ia = wrap(static_cast(start) - 1); + const std::size_t ic = wrap(static_cast(start + N)); + const std::size_t id = wrap(static_cast(start + N + 1)); + + auto a = vertices.begin() + static_cast(ia); + auto b = vertices.begin() + static_cast(ib); + auto c = vertices.begin() + static_cast(ic); + auto d = vertices.begin() + static_cast(id); + + CGAL::Ray_2 r1((*a)->point(), (*b)->point()); + CGAL::Ray_2 r2((*d)->point(), (*c)->point()); + + std::cout << "a: (" << (*a)->point().x() << "," << (*a)->point().y() << ") b: (" << (*b)->point().x() << "," << (*b)->point().y() << ") c: (" << (*c)->point().x() << "," << (*c)->point().y() << ") d: (" << (*d)->point().x() << "," << (*d)->point().y() << ")" << std::endl; + + auto x = CGAL::intersection(r1, r2); + if (x) { + if (auto* xp = variant_get>(&*x)) { + std::cout << "ray xp: (" << xp->x() << "," << xp->y() << ")" << std::endl; + to_insert.emplace_back((*b)->point(), *xp); + to_insert.emplace_back((*c)->point(), *xp); + + debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + } + } else { + CGAL::Line_2 r1((*a)->point(), (*b)->point()); + CGAL::Line_2 r2((*d)->point(), (*c)->point()); + + auto x = CGAL::intersection(r1, r2); + if (x) { + if (auto* xp = variant_get>(&*x)) { + std::cout << "line xp: (" << xp->x() << "," << xp->y() << ")" << std::endl; + to_insert.emplace_back((*b)->point(), *xp); + to_insert.emplace_back((*c)->point(), *xp); + + debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + } + } } } - if (!removed) { - std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; - } } - - for (auto& pq : to_insert) { - if (pq.first == pq.second) { - continue; - } - CGAL::insert(arr, Segment_2(pq.first, pq.second)); - // debug_output.write_segment(pq.first, pq.second, "arr_bad_insert"); - } - } void remove_colinear_vertices(Arrangement_2& arr) { @@ -1725,20 +2257,31 @@ class timer { public: class entry { public: + entry() {} + entry(std::map::const_iterator start_it) : start_it(start_it) {} + void stop() { - auto end = std::chrono::high_resolution_clock::now(); - auto duration = std::chrono::duration(end - start_it->second).count(); - std::cerr << "Timing for " << start_it->first << ": " << duration << " ms" << std::endl; + if (start_it) { + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration(end - start_it.value()->second).count(); + std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl; + } } private: - std::map::const_iterator start_it; + std::optional::const_iterator> start_it; }; + timer(bool enabled = true) : enabled_(enabled) {} + entry start(const std::string& name) { - return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + if (enabled_) { + return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + } else { + return entry(); + } } private: @@ -1746,27 +2289,30 @@ class timer { std::string, std::chrono::high_resolution_clock::time_point> timings_; + + bool enabled_; }; -void arrange_cgal_polygons(const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { +void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1; // even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied // no, `1.e-2 + 1.e-5` creates issues with the outer perimeter, are there other tolerances in play? static const double OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT = 1.e-5; -#ifdef SVGFILL_DEBUG - auto t = std::time(nullptr); - auto tm = *std::localtime(&t); + DebugWriter debug_output; + if (settings.debug_output) { + auto t = std::time(nullptr); + auto tm = *std::localtime(&t); - std::ostringstream oss; - oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); - auto now = oss.str(); - DebugWriter debug_output(true, now); -#else - DebugWriter debug_output(false, ""); -#endif + std::ostringstream oss; + oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); + auto now = oss.str(); + debug_output = DebugWriter(true, now); + } else { + debug_output = DebugWriter(false, ""); + } - timer timer; + timer timer(settings.debug_output); auto t0 = timer.start("input"); @@ -1794,7 +2340,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v t0.stop(); t0 = timer.start("overlap elimination"); - eliminate_overlaps(OVERLAP_RESOLUTION_DISTANCE, input_polygons); + eliminate_overlaps(debug_output, OVERLAP_RESOLUTION_DISTANCE, input_polygons); t0.stop(); @@ -1813,79 +2359,79 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v debug_output.write_polygons(input_polygons, "processed_input"); -#if 1 - t0 = timer.start("outer perimeter"); - - // Find the outer perimeter using offset - union - negative offset - std::vector offset_polygons; - for (auto& r : input_polygons) { - auto R = r; - if (!R.is_counterclockwise_oriented()) { - R.reverse_orientation(); - } - - // Overlap removal can also result in close points causing problems when converted into non-exact nt - remove_close_points(R); - - auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); - for (auto& p : ps) { - if (!p.is_simple()) { - throw std::runtime_error("Complex polygon originated from offset"); - } - } - offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); - } - - debug_output.write_polygons(offset_polygons, "offset_input"); - - // Perform Boolean union on the offset polygons - std::vector unioned_polygons; - CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); - - if (unioned_polygons.size() > 1) { - // @todo this is currently one of the major limitations in the code that still can be eliminated - // by grouping the input polygons by their perimiter polygon in unioned_polygons - std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); - } - - debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); - - Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); - remove_close_points(fused_removed_close_points, 1.e-4); - - // Apply negative offset to get the outer perimeter polygon - auto outer_perimiter = create_and_convert_offset_polygon( - // Because polygon_offset is inexact, make sure our inset distance is slightly larger - // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), - - // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter - -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, - fused_removed_close_points); - - debug_output.write_polygons(outer_perimiter, "outer_perimiter"); -#else - std::map> neighbour_map; - build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); - - auto start_vertex = neighbour_map.rbegin()->first; - auto next_vertex = neighbour_map.rbegin()->second.front(); - - std::vector cycle = {start_vertex, next_vertex}; - while (cycle.back() != cycle.front()) { - const auto& incoming_from = *(cycle.rbegin() + 1); - const auto& nb = neighbour_map[cycle.back()]; - auto it = std::find(nb.begin(), nb.end(), incoming_from); - // cycle it -1 around nb - if (it == nb.begin()) { - it == nb.end() - 1; - } else { - --it; - } - cycle.push_back(*it); - } std::vector outer_perimiter; - outer_perimiter.emplace_back(cycle.begin(), cycle.end()); -#endif + if (settings.outer_perimiter_algo == 0) { + t0 = timer.start("outer perimeter"); + + // Find the outer perimeter using offset - union - negative offset + std::vector offset_polygons; + for (auto& r : input_polygons) { + auto R = r; + if (!R.is_counterclockwise_oriented()) { + R.reverse_orientation(); + } + + // Overlap removal can also result in close points causing problems when converted into non-exact nt + remove_close_points(R); + + auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); + for (auto& p : ps) { + if (!p.is_simple()) { + throw std::runtime_error("Complex polygon originated from offset"); + } + } + offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); + } + + debug_output.write_polygons(offset_polygons, "offset_input"); + + // Perform Boolean union on the offset polygons + std::vector unioned_polygons; + CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); + + if (unioned_polygons.size() > 1) { + // @todo this is currently one of the major limitations in the code that still can be eliminated + // by grouping the input polygons by their perimiter polygon in unioned_polygons + std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); + } + + debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); + + Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); + remove_close_points(fused_removed_close_points, 1.e-4); + + // Apply negative offset to get the outer perimeter polygon + outer_perimiter = create_and_convert_offset_polygon( + // Because polygon_offset is inexact, make sure our inset distance is slightly larger + // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), + + // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter + -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, + fused_removed_close_points); + + debug_output.write_polygons(outer_perimiter, "outer_perimiter"); + } else { + std::map> neighbour_map; + build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); + + auto start_vertex = neighbour_map.rbegin()->first; + auto next_vertex = neighbour_map.rbegin()->second.front(); + + std::vector cycle = {start_vertex, next_vertex}; + while (cycle.back() != cycle.front()) { + const auto& incoming_from = *(cycle.rbegin() + 1); + const auto& nb = neighbour_map[cycle.back()]; + auto it = std::find(nb.begin(), nb.end(), incoming_from); + // cycle it -1 around nb + if (it == nb.begin()) { + it = nb.end() - 1; + } else { + --it; + } + cycle.push_back(*it); + } + outer_perimiter.emplace_back(cycle.begin(), cycle.end()); + } t0.stop(); t0 = timer.start("corridor creation"); @@ -1911,8 +2457,10 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v // subdivide difference_result to have better more detailed triangulation and therefore less-pronounced artefacts in midpoint network + auto subdivision_length = polygon_offset_distance / settings.subdivision_factor; + for (auto& pwh : difference_result) { - difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 8., pwh)); + difference_result_subdivided.push_back(subdivide_polygon(subdivision_length, pwh)); // difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 64., pwh)); } @@ -1940,13 +2488,43 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v SegmentLookup segment_lookup(input_polygons); - auto [line_graph, midpoint_to_segment, segment_to_input_facet] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); + auto [line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); for (auto& p : line_graph) { for (auto& q : p.second) { debug_output.write_segment(p.first, q, "network_1"); } } + // Write a JSON structure with center line topology with the midpoint_to_edge_length as per-point data + { + std::ofstream ofs("center_line_topology.json"); + ofs << "{\n"; + ofs << " \"vertices\": [\n"; + bool first_vertex = true; + for (auto& p : line_graph) { + if (!first_vertex) { + ofs << ",\n"; + } + first_vertex = false; + ofs << " {\n"; + ofs << " \"point\": [" << p.first.x() << ", " << p.first.y() << "],\n"; + ofs << " \"width\":" << midpoint_to_edge_length.find(p.first)->second << ",\n"; + ofs << " \"connected_to\": [\n"; + bool first_connected = true; + for (auto& q : p.second) { + if (!first_connected) { + ofs << ",\n"; + } + first_connected = false; + ofs << " [" << q.x() << ", " << q.y() << "]"; + } + ofs << "\n ]\n"; + ofs << " }"; + } + ofs << "\n ]\n"; + ofs << "}\n"; + } + t0.stop(); t0 = timer.start("center line cleaning"); @@ -1981,7 +2559,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v t0 = timer.start("topology"); - auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup); + auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4); // Now plot the edges on an arrangement in order to find planar cycles // and merge the corridor-halves with their neighbouring input polygon @@ -1997,31 +2575,31 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v debug_output.write_segment(pq.first, pq.second, "extended_segments"); } -#if 0 - // Write input polygons to arrangement_2 - // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter - for (auto& poly : input_polygons) { - for (size_t i = 0; i != poly.size(); ++i) { - auto j = (i + 1) % poly.size(); - if (poly.vertex(i) == poly.vertex(j)) { - continue; + if (settings.topology_reconstruction_algo != 0) { + // Write input polygons to arrangement_2 + // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter + for (auto& poly : input_polygons) { + for (size_t i = 0; i != poly.size(); ++i) { + auto j = (i + 1) % poly.size(); + if (poly.vertex(i) == poly.vertex(j)) { + continue; + } + CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); + } + } + } else { + // Write outer perimeter to arrangement_2 + for (auto& p : outer_perimiter) { + for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source = it->source(); + auto target = it->target(); + if (source == target) { + continue; + } + CGAL::insert(arr, Segment_2(source, target)); } - CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); } } -#else - // Write outer perimeter to arrangement_2 - for (auto& p : outer_perimiter) { - for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { - auto source = it->source(); - auto target = it->target(); - if (source == target) { - continue; - } - CGAL::insert(arr, Segment_2(source, target)); - } - } -#endif // Just for the automatic numbering, create a full vector std::vector temp; @@ -2047,12 +2625,17 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v // corridor network we know it needs to be joined with an input polygon. In that // case the edges need to be eliminated that correspond to original geometry. -#if 0 - fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); -#else - remove_colinear_vertices(arr); - clean_noisy_paths(arr, segment_lookup); -#endif + if (settings.topology_reconstruction_algo != 0) { + fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); + } + + if (settings.perform_cleanup) { + remove_colinear_vertices(arr); + double threshold; + clean_noisy_paths(debug_output, arr, segment_lookup, threshold); + remove_colinear_vertices(arr); + clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); + } t0.stop(); @@ -2068,8 +2651,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v #ifndef SVGFILL_MAIN -bool svgfill::arrange_polygons(const std::vector& polygons, std::vector& arranged) -{ +bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged) { std::vector cgal_polygons, cgal_polygons_out; std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) { Polygon_2 result; @@ -2078,7 +2660,7 @@ bool svgfill::arrange_polygons(const std::vector& polygons, }); return result; }); - arrange_cgal_polygons(cgal_polygons, cgal_polygons_out); + arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out); std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) { svgfill::polygon_2 result; std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) { @@ -2128,7 +2710,7 @@ int main(int argc, char** argv) { input_polygons.back().push_back(CGAL::Point_2(x, y)); } } - arrange_cgal_polygons(input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); break; } return 0; @@ -2141,7 +2723,7 @@ int main(int argc, char** argv) { input_polygons = { rect1, rect2, rect3, rect4, rect5 }; } - arrange_cgal_polygons(input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); return 0; } diff --git a/src/svgfill/src/svgfill.cpp b/src/svgfill/src/svgfill.cpp index 8a2a1bb008..cf45881c93 100644 --- a/src/svgfill/src/svgfill.cpp +++ b/src/svgfill/src/svgfill.cpp @@ -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& edge_indices) { if (edge_indices.empty()) { return; diff --git a/src/svgfill/src/svgfill.h b/src/svgfill/src/svgfill.h index 396fc9c924..fab4dd0142 100644 --- a/src/svgfill/src/svgfill.h +++ b/src/svgfill/src/svgfill.h @@ -67,6 +67,7 @@ namespace svgfill { virtual std::vector 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>&); 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>& polygons, bool random_color=false); SVGFILL_API std::string polygons_to_svg(const std::vector& polygons, bool random_color = false); SVGFILL_API bool svg_to_polygons(const std::string& data, const boost::optional& class_name, std::vector& polygons); - SVGFILL_API bool arrange_polygons(const std::vector& polygons, std::vector& 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& polygons, std::vector& arranged); + } #endif From c1f146966cc281986b36d5db6749d233dc6c69c5 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 11:03:44 +0200 Subject: [PATCH 24/76] The end of open source? Just regurgitate some markdown parsing code. --- src/ifcchat/app.js | 175 +++++++++++++++++++++++++++++++++++++++++- src/ifcchat/style.css | 67 ++++++++++++++++ 2 files changed, 241 insertions(+), 1 deletion(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index ae1d7b0ae9..c0046491b5 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -162,17 +162,190 @@ function setBusy(isBusy, reason = "") { setStatus(isBusy ? (reason || "Working…") : "Ready"); } +function escapeHtml(text) { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function sanitizeUrl(url) { + try { + const parsed = new URL(url, window.location.href); + if (["http:", "https:", "mailto:"].includes(parsed.protocol)) { + return parsed.href; + } + } catch { + } + return null; +} + +function renderInlineMarkdown(text) { + const placeholders = []; + const addPlaceholder = (html) => { + const token = `@@MD${placeholders.length}@@`; + placeholders.push({ token, html }); + return token; + }; + + let rendered = text; + + rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`${escapeHtml(code)}`)); + rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => { + const href = sanitizeUrl(url); + if (!href) { + return `${label} (${url})`; + } + return addPlaceholder( + `${escapeHtml(label)}` + ); + }); + + rendered = escapeHtml(rendered); + rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1"); + rendered = rendered.replace(/\*([^*]+)\*/g, "$1"); + rendered = rendered.replace(/_([^_]+)_/g, "$1"); + + for (const placeholder of placeholders) { + rendered = rendered.replaceAll(placeholder.token, placeholder.html); + } + + return rendered; +} + +function renderMarkdown(text) { + const lines = String(text).replace(/\r\n?/g, "\n").split("\n"); + const html = []; + let paragraphLines = []; + let quoteLines = []; + let listType = null; + let listItems = []; + + const flushParagraph = () => { + if (!paragraphLines.length) return; + html.push(`

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

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

${quoteBody}

`); + quoteLines = []; + }; + + const flushList = () => { + if (!listItems.length || !listType) return; + const items = listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join(""); + html.push(`<${listType}>${items}`); + listType = null; + listItems = []; + }; + + const flushAll = () => { + flushParagraph(); + flushQuote(); + flushList(); + }; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmed = line.trim(); + + if (trimmed.startsWith("```")) { + flushAll(); + const language = trimmed.slice(3).trim(); + const codeLines = []; + index += 1; + while (index < lines.length && !lines[index].trim().startsWith("```")) { + codeLines.push(lines[index]); + index += 1; + } + const languageClass = language ? ` class="language-${escapeHtml(language)}"` : ""; + html.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + continue; + } + + if (!trimmed) { + flushAll(); + continue; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushAll(); + const level = headingMatch[1].length; + html.push(`${renderInlineMarkdown(headingMatch[2])}`); + continue; + } + + const quoteMatch = trimmed.match(/^>\s?(.*)$/); + if (quoteMatch) { + flushParagraph(); + flushList(); + quoteLines.push(quoteMatch[1]); + continue; + } + + if (quoteLines.length) { + flushQuote(); + } + + const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/); + if (unorderedListMatch) { + flushParagraph(); + if (listType && listType !== "ul") { + flushList(); + } + listType = "ul"; + listItems.push(unorderedListMatch[1]); + continue; + } + + const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/); + if (orderedListMatch) { + flushParagraph(); + if (listType && listType !== "ol") { + flushList(); + } + listType = "ol"; + listItems.push(orderedListMatch[1]); + continue; + } + + if (listItems.length) { + flushList(); + } + + paragraphLines.push(trimmed); + } + + flushAll(); + + return html.join(""); +} + function addMessage(role, text) { if (text.ok) { text = text.data; } + if (typeof text !== "string") { + text = JSON.stringify(text, null, 2); + } const wrap = document.createElement("div"); wrap.className = `msg ${role}`; wrap.innerHTML = `
    ${role}${role === "tool" ? '' : ''}
    `; const bubble = wrap.querySelector(".bubble"); - bubble.textContent = text; + if (role === "assistant") { + bubble.classList.add("markdown-content"); + bubble.innerHTML = renderMarkdown(text); + } else { + bubble.textContent = text; + } bubble.onclick = function () { if (bubble.scrollHeight > 100 && role === "tool") { const expanded = bubble.style.maxHeight === 'none'; diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css index 9d3ba064c9..1dfd5335b8 100644 --- a/src/ifcchat/style.css +++ b/src/ifcchat/style.css @@ -86,6 +86,73 @@ main { 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; From 7350ccd25e71d73162846ced432abaac75d4e2cd Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 11:14:40 +0200 Subject: [PATCH 25/76] Tweak header padding --- src/ifcchat/index.html | 4 +--- src/ifcchat/style.css | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index 1048831657..e919b1ae8f 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -84,10 +84,8 @@
    +
    - - - IfcOpenShell AI Assistant
    diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css index 1dfd5335b8..b84fb1fb30 100644 --- a/src/ifcchat/style.css +++ b/src/ifcchat/style.css @@ -317,3 +317,7 @@ hr { padding: 2px; display: inline-block; } + +header .row:nth-child(2) { + padding: 15px 0 0 0; +} \ No newline at end of file From 918cc65a0da33ddc20cb97d6c19a42051b959b38 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 11:22:43 +0200 Subject: [PATCH 26/76] Provider selection as tabs --- src/ifcchat/app.js | 14 ++++++++---- src/ifcchat/index.html | 24 +++++++++++++++----- src/ifcchat/style.css | 51 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index c0046491b5..7a7eb1adab 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -112,13 +112,17 @@ const baseUrlLabelEl = $("baseUrlLabel"); const baseUrlEl = $("baseUrl"); const thinkingIndicatorEl = $("thinkingIndicator"); const modelEl = $("model"); -const providerEl = $("provider"); +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[providerEl.value]; + const provider = PROVIDERS[getProviderValue()]; apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}stored in browser memory; only sent to provider servers`; apiKeyEl.placeholder = provider.apiKeyPlaceholder; baseUrlRowEl.hidden = !provider.baseUrlDefault; @@ -133,7 +137,9 @@ function onProviderChange() { modelEl.innerHTML = provider.models.map(m => ``).join(""); } -providerEl.addEventListener("change", onProviderChange); +for (const providerEl of providerEls) { + providerEl.addEventListener("change", onProviderChange); +} onProviderChange(); function setBusy(isBusy, reason = "") { @@ -467,7 +473,7 @@ async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); - const provider = PROVIDERS[providerEl.value]; + const provider = PROVIDERS[getProviderValue()]; const { chat } = provider.api; const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index e919b1ae8f..daf6146126 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -14,12 +14,24 @@
    - +
    + + + + +
    diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css index b84fb1fb30..105e0d574f 100644 --- a/src/ifcchat/style.css +++ b/src/ifcchat/style.css @@ -32,7 +32,7 @@ main { .side { border-right: 1px solid #ddd; - padding: 100px 12px 12px 12px; + padding: 12px; overflow: auto; background: #f9f9f9; } @@ -234,7 +234,54 @@ section > .row > label .small { } .side .row { - margin-bottom: 32px; + 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 { From 9bc0588d211fc70d384a1b33adf354af64f45387 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 11:30:23 +0200 Subject: [PATCH 27/76] Update openai model list --- src/ifcchat/app.js | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 7a7eb1adab..971ff9e4e6 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -12,13 +12,45 @@ const PROVIDERS = { baseUrlPlaceholder: "https://api.openai.com/v1", baseUrlDefault: "https://api.openai.com/v1", models: [ - { - value: "gpt-5", - label: "gpt-5" + { + value: "gpt-5.2", + label: "gpt-5.2" }, - { - value: "gpt-4.1", - label: "gpt-4.1" + { + value: "gpt-5.2-chat-latest", + label: "gpt-5.2-chat-latest" + }, + { + value: "gpt-5.2-pro", + label: "gpt-5.2-pro" + }, + { + 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" }, ], }, From c28251a1b15bee2bdd828b144eeec5e4f5a23e48 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 11:30:33 +0200 Subject: [PATCH 28/76] Add CNAME file --- src/ifcchat/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/ifcchat/CNAME diff --git a/src/ifcchat/CNAME b/src/ifcchat/CNAME new file mode 100644 index 0000000000..6fbcc54661 --- /dev/null +++ b/src/ifcchat/CNAME @@ -0,0 +1 @@ +ai-chat.ifcopenshell.org \ No newline at end of file From c478da5257d4418a5ebc49863e81d3b4cec7b4e0 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 3 Apr 2026 11:36:56 +0200 Subject: [PATCH 29/76] Remove pro --- src/ifcchat/app.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 971ff9e4e6..b268bf8256 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -20,10 +20,6 @@ const PROVIDERS = { value: "gpt-5.2-chat-latest", label: "gpt-5.2-chat-latest" }, - { - value: "gpt-5.2-pro", - label: "gpt-5.2-pro" - }, { value: "gpt-5", label: "gpt-5" From ca6e9504968cd1097df4bccd7de12a49ba43faa7 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 3 Apr 2026 13:27:29 +0100 Subject: [PATCH 30/76] ifcgit: conflict report panel and dry-run merge preview Parse ifcmerge JSON output and display a per-conflict breakdown in the panel when merge fails. Ctrl+click on the Merge button previews conflicts without committing. Add SelectConflictEntity operator to select and frame the conflicting object in the 3D viewport. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/ifcgit/__init__.py | 1 + .../bonsai/bim/module/ifcgit/operator.py | 73 +++++++++- src/bonsai/bonsai/bim/module/ifcgit/prop.py | 6 + src/bonsai/bonsai/bim/module/ifcgit/ui.py | 51 +++++++ src/bonsai/bonsai/core/ifcgit.py | 48 ++++++- src/bonsai/bonsai/core/tool.py | 6 +- src/bonsai/bonsai/tool/ifcgit.py | 73 ++++++++-- src/bonsai/test/core/test_ifcgit.py | 61 ++++++++- src/bonsai/test/tool/test_ifcgit.py | 125 ++++++++++++++++++ 9 files changed, 427 insertions(+), 17 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index 077a08b062..6f4fec0836 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -34,6 +34,7 @@ classes = ( operator.Fetch, operator.Merge, operator.ObjectLog, + operator.SelectConflictEntity, operator.Push, operator.RefreshGit, operator.RenameBranch, diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 8e86a5b18e..cc0f75577a 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -278,7 +278,7 @@ class SwitchRevision(bpy.types.Operator): class Merge(bpy.types.Operator): - """Merges the selected branch into working branch""" + """Merges the selected branch into working branch.\nCtrl+click to preview without merging""" bl_label = "Merge this branch" bl_idname = "ifcgit.merge" @@ -292,8 +292,14 @@ class Merge(bpy.types.Operator): return True return False - def execute(self, context): + def invoke(self, context, event): + if event.ctrl: + core.dry_run_merge(tool.IfcGit, tool.Ifc, self) + refresh() + return {"FINISHED"} + return self.execute(context) + def execute(self, context): if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False: refresh() return {"FINISHED"} @@ -301,6 +307,69 @@ class Merge(bpy.types.Operator): return {"CANCELLED"} +class SelectConflictEntity(bpy.types.Operator): + """Select the conflicting entity in the viewport""" + + bl_label = "Select Conflict Entity" + bl_idname = "ifcgit.select_conflict_entity" + bl_options = {"REGISTER"} + + step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + + if TYPE_CHECKING: + step_id: int + + def execute(self, context): + model = tool.Ifc.get() + if not model: + return {"CANCELLED"} + + try: + entity = model.by_id(self.step_id) + except Exception: + self.report({"WARNING"}, f"Entity #{self.step_id} not found (may have been deleted locally)") + return {"CANCELLED"} + + obj = tool.Ifc.get_object(entity) + if obj is None: + # Walk inverse references up to 5 hops to find nearest entity with a Blender object + visited = {entity.id()} + queue = [entity] + for _ in range(5): + next_queue = [] + for ent in queue: + for inv in model.get_inverse(ent): + if inv.id() in visited: + continue + visited.add(inv.id()) + obj = tool.Ifc.get_object(inv) + if obj is not None: + break + next_queue.append(inv) + if obj is not None: + break + if obj is not None: + break + queue = next_queue + + if obj is None: + self.report({"INFO"}, f"No viewport representation found for #{self.step_id} ({entity.is_a()})") + return {"CANCELLED"} + + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + context.view_layer.objects.active = obj + for area in context.screen.areas: + if area.type == "VIEW_3D": + region = next((r for r in area.regions if r.type == "WINDOW"), None) + if region: + with context.temp_override(area=area, region=region): + bpy.ops.view3d.view_selected() + break + + return {"FINISHED"} + + class Push(bpy.types.Operator): """Pushes the working branch to selected remote""" diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py index 865cb4eacf..494da26f35 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py @@ -139,6 +139,11 @@ class IfcGitProperties(PropertyGroup): ], update=update_revlist, ) + merge_conflicts: StringProperty( + name="Merge Conflicts", + description="JSON report from last failed merge attempt", + default="", + ) if TYPE_CHECKING: ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem] @@ -153,3 +158,4 @@ class IfcGitProperties(PropertyGroup): display_branch: str select_remote: str ifcgit_filter: Literal["all", "tagged", "relevant"] + merge_conflicts: str diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index 3b8bc45000..901dee31ad 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -134,6 +134,57 @@ class IFCGIT_PT_panel(bpy.types.Panel): row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") row.operator("ifcgit.merge", icon="SYSTEM") + conflicts = tool.IfcGit.get_merge_conflicts() + if conflicts is not None: + box = layout.box() + box.alert = True + row = box.row() + row.label( + text=f"Merge failed \u2014 {len(conflicts)} conflict(s)", + icon="ERROR", + ) + for conflict in conflicts: + col = box.column(align=True) + conflict_type = conflict.get("type", "") + entity_id = conflict.get("entity_id", "?") + local_id = conflict.get("original_local_id") + + if conflict_type == "attribute_conflict": + entity_class = conflict.get("entity_class", "Entity") + attr_idx = conflict.get("attribute_index", "?") + desc = f"#{entity_id} {entity_class}: attribute {attr_idx} conflict" + elif conflict_type == "entity_deleted_and_modified": + entity_class = conflict.get("entity_class", "Entity") + desc = f"#{entity_id} {entity_class}: " + conflict.get("message", "deleted/modified conflict") + elif conflict_type == "class_changed": + desc = ( + f"#{entity_id}: class changed " + + conflict.get("base_class", "?") + + " \u2192 " + + conflict.get("modified_class", "?") + ) + elif conflict_type == "required_entity_deleted": + desc = f"#{entity_id}: " + conflict.get("message", "required entity deleted") + else: + desc = f"#{entity_id}: {conflict_type}" + + row = col.row(align=True) + row.label(text=desc) + if local_id: + op = row.operator( + "ifcgit.select_conflict_entity", + text="", + icon="RESTRICT_SELECT_OFF", + ) + op.step_id = local_id + + if conflict_type == "attribute_conflict": + sub = col.column(align=True) + sub.scale_y = 0.75 + sub.label(text=f" Base: {conflict.get('base_value', '')}") + sub.label(text=f" Local: {conflict.get('local_value', '')}") + sub.label(text=f" Remote: {conflict.get('remote_value', '')}") + if not props.ifcgit_commits: return diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 3fb8a79055..289337908a 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -98,6 +98,7 @@ def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: + ifcgit.clear_merge_conflicts() if ifcgit.repo_has_commits(): ifcgit.refresh_revision_list(ifc.get_path()) @@ -147,19 +148,60 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t operator.report({"ERROR"}, "Unknown IFC Merge failure") return False elif merge_result == "conflict": - error = ifcgit.git_mergetool(mergetool) - if error: + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + if conflicts is not None: ifcgit.git_merge_abort() - operator.report({"ERROR"}, "IFC Merge failed:" + error) + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") return False ifcgit.commit_merge(path_ifc) + ifcgit.clear_merge_conflicts() ifcgit.set_display_branch() + ifcgit.git_checkout(path_ifc) ifcgit.load_project(path_ifc) ifcgit.refresh_revision_list(path_ifc) ifcgit.decolourise() +def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: + path_ifc = ifc.get_path() + ifcgit.config_ifcmerge() + + branch_name = ifcgit.get_selected_branch() + if branch_name is None: + return + + mergetool = ifcgit.get_merge_tool(branch_name) + merge_result = ifcgit.git_merge_no_commit(branch_name) + + if merge_result == "error": + try: + ifcgit.git_merge_abort() + except Exception: + pass + operator.report({"ERROR"}, "Unknown IFC Merge failure") + return + + if merge_result == "conflict": + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + ifcgit.git_merge_abort() + if conflicts is not None: + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge preview: conflicts found — see the panel below") + else: + ifcgit.clear_merge_conflicts() + operator.report({"INFO"}, "Merge preview: no conflicts") + else: + # Clean merge or already up to date — abort the pending merge state if any + try: + ifcgit.git_merge_abort() + except Exception: + pass + ifcgit.clear_merge_conflicts() + operator.report({"INFO"}, "Merge preview: no conflicts") + + def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None: path_ifc = ifc.get_path() log_text = ifcgit.entity_log(path_ifc, step_id) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 910ed79627..e6a60c9deb 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -559,7 +559,11 @@ class IfcGit: 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 git_merge_no_commit(cls, branch_name): pass + def git_mergetool(cls, mergetool, path_ifc): pass + def store_merge_conflicts(cls, conflicts): pass + def clear_merge_conflicts(cls): pass + def get_merge_conflicts(cls): pass def set_display_branch(cls): pass def get_active_branch_name(cls): pass def get_ifcgit_props(cls): pass diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 74cef0f522..ae7176eb6f 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json import logging import os import re @@ -282,8 +283,11 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] from bonsai.bim.module.root.data import IfcClassData + from bonsai.bim.module.model.data import AuthoringData import bonsai.bim.handler + AuthoringData.type_thumbnails = {} + IfcClassData.is_loaded = False settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC")) @@ -514,20 +518,25 @@ class IfcGit: def config_ifcmerge(cls) -> None: config_reader = IfcGitRepo.repo.config_reader() section = 'mergetool "ifcmerge"' + new_cmd = "ifcmerge $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") != new_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) section = 'mergetool "ifcmerge-forward"' - new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED" - old_cmd = "ifcmerge $BASE $REMOTE $LOCAL $MERGED" + new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) - elif config_reader.get_value(section, "cmd") == old_cmd: + elif config_reader.get_value(section, "cmd") != new_cmd: with IfcGitRepo.repo.config_writer() as config_writer: config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) @classmethod def config_push(cls, repo: git.Repo) -> None: @@ -590,14 +599,62 @@ class IfcGit: return "error" @classmethod - def git_mergetool(cls, mergetool: str) -> Union[str, None]: - """Run ifcmerge tool. Returns None on success, error message string on failure.""" + def git_merge_no_commit(cls, branch_name: str) -> Union[str, None]: + """Attempt a git merge without committing (always leaves a merge state to abort). + Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure.""" repo = IfcGitRepo.repo + branch = repo.branches[branch_name] + try: + repo.git.merge(branch, no_commit=True, no_ff=True) + return None + except git.exc.GitCommandError: + return "conflict" + except git.exc.GitError: + return "error" + + @classmethod + def git_mergetool(cls, mergetool: str, path_ifc: str) -> Union[list, None]: + """Run ifcmerge tool. Returns None on success, list of conflict dicts on failure.""" + repo = IfcGitRepo.repo + report_path = path_ifc + ".ifcmerge" try: repo.git.mergetool(tool=mergetool) + except git.exc.GitCommandError: + pass + + conflicts = None + if os.path.exists(report_path): + try: + with open(report_path) as f: + content = f.read().strip() + if content: + data = json.loads(content) + conflicts = data.get("conflicts", []) + except (json.JSONDecodeError, OSError): + pass + try: + os.remove(report_path) + except OSError: + pass + return conflicts + + @classmethod + def store_merge_conflicts(cls, conflicts: list) -> None: + cls.get_ifcgit_props().merge_conflicts = json.dumps(conflicts) + + @classmethod + def clear_merge_conflicts(cls) -> None: + cls.get_ifcgit_props().merge_conflicts = "" + + @classmethod + def get_merge_conflicts(cls) -> Union[list, None]: + raw = cls.get_ifcgit_props().merge_conflicts + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: return None - except git.exc.GitCommandError as exc: - return re.sub("( stdout: '|')", "", exc.stdout) @classmethod def git_merge_abort(cls) -> None: diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py index b4d80985b2..539e4a082c 100644 --- a/src/bonsai/test/core/test_ifcgit.py +++ b/src/bonsai/test/core/test_ifcgit.py @@ -132,12 +132,14 @@ class TestPush: class TestRefreshRevisionList: def test_refreshes_when_repo_has_heads(self, ifcgit, ifc): + ifcgit.clear_merge_conflicts().should_be_called() ifcgit.repo_has_commits().should_be_called().will_return(True) 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.clear_merge_conflicts().should_be_called() ifcgit.repo_has_commits().should_be_called().will_return(False) subject.refresh_revision_list(ifcgit, ifc) # nothing else should be called — Prophecy will verify @@ -194,7 +196,9 @@ class TestMergeBranch: ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return(None) + ifcgit.clear_merge_conflicts().should_be_called() ifcgit.set_display_branch().should_be_called() + ifcgit.git_checkout("path/to/model.ifc").should_be_called() ifcgit.load_project("path/to/model.ifc").should_be_called() ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() ifcgit.decolourise().should_be_called() @@ -206,25 +210,29 @@ class TestMergeBranch: 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.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) ifcgit.commit_merge("path/to/model.ifc").should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() ifcgit.set_display_branch().should_be_called() + ifcgit.git_checkout("path/to/model.ifc").should_be_called() ifcgit.load_project("path/to/model.ifc").should_be_called() ifcgit.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): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] ifc.get_path().should_be_called().will_return("path/to/model.ifc") ifcgit.config_ifcmerge().should_be_called() ifcgit.get_selected_branch().should_be_called().will_return("feature") ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") ifcgit.git_merge("feature").should_be_called().will_return("conflict") - ifcgit.git_mergetool("ifcmerge-forward").should_be_called().will_return("merge error") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_conflicts(conflicts).should_be_called() op = MockOperator() subject.merge_branch(ifcgit, ifc, op) - assert op.reports == [({"ERROR"}, "IFC Merge failed:merge error")] + assert op.reports == [({"WARNING"}, "Merge failed — see the conflict report in the panel below")] def test_unknown_merge_error(self, ifcgit, ifc): ifc.get_path().should_be_called().will_return("path/to/model.ifc") @@ -237,6 +245,53 @@ class TestMergeBranch: assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")] +class TestDryRunMerge: + def test_no_branch_at_selected_commit(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return(None) + subject.dry_run_merge(ifcgit, ifc, operator=None) + + def test_clean_merge_preview(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return(None) + ifcgit.git_merge_abort().should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] + + def test_conflict_preview_shows_report(self, ifcgit, ifc): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) + ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_conflicts(conflicts).should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"WARNING"}, "Merge preview: conflicts found — see the panel below")] + + def test_conflict_preview_mergetool_succeeds(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) + ifcgit.git_merge_abort().should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] + + class TestEntityLog: def test_run(self, ifcgit, ifc): ifc.get_path().should_be_called().will_return("path/to/model.ifc") diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py index 5cd051aa5f..964bd96fd2 100644 --- a/src/bonsai/test/tool/test_ifcgit.py +++ b/src/bonsai/test/tool/test_ifcgit.py @@ -454,3 +454,128 @@ class TestIfcDiffIds(NewFile): result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) assert 1 in result["modified"] assert 2 in result["modified"] + + +# --------------------------------------------------------------------------- +# Merge conflict report — store / clear / get +# --------------------------------------------------------------------------- + + +class TestStoreClearGetMergeConflicts(NewFile): + def test_round_trip(self): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + IfcGit.store_merge_conflicts(conflicts) + result = IfcGit.get_merge_conflicts() + assert result == conflicts + + def test_get_returns_none_when_empty(self): + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_clear_removes_stored_conflicts(self): + IfcGit.store_merge_conflicts([{"type": "class_changed"}]) + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_get_returns_none_on_corrupt_json(self): + import bpy + + bpy.context.scene.IfcGitProperties.merge_conflicts = "not valid json {" + assert IfcGit.get_merge_conflicts() is None + + +# --------------------------------------------------------------------------- +# git_mergetool — report file reading +# --------------------------------------------------------------------------- + + +class TestGitMergetool: + @requires_git + def test_returns_none_when_report_file_absent(self): + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + mock_repo = mock.MagicMock() + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result is None + IfcGitRepo.repo = None + + @requires_git + def test_returns_none_when_report_file_empty(self): + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + open(report_path, "w").close() + mock_repo = mock.MagicMock() + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result is None + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + @requires_git + def test_parses_conflict_report_and_deletes_file(self): + import json + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + conflicts = [{"type": "attribute_conflict", "entity_id": 5}] + with open(report_path, "w") as f: + json.dump({"status": "failed", "conflicts": conflicts}, f) + mock_repo = mock.MagicMock() + mock_repo.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1) + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result == conflicts + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + +# --------------------------------------------------------------------------- +# config_ifcmerge — cmd format and update +# --------------------------------------------------------------------------- + + +class TestConfigIfcmerge: + @requires_git + def test_writes_redirect_cmd_on_first_call(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_updates_cmd_missing_redirect(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + with repo.config_writer() as w: + w.set_value('mergetool "ifcmerge"', "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + w.set_value('mergetool "ifcmerge"', "trustExitCode", True) + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_forward_tool_writes_redirect_cmd(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge-forward"', "cmd") + assert "--prioritise-local" in cmd + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None From 0a1c54cedc55c254be4bfb8bee251649bf573e09 Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 4 Apr 2026 03:56:08 +0100 Subject: [PATCH 31/76] Fix the ci-bonsai-daily blender url --- .github/workflows/ci-bonsai-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index 68b6ac2dbb..c6deec140e 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -109,7 +109,7 @@ jobs: # Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo. # Download Blender. - wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.1.0-linux-x64.tar.xz + wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz tar -xf blender.tar.xz # Setup Blender. From 12123baafecd4e5f3404501c916882813eec949b Mon Sep 17 00:00:00 2001 From: Stephen Boddy Date: Sat, 4 Apr 2026 04:22:12 +0100 Subject: [PATCH 32/76] Update the pyver as 3.13 is default in 5.1 now --- .github/workflows/ci-bonsai-daily.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index c6deec140e..5e1e90e8d9 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -122,7 +122,7 @@ jobs: pip install -r requirements.txt python setup_extensions_repo.py --last-tag cd .. - bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py311*-linux-x64.zip)" + bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py313*-linux-x64.zip)" # Install Bonsai. blender --command extension install-file -r user_default -e $bonsai_zip From 5b1ec85f7526468aeb0deca54b581a008294c9d9 Mon Sep 17 00:00:00 2001 From: DesertSpringsCivil Date: Fri, 3 Apr 2026 15:47:13 -0600 Subject: [PATCH 33/76] feat: Reduce token usage in ifcchat and default to IFC4X3 - Add Anthropic prompt caching (cache_control on system prompt and tools) to reduce repeated token costs by ~90% - Truncate large tool results in conversation history (2000 char cap) to prevent context bloat from ifc_tree/ifc_select responses - Add sliding window (40 messages) on conversation history, trimming at user message boundaries to avoid breaking tool-call sequences - Default "New IFC" button to IFC4X3 schema instead of IFC4 - Constrain ifc_new schema parameter with enum to prevent invalid schema strings like "IFC4X3ADD2" Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ifcchat/api_anthropic.js | 11 +++++++++-- src/ifcchat/app.js | 33 +++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/ifcchat/api_anthropic.js b/src/ifcchat/api_anthropic.js index 0359cde936..6403317154 100644 --- a/src/ifcchat/api_anthropic.js +++ b/src/ifcchat/api_anthropic.js @@ -146,15 +146,22 @@ function toChatCompletionResponse(response) { export async function chat({ apiKey, model, messages, tools }) { const request = splitSystemAndMessages(messages); + const anthropicTools = toAnthropicTools(tools); + + // Mark the last tool with cache_control so the entire tool list is cached + if (anthropicTools.length > 0) { + anthropicTools[anthropicTools.length - 1].cache_control = { type: "ephemeral" }; + } + const body = { model, max_tokens: 4096, messages: request.messages, - tools: toAnthropicTools(tools), + tools: anthropicTools, }; if (request.system) { - body.system = request.system; + body.system = [{ type: "text", text: request.system, cache_control: { type: "ephemeral" } }]; } const res = await fetch("https://api.anthropic.com/v1/messages", { diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index b268bf8256..a3b340bc97 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -418,8 +418,8 @@ function callWorker(type, payload = {}) { // ---- Tool schemas (should match ifcmcp.core openai_tools()) ---- const tools = [ { - 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", function: { name: "ifc_new", description: "Create a new empty IFC model in memory. Valid schemas: IFC4, IFC2X3, IFC4X3 (for IFC 4.3).", + parameters: { type: "object", properties: { schema: { type: "string", enum: ["IFC4", "IFC2X3", "IFC4X3"] } }, required: [], additionalProperties: false } } }, { type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.", @@ -497,6 +497,27 @@ Be concise. Avoid dumping huge trees unless asked. let messages = []; // running conversation state (Chat Completions style) +const MAX_TOOL_RESULT_CHARS = 2000; +const MAX_HISTORY_MESSAGES = 40; + +function truncateToolResult(text) { + if (text.length <= MAX_TOOL_RESULT_CHARS) return text; + return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)"; +} + +function trimHistory() { + if (messages.length <= MAX_HISTORY_MESSAGES) return; + // Find a safe cut point — don't break mid-tool-call sequence. + // Walk forward from the trim target to find a user message boundary. + let cut = messages.length - MAX_HISTORY_MESSAGES; + while (cut < messages.length && messages[cut].role !== "user") { + cut++; + } + if (cut > 0 && cut < messages.length) { + messages.splice(0, cut); + } +} + async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); @@ -506,6 +527,7 @@ async function runAgentTurn(userText) { const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; messages.push({ role: "user", content: userText }); + trimHistory(); for (let i = 0; i < 64; i++) { const response = await chat({ @@ -535,12 +557,15 @@ async function runAgentTurn(userText) { const toolRes = await callWorker("toolCall", { name: call.function.name, args }); + const fullResult = JSON.stringify(toolRes.result); + messages.push({ role: "tool", tool_call_id: call.id, - content: JSON.stringify(toolRes.result), + content: truncateToolResult(fullResult), }); + // Show full result in UI, but only truncated version goes to the LLM addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`); } } @@ -588,7 +613,7 @@ ifcFileEl.onchange = async () => { newBtn.onclick = async () => { try { setBusy(true, "Creating new model…"); - const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4" } }); + const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4X3" } }); addMessage("assistant", `New model: ${JSON.stringify(r.result)}`); setBusy(false, "Ready"); } catch (e) { From 30517770e0967366b7342eebf86dcf58bc28a6fa Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sat, 4 Apr 2026 13:12:47 +0200 Subject: [PATCH 34/76] Revert default tool output truncation --- src/ifcchat/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index a3b340bc97..203d25798a 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -497,11 +497,11 @@ Be concise. Avoid dumping huge trees unless asked. let messages = []; // running conversation state (Chat Completions style) -const MAX_TOOL_RESULT_CHARS = 2000; +const MAX_TOOL_RESULT_CHARS = 0; const MAX_HISTORY_MESSAGES = 40; function truncateToolResult(text) { - if (text.length <= MAX_TOOL_RESULT_CHARS) return text; + if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text; return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)"; } From 97ee4eaef0b576a6d262be60254aeacb90968d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Sat, 4 Apr 2026 14:29:54 -0300 Subject: [PATCH 35/76] See #7888 - Fix snap when object changes during modal operator. Handle cases where the snapped target is modified while a modal operator is active (e.g., adding a door or window that alters the wall geometry). --- src/bonsai/bonsai/tool/raycast.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 6b613aecaf..38882a5095 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -889,8 +889,19 @@ class Raycast(bonsai.core.tool.Raycast): def create_snap_obj(cls, obj): if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): return None - for snap_obj in cls.snap_objs: + for i, snap_obj in enumerate(cls.snap_objs): if obj.name == snap_obj.obj.name: + # Handle objects modified while a modal operator is active. + # Example: adding a door or window alters the wall geometry. + if len(obj.data.vertices) != len(snap_obj.verts_3d): + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d): + if (obj.matrix_world @ v1.co) != v2: + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) return snap_obj snap_obj = SnapObj(obj) cls.snap_objs.append(snap_obj) From 1c7e134d78ebf6bd67bc7fdf8f1de07c0bc66978 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 4 Apr 2026 13:35:41 -0500 Subject: [PATCH 36/76] Fix #3742: Remove coplanar boundary lines between adjacent same-material elements in Bonsai SVG drawings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `remove_coplanar_boundary_lines()` to operator.py (Bonsai uses this path, not draw.py's main()). After `merge_linework_and_add_metadata()` assigns material CSS classes, this post-processes the SVG to delete projection line segments that appear in two or more adjacent, coplanar elements with the same material and presentation style. Key design decisions: - Material identity: compared via sorted IFC material ID tuples from `get_materials()`, not CSS class names — avoids false matches between unrelated `material-null` elements. - Presentation style identity: compared via IFC IfcPresentationStyle IDs from `StyledByItem` on geometry representation items — handles elements with no material but distinct visual styles. - Physical adjacency: confirmed by a 3D shared-vertex test (tol=0.01 m) after a quick AABB guard, rejecting elements whose 2D projections overlap but sit at different depths. - Coplanarity: determined by the dominant (largest-area) face normal of each Blender mesh object — area-weighted averages are unreliable for slabs whose equal top/bottom faces cancel out. Folded walls sharing an edge but meeting at an angle are correctly rejected (normal dot ≪ 1.0). Co-Authored-By: Claude Sonnet 4.6 --- .../bonsai/bim/module/drawing/operator.py | 243 ++++++++++++++++-- 1 file changed, 217 insertions(+), 26 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 45f67b0769..ebb505a4fa 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1005,12 +1005,14 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) + self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) elif self.cprops.cut_mode == "OPENCASCADE": self.move_projection_to_bottom(root) if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) + self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) if self.cprops.fill_mode == "SHAPELY": @@ -1088,6 +1090,7 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.fill_mode == "SVGFILL": results = etree.tostring(root).decode("utf8") svg_data_1 = results + from collections import defaultdict from xml.dom.minidom import parseString def yield_groups(n): @@ -1102,8 +1105,19 @@ class CreateDrawing(bpy.types.Operator): ls_groups = ifcopenshell.ifcopenshell_wrapper.svg_to_line_segments(results, "projection") - for i, (ls, g1) in enumerate(zip(ls_groups, groups1)): - projection, g1 = g1, g1.parentNode + # Group projection elements by their parent section-view group so that all + # projection linework from the same view is merged in one cell decomposition. + # This enables coplanar surfaces from *different* elements to be joined. + groups_by_parent = defaultdict(list) + ls_by_parent = defaultdict(list) + for ls, g in zip(ls_groups, groups1): + pid = id(g.parentNode) + groups_by_parent[pid].append(g) + ls_by_parent[pid].extend(ls) + + for pid, projection_groups in groups_by_parent.items(): + section_parent = projection_groups[0].parentNode + combined_ls = ls_by_parent[pid] svgfill_context = ifcopenshell.ifcopenshell_wrapper.context( ifcopenshell.ifcopenshell_wrapper.EXACT_CONSTRUCTIONS, 1.0e-3 @@ -1111,10 +1125,11 @@ class CreateDrawing(bpy.types.Operator): # EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT # remove duplicates (without tolerance) - ls = [l for l in map(tuple, set(map(frozenset, ls))) if len(l) == 2 and l[0] != l[1]] - svgfill_context.add(ls) + combined_ls = [l for l in map(tuple, set(map(frozenset, combined_ls))) if len(l) == 2 and l[0] != l[1]] + svgfill_context.add(combined_ls) - num_passes = 0 + num_passes = 1 + g2 = None for iteration in range(num_passes + 1): # initialize empty group, note that in the current approach only one @@ -1189,11 +1204,8 @@ class CreateDrawing(bpy.types.Operator): if iteration != num_passes: to_remove = [] + material_cache = {} for he_idx in range(0, len(pairs), 2): - # @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal) - # to see if they're coplanar, because ray-distance will be different in case - # of element surfaces non-orthogonal to the view direction - def format(x): if x is None: return None @@ -1201,34 +1213,48 @@ class CreateDrawing(bpy.types.Operator): # found to be inside element using tree.select() no face or style info return x else: - return (x.instance.is_a(), x.ray_distance, tuple(x.position)) + return (x.instance, tuple(x.position), tuple(x.normal), x.style_index) pp = pairs[he_idx : he_idx + 2] if pp == (-1, -1): continue data = list(map(format, map(semantics.__getitem__, pp))) - if None not in data and data[0][0] == data[1][0] and abs(data[0][1] - data[1][1]) < 1.0e-5: - to_remove.append(he_idx // 2) - # Print edge index and semantic data - # print(he_idx // 2, *data) + if None not in data and data[0][0].is_a() == data[1][0].is_a(): + if len(data[0]) == 2 and len(data[1]) == 2: + # Both from tree.select() -> same element = same surface + if data[0][0] == data[1][0]: + to_remove.append(he_idx // 2) + elif len(data[0]) == 4 and len(data[1]) == 4: + # Both from tree.select_ray() -> coplanar + same style + same material + p1, n1, s1 = np.array(data[0][1]), np.array(data[0][2]), data[0][3] + p2, n2, s2 = np.array(data[1][1]), np.array(data[1][2]), data[1][3] + + if s1 == s2: + if abs(1.0 - abs(np.dot(n1, n2))) < 1.0e-4: + if abs(np.dot(p1 - p2, n1)) < 1.0e-4: + def get_cached_material(inst): + id_ = inst.id() + if id_ not in material_cache: + mats = ifcopenshell.util.element.get_materials(inst) + material_cache[id_] = tuple(m.id() for m in mats) if mats else (-1,) + return material_cache[id_] + + if get_cached_material(data[0][0]) == get_cached_material(data[1][0]): + to_remove.append(he_idx // 2) + # print(he_idx // 2, *data) svgfill_context.merge(to_remove) - # Swap the XML nodes from the files - # Remove the original hidden line node we still have in the serializer output - g1.removeChild(projection) + # Replace all per-element projection groups with one merged cell group. + # SVG draw order: projections must be below sections, so insert first. + for pg in projection_groups: + section_parent.removeChild(pg) g2.setAttribute("class", "projection") - # Find the children of the projection node parent - children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE] + children = [x for x in section_parent.childNodes if x.nodeType == x.ELEMENT_NODE] if children: - # Insert the new semantically enriched cell-based projection node - # *before* the node with sections from the serializer. SVG derives - # draw order from node order in the DOM so sections are draw over - # the projections. - g1.insertBefore(g2, children[0]) + section_parent.insertBefore(g2, children[0]) else: - # This generally shouldn't happen - g1.appendChild(g2) + section_parent.appendChild(g2) results = dom1.toxml() results = results.encode("ascii", "xmlcharrefreplace") @@ -1600,6 +1626,171 @@ class CreateDrawing(bpy.types.Operator): g.set("class", " ".join(list(polygon_classes))) group.append(g) + def remove_coplanar_boundary_lines(self, root): + """Remove projection line segments shared between same-material elements. + + After merge_linework_and_add_metadata() adds material-* CSS classes, + this scans all per-element projection groups under each common + parent, finds path segments (M x0,y0 L x1,y1) that appear in two or + more groups that carry the same material-* class, and deletes them from + both groups so coplanar surfaces of the same material appear seamless. + """ + SVG = "http://www.w3.org/2000/svg" + TOL = 0.01 # SVG coordinate tolerance for matching line endpoints + + obj_cache = {} + + def get_obj(guid): + if guid in obj_cache: + return obj_cache[guid] + element = self.get_element_by_guid(guid) + obj = tool.Ifc.get_object(element) if element is not None else None + obj_cache[guid] = obj + return obj + + adjacency_cache = {} + + def are_coplanar_and_adjacent(guid_a, guid_b, tol=0.01): + """True if the two meshes share a vertex AND have parallel face normals. + + Sharing a vertex confirms physical adjacency (rules out depth-stacked elements + whose 2D projections accidentally overlap). Parallel normals confirms the + shared face is coplanar — elements meeting at a fold angle are rejected. + """ + key = (min(guid_a, guid_b), max(guid_a, guid_b)) + if key in adjacency_cache: + return adjacency_cache[key] + obj_a = get_obj(guid_a) + obj_b = get_obj(guid_b) + if obj_a is None or obj_b is None or obj_a.type != "MESH" or obj_b.type != "MESH": + adjacency_cache[key] = True + return True + # Quick AABB guard + corners_a = [obj_a.matrix_world @ Vector(c) for c in obj_a.bound_box] + corners_b = [obj_b.matrix_world @ Vector(c) for c in obj_b.bound_box] + for axis in range(3): + if min(c[axis] for c in corners_a) > max(c[axis] for c in corners_b) + tol: + adjacency_cache[key] = False + return False + if min(c[axis] for c in corners_b) > max(c[axis] for c in corners_a) + tol: + adjacency_cache[key] = False + return False + # Shared vertex check + tol_sq = tol * tol + verts_a = [obj_a.matrix_world @ v.co for v in obj_a.data.vertices] + verts_b = [obj_b.matrix_world @ v.co for v in obj_b.data.vertices] + has_shared = any((va - vb).length_squared < tol_sq for va in verts_a for vb in verts_b) + if not has_shared: + adjacency_cache[key] = False + return False + # Coplanarity check: use the largest-face normal for each object. + # Area-weighted averages fail for slabs because top/bottom faces cancel. + def dominant_world_normal(obj): + mat3 = obj.matrix_world.to_3x3().normalized() + best = max(obj.data.polygons, key=lambda p: p.area, default=None) + if best is None or best.area < 1e-10: + return None + return (mat3 @ best.normal).normalized() + + n_a = dominant_world_normal(obj_a) + n_b = dominant_world_normal(obj_b) + if n_a is None or n_b is None: + adjacency_cache[key] = True + return True + result = abs(n_a.dot(n_b)) > 1.0 - 1e-3 + adjacency_cache[key] = result + return result + + def parse_line(d): + # Format is "Mx0,y0 Lx1,y1" (no space after M/L) + parts = d.strip().split() + if len(parts) == 2 and parts[0].startswith("M") and parts[1].startswith("L"): + try: + x0, y0 = map(float, parts[0][1:].split(",")) + x1, y1 = map(float, parts[1][1:].split(",")) + return (x0, y0), (x1, y1) + except ValueError: + pass + return None + + def lines_match(a, b): + (x0a, y0a), (x1a, y1a) = a + (x0b, y0b), (x1b, y1b) = b + return ( + abs(x0a - x0b) < TOL and abs(y0a - y0b) < TOL and abs(x1a - x1b) < TOL and abs(y1a - y1b) < TOL + ) or ( + abs(x0a - x1b) < TOL and abs(y0a - y1b) < TOL and abs(x1a - x0b) < TOL and abs(y1a - y0b) < TOL + ) + + # Group projection elements by their immediate parent + parent_to_groups = {} + for g in root.iter(f"{{{SVG}}}g"): + cls_list = g.get("class", "").split() + if "projection" not in cls_list: + continue + parent = g.getparent() + if parent is None: + continue + parent_to_groups.setdefault(id(parent), []).append(g) + + for pid, proj_groups in parent_to_groups.items(): + if len(proj_groups) < 2: + continue + + def get_material_key(guid): + element = self.get_element_by_guid(guid) + if element is None: + return None + mats = ifcopenshell.util.element.get_materials(element) + return tuple(sorted(m.id() for m in mats)) if mats else () + + def get_style_key(guid): + """IDs of IfcPresentationStyles directly on the element's geometry items.""" + element = self.get_element_by_guid(guid) + if element is None or not getattr(element, "Representation", None): + return () + style_ids = set() + for rep in element.Representation.Representations: + for item in rep.Items: + for si in getattr(item, "StyledByItem", ()): + for style in si.Styles: + style_ids.add(style.id()) + return tuple(sorted(style_ids)) + + group_data = [] + for grp in proj_groups: + guid = grp.get("{http://www.ifcopenshell.org/ns}guid", "") + mat_key = get_material_key(guid) + style_key = get_style_key(guid) + segs = [] + for path_el in grp.findall(f"{{{SVG}}}path"): + line = parse_line(path_el.get("d", "")) + if line is not None: + segs.append((path_el, line)) + group_data.append((grp, mat_key, style_key, segs, guid)) + + to_remove = set() + for i, (grp_i, mat_i, style_i, segs_i, guid_i) in enumerate(group_data): + if mat_i is None: + continue + for j, (grp_j, mat_j, style_j, segs_j, guid_j) in enumerate(group_data): + if j <= i: + continue + if mat_j != mat_i or style_j != style_i: + continue + if not are_coplanar_and_adjacent(guid_i, guid_j): + continue + for path_i, line_i in segs_i: + for path_j, line_j in segs_j: + if lines_match(line_i, line_j): + to_remove.add(id(path_i)) + to_remove.add(id(path_j)) + if to_remove: + for grp, mat, style, segs, guid in group_data: + for path_el, _ in segs: + if id(path_el) in to_remove: + grp.remove(path_el) + def drawing_to_model_co(self, x: float, y: float) -> Vector: camera_xy = np.array((x, -y)) / self.scale / 1000 camera_xy += np.array((self.cprops.width / -2, self.cprops.height / 2)) # top left offset From 5436467fc56c90149bf3d325fe9119e9c183f8e5 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 4 Apr 2026 13:49:02 -0500 Subject: [PATCH 37/76] Whoops, this was supposed to be a PR... Revert "Fix #3742: Remove coplanar boundary lines between adjacent same-material elements in Bonsai SVG drawings" This reverts commit 1c7e134d78ebf6bd67bc7fdf8f1de07c0bc66978. --- .../bonsai/bim/module/drawing/operator.py | 243 ++---------------- 1 file changed, 26 insertions(+), 217 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index ebb505a4fa..45f67b0769 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1005,14 +1005,12 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) - self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) elif self.cprops.cut_mode == "OPENCASCADE": self.move_projection_to_bottom(root) if self.cprops.generate_material_layers: self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) - self.remove_coplanar_boundary_lines(root) self.move_elements_to_top(root) if self.cprops.fill_mode == "SHAPELY": @@ -1090,7 +1088,6 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.fill_mode == "SVGFILL": results = etree.tostring(root).decode("utf8") svg_data_1 = results - from collections import defaultdict from xml.dom.minidom import parseString def yield_groups(n): @@ -1105,19 +1102,8 @@ class CreateDrawing(bpy.types.Operator): ls_groups = ifcopenshell.ifcopenshell_wrapper.svg_to_line_segments(results, "projection") - # Group projection elements by their parent section-view group so that all - # projection linework from the same view is merged in one cell decomposition. - # This enables coplanar surfaces from *different* elements to be joined. - groups_by_parent = defaultdict(list) - ls_by_parent = defaultdict(list) - for ls, g in zip(ls_groups, groups1): - pid = id(g.parentNode) - groups_by_parent[pid].append(g) - ls_by_parent[pid].extend(ls) - - for pid, projection_groups in groups_by_parent.items(): - section_parent = projection_groups[0].parentNode - combined_ls = ls_by_parent[pid] + for i, (ls, g1) in enumerate(zip(ls_groups, groups1)): + projection, g1 = g1, g1.parentNode svgfill_context = ifcopenshell.ifcopenshell_wrapper.context( ifcopenshell.ifcopenshell_wrapper.EXACT_CONSTRUCTIONS, 1.0e-3 @@ -1125,11 +1111,10 @@ class CreateDrawing(bpy.types.Operator): # EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT # remove duplicates (without tolerance) - combined_ls = [l for l in map(tuple, set(map(frozenset, combined_ls))) if len(l) == 2 and l[0] != l[1]] - svgfill_context.add(combined_ls) + ls = [l for l in map(tuple, set(map(frozenset, ls))) if len(l) == 2 and l[0] != l[1]] + svgfill_context.add(ls) - num_passes = 1 - g2 = None + num_passes = 0 for iteration in range(num_passes + 1): # initialize empty group, note that in the current approach only one @@ -1204,8 +1189,11 @@ class CreateDrawing(bpy.types.Operator): if iteration != num_passes: to_remove = [] - material_cache = {} for he_idx in range(0, len(pairs), 2): + # @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal) + # to see if they're coplanar, because ray-distance will be different in case + # of element surfaces non-orthogonal to the view direction + def format(x): if x is None: return None @@ -1213,48 +1201,34 @@ class CreateDrawing(bpy.types.Operator): # found to be inside element using tree.select() no face or style info return x else: - return (x.instance, tuple(x.position), tuple(x.normal), x.style_index) + return (x.instance.is_a(), x.ray_distance, tuple(x.position)) pp = pairs[he_idx : he_idx + 2] if pp == (-1, -1): continue data = list(map(format, map(semantics.__getitem__, pp))) - if None not in data and data[0][0].is_a() == data[1][0].is_a(): - if len(data[0]) == 2 and len(data[1]) == 2: - # Both from tree.select() -> same element = same surface - if data[0][0] == data[1][0]: - to_remove.append(he_idx // 2) - elif len(data[0]) == 4 and len(data[1]) == 4: - # Both from tree.select_ray() -> coplanar + same style + same material - p1, n1, s1 = np.array(data[0][1]), np.array(data[0][2]), data[0][3] - p2, n2, s2 = np.array(data[1][1]), np.array(data[1][2]), data[1][3] - - if s1 == s2: - if abs(1.0 - abs(np.dot(n1, n2))) < 1.0e-4: - if abs(np.dot(p1 - p2, n1)) < 1.0e-4: - def get_cached_material(inst): - id_ = inst.id() - if id_ not in material_cache: - mats = ifcopenshell.util.element.get_materials(inst) - material_cache[id_] = tuple(m.id() for m in mats) if mats else (-1,) - return material_cache[id_] - - if get_cached_material(data[0][0]) == get_cached_material(data[1][0]): - to_remove.append(he_idx // 2) - # print(he_idx // 2, *data) + if None not in data and data[0][0] == data[1][0] and abs(data[0][1] - data[1][1]) < 1.0e-5: + to_remove.append(he_idx // 2) + # Print edge index and semantic data + # print(he_idx // 2, *data) svgfill_context.merge(to_remove) - # Replace all per-element projection groups with one merged cell group. - # SVG draw order: projections must be below sections, so insert first. - for pg in projection_groups: - section_parent.removeChild(pg) + # Swap the XML nodes from the files + # Remove the original hidden line node we still have in the serializer output + g1.removeChild(projection) g2.setAttribute("class", "projection") - children = [x for x in section_parent.childNodes if x.nodeType == x.ELEMENT_NODE] + # Find the children of the projection node parent + children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE] if children: - section_parent.insertBefore(g2, children[0]) + # Insert the new semantically enriched cell-based projection node + # *before* the node with sections from the serializer. SVG derives + # draw order from node order in the DOM so sections are draw over + # the projections. + g1.insertBefore(g2, children[0]) else: - section_parent.appendChild(g2) + # This generally shouldn't happen + g1.appendChild(g2) results = dom1.toxml() results = results.encode("ascii", "xmlcharrefreplace") @@ -1626,171 +1600,6 @@ class CreateDrawing(bpy.types.Operator): g.set("class", " ".join(list(polygon_classes))) group.append(g) - def remove_coplanar_boundary_lines(self, root): - """Remove projection line segments shared between same-material elements. - - After merge_linework_and_add_metadata() adds material-* CSS classes, - this scans all per-element projection groups under each common - parent, finds path segments (M x0,y0 L x1,y1) that appear in two or - more groups that carry the same material-* class, and deletes them from - both groups so coplanar surfaces of the same material appear seamless. - """ - SVG = "http://www.w3.org/2000/svg" - TOL = 0.01 # SVG coordinate tolerance for matching line endpoints - - obj_cache = {} - - def get_obj(guid): - if guid in obj_cache: - return obj_cache[guid] - element = self.get_element_by_guid(guid) - obj = tool.Ifc.get_object(element) if element is not None else None - obj_cache[guid] = obj - return obj - - adjacency_cache = {} - - def are_coplanar_and_adjacent(guid_a, guid_b, tol=0.01): - """True if the two meshes share a vertex AND have parallel face normals. - - Sharing a vertex confirms physical adjacency (rules out depth-stacked elements - whose 2D projections accidentally overlap). Parallel normals confirms the - shared face is coplanar — elements meeting at a fold angle are rejected. - """ - key = (min(guid_a, guid_b), max(guid_a, guid_b)) - if key in adjacency_cache: - return adjacency_cache[key] - obj_a = get_obj(guid_a) - obj_b = get_obj(guid_b) - if obj_a is None or obj_b is None or obj_a.type != "MESH" or obj_b.type != "MESH": - adjacency_cache[key] = True - return True - # Quick AABB guard - corners_a = [obj_a.matrix_world @ Vector(c) for c in obj_a.bound_box] - corners_b = [obj_b.matrix_world @ Vector(c) for c in obj_b.bound_box] - for axis in range(3): - if min(c[axis] for c in corners_a) > max(c[axis] for c in corners_b) + tol: - adjacency_cache[key] = False - return False - if min(c[axis] for c in corners_b) > max(c[axis] for c in corners_a) + tol: - adjacency_cache[key] = False - return False - # Shared vertex check - tol_sq = tol * tol - verts_a = [obj_a.matrix_world @ v.co for v in obj_a.data.vertices] - verts_b = [obj_b.matrix_world @ v.co for v in obj_b.data.vertices] - has_shared = any((va - vb).length_squared < tol_sq for va in verts_a for vb in verts_b) - if not has_shared: - adjacency_cache[key] = False - return False - # Coplanarity check: use the largest-face normal for each object. - # Area-weighted averages fail for slabs because top/bottom faces cancel. - def dominant_world_normal(obj): - mat3 = obj.matrix_world.to_3x3().normalized() - best = max(obj.data.polygons, key=lambda p: p.area, default=None) - if best is None or best.area < 1e-10: - return None - return (mat3 @ best.normal).normalized() - - n_a = dominant_world_normal(obj_a) - n_b = dominant_world_normal(obj_b) - if n_a is None or n_b is None: - adjacency_cache[key] = True - return True - result = abs(n_a.dot(n_b)) > 1.0 - 1e-3 - adjacency_cache[key] = result - return result - - def parse_line(d): - # Format is "Mx0,y0 Lx1,y1" (no space after M/L) - parts = d.strip().split() - if len(parts) == 2 and parts[0].startswith("M") and parts[1].startswith("L"): - try: - x0, y0 = map(float, parts[0][1:].split(",")) - x1, y1 = map(float, parts[1][1:].split(",")) - return (x0, y0), (x1, y1) - except ValueError: - pass - return None - - def lines_match(a, b): - (x0a, y0a), (x1a, y1a) = a - (x0b, y0b), (x1b, y1b) = b - return ( - abs(x0a - x0b) < TOL and abs(y0a - y0b) < TOL and abs(x1a - x1b) < TOL and abs(y1a - y1b) < TOL - ) or ( - abs(x0a - x1b) < TOL and abs(y0a - y1b) < TOL and abs(x1a - x0b) < TOL and abs(y1a - y0b) < TOL - ) - - # Group projection elements by their immediate parent - parent_to_groups = {} - for g in root.iter(f"{{{SVG}}}g"): - cls_list = g.get("class", "").split() - if "projection" not in cls_list: - continue - parent = g.getparent() - if parent is None: - continue - parent_to_groups.setdefault(id(parent), []).append(g) - - for pid, proj_groups in parent_to_groups.items(): - if len(proj_groups) < 2: - continue - - def get_material_key(guid): - element = self.get_element_by_guid(guid) - if element is None: - return None - mats = ifcopenshell.util.element.get_materials(element) - return tuple(sorted(m.id() for m in mats)) if mats else () - - def get_style_key(guid): - """IDs of IfcPresentationStyles directly on the element's geometry items.""" - element = self.get_element_by_guid(guid) - if element is None or not getattr(element, "Representation", None): - return () - style_ids = set() - for rep in element.Representation.Representations: - for item in rep.Items: - for si in getattr(item, "StyledByItem", ()): - for style in si.Styles: - style_ids.add(style.id()) - return tuple(sorted(style_ids)) - - group_data = [] - for grp in proj_groups: - guid = grp.get("{http://www.ifcopenshell.org/ns}guid", "") - mat_key = get_material_key(guid) - style_key = get_style_key(guid) - segs = [] - for path_el in grp.findall(f"{{{SVG}}}path"): - line = parse_line(path_el.get("d", "")) - if line is not None: - segs.append((path_el, line)) - group_data.append((grp, mat_key, style_key, segs, guid)) - - to_remove = set() - for i, (grp_i, mat_i, style_i, segs_i, guid_i) in enumerate(group_data): - if mat_i is None: - continue - for j, (grp_j, mat_j, style_j, segs_j, guid_j) in enumerate(group_data): - if j <= i: - continue - if mat_j != mat_i or style_j != style_i: - continue - if not are_coplanar_and_adjacent(guid_i, guid_j): - continue - for path_i, line_i in segs_i: - for path_j, line_j in segs_j: - if lines_match(line_i, line_j): - to_remove.add(id(path_i)) - to_remove.add(id(path_j)) - if to_remove: - for grp, mat, style, segs, guid in group_data: - for path_el, _ in segs: - if id(path_el) in to_remove: - grp.remove(path_el) - def drawing_to_model_co(self, x: float, y: float) -> Vector: camera_xy = np.array((x, -y)) / self.scale / 1000 camera_xy += np.array((self.cprops.width / -2, self.cprops.height / 2)) # top left offset From ab7d9fdf4a519b74ca82bde48ac9518fee9683d9 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 5 Apr 2026 16:43:03 -0500 Subject: [PATCH 38/76] Auto-assign aggregate on eyedropper pick Add update callbacks to the relating_object and related_object PointerProperties so that selecting an object via the eyedropper in BIM_PT_aggregate immediately calls aggregate_assign_object and closes the editing panel, removing the need to click the checkmark button manually. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/aggregate/prop.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/prop.py b/src/bonsai/bonsai/bim/module/aggregate/prop.py index 0595c200c5..424f2b829f 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/prop.py +++ b/src/bonsai/bonsai/bim/module/aggregate/prop.py @@ -73,6 +73,22 @@ def poll_related_object(self: "BIMObjectAggregateProperties", related_obj: bpy.t return True +def update_relating_object(self, context): + if self.relating_object: + ifc_id = tool.Blender.get_object_bim_props(self.relating_object).ifc_definition_id + if ifc_id: + bpy.ops.bim.aggregate_assign_object(relating_object=ifc_id) + bpy.ops.bim.disable_editing_aggregate() + + +def update_related_object(self, context): + if self.related_object: + ifc_id = tool.Blender.get_object_bim_props(self.related_object).ifc_definition_id + if ifc_id: + bpy.ops.bim.aggregate_assign_object(related_object=ifc_id) + bpy.ops.bim.disable_editing_aggregate() + + def update_aggregate_decorator(self, context): if self.aggregate_decorator: AggregateDecorator.install(bpy.context) @@ -89,12 +105,15 @@ def update_aggregate_mode_decorator(self, context): class BIMObjectAggregateProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") - relating_object: PointerProperty(name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object) + relating_object: PointerProperty( + name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object, update=update_relating_object + ) related_object: PointerProperty( name="Related Part", description="Related Part, will be used to derive the Relating Object", type=bpy.types.Object, poll=poll_related_object, + update=update_related_object, ) if TYPE_CHECKING: From 9d4307d34373e47244a8e5b0fde3e2a2d1c9d3eb Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sun, 5 Apr 2026 11:50:19 +0200 Subject: [PATCH 39/76] ifcchat: Throttling of messages based on estimated token counts --- src/ifcchat/app.js | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 203d25798a..bcb28ecf57 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -499,6 +499,9 @@ let messages = []; // running conversation state (Chat Completions style) const MAX_TOOL_RESULT_CHARS = 0; const MAX_HISTORY_MESSAGES = 40; +const ESTIMATED_CHARS_PER_TOKEN = 4; +const MAX_ESTIMATED_TOKENS_PER_MINUTE = 20000; +const minuteTokenMap = new Map(); function truncateToolResult(text) { if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text; @@ -518,6 +521,16 @@ function trimHistory() { } } +function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) { + return Array.from(minuteTokenMap.entries()) + .filter(([minuteBucket]) => minuteBucket >= firstIterationMinuteBucket) + .sort(([leftMinuteBucket], [rightMinuteBucket]) => leftMinuteBucket - rightMinuteBucket) + .map(([minuteBucket, estimatedTokens]) => ({ + timestamp: new Date(minuteBucket * 60000).toISOString(), + estimated_tokens: estimatedTokens, + })); +} + async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); @@ -525,16 +538,33 @@ async function runAgentTurn(userText) { const provider = PROVIDERS[getProviderValue()]; const { chat } = provider.api; const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; + let firstIterationMinuteBucket = null; messages.push({ role: "user", content: userText }); trimHistory(); for (let i = 0; i < 64; i++) { + const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]; + const estimatedTokens = Math.max(1, Math.ceil(JSON.stringify(messages_with_system).length / ESTIMATED_CHARS_PER_TOKEN)); + const now = Date.now(); + let currentMinuteBucket = Math.floor(now / 60000); + if (firstIterationMinuteBucket === null) { + firstIterationMinuteBucket = currentMinuteBucket; + } + const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens; + + if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) { + currentMinuteBucket += 1; + await new Promise((resolve) => setTimeout(() => resolve(), 60000)); + } + + minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens); + const response = await chat({ apiKey, baseURL, model: modelEl.value, - messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages], + messages: messages_with_system, tools, }); @@ -546,7 +576,10 @@ async function runAgentTurn(userText) { if (message.content) addMessage("assistant", message.content); const calls = message.tool_calls ?? []; - if (calls.length === 0) return; + if (calls.length === 0) { + console.log("Estimated token usage by minute", getEstimatedTokenMinuteLog(firstIterationMinuteBucket)); + return; + } for (const call of calls) { let args = {}; From a751c1cce3d3b497abc0b1e756c28f230a9a2aae Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Tue, 7 Apr 2026 09:46:45 +0200 Subject: [PATCH 40/76] ifcchat: compaction --- src/ifcchat/app.js | 122 +++++++++++++++++++++++++++++++++++------ src/ifcchat/index.html | 4 ++ 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index bcb28ecf57..cf8723f46f 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -139,6 +139,7 @@ const baseUrlRowEl = $("baseUrlRow"); const baseUrlLabelEl = $("baseUrlLabel"); const baseUrlEl = $("baseUrl"); const thinkingIndicatorEl = $("thinkingIndicator"); +const compactingIndicatorEl = $("compactingIndicator"); const modelEl = $("model"); const providerEls = document.querySelectorAll('input[name="provider"]'); const ifcFileEl = $("ifcFile"); @@ -395,6 +396,7 @@ function addMessage(role, text) { function setStatus(text) { statusEl.textContent = text; thinkingIndicatorEl.hidden = text !== "Thinking…"; + compactingIndicatorEl.hidden = text !== "Compacting…"; msgsEl.scrollTop = msgsEl.scrollHeight; } @@ -491,6 +493,7 @@ Rules: - If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree. - If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit. - If there is no model and the user wants to create one, call ifc_new. +- In case of type errors on api functions, retry providing values as strings (for example in the case of the matrix in geometry.edit_object_placement). - After edits, explain what changed and suggest downloading the IFC. Be concise. Avoid dumping huge trees unless asked. `; @@ -500,7 +503,9 @@ let messages = []; // running conversation state (Chat Completions style) const MAX_TOOL_RESULT_CHARS = 0; const MAX_HISTORY_MESSAGES = 40; const ESTIMATED_CHARS_PER_TOKEN = 4; -const MAX_ESTIMATED_TOKENS_PER_MINUTE = 20000; +const MAX_ESTIMATED_TOKENS_PER_MINUTE = 24000; +const COMPACT_WHEN_ESTIMATED_TOKENS = 18000; +const KEEP_RAW_TURN_GROUPS = 1; const minuteTokenMap = new Map(); function truncateToolResult(text) { @@ -531,6 +536,93 @@ function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) { })); } +async function chatWithMinuteDelay({ chat, apiKey, baseURL, model, messages, tools }) { + const estimatedTokens = Math.max( + 1, + Math.ceil(JSON.stringify({ model, messages, ...(tools ? { tools } : {}) }).length / ESTIMATED_CHARS_PER_TOKEN) + ); + let currentMinuteBucket = Math.floor(Date.now() / 60000); + const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens; + + if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) { + currentMinuteBucket += 1; + await new Promise((resolve) => setTimeout(() => resolve(), 60000)); + } + + minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens); + + return { + minuteBucket: currentMinuteBucket, + response: await chat({ apiKey, baseURL, model, messages, tools }), + }; +} + +async function compactHistoryWithLLM(chat, apiKey, baseURL, model) { + const estimatedTokens = Math.max( + 1, + Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN) + ); + if (messages.length <= MAX_HISTORY_MESSAGES && estimatedTokens <= COMPACT_WHEN_ESTIMATED_TOKENS) return null; + + const { prefix, groups } = messages.reduce((acc, message) => { + if (message.role === "user") { + acc.groups.push([message]); + } else if (acc.groups.length) { + acc.groups[acc.groups.length - 1].push(message); + } else { + acc.prefix.push(message); + } + return acc; + }, { prefix: [], groups: [] }); + + if (groups.length <= KEEP_RAW_TURN_GROUPS) return null; + + const compacted = [...prefix, ...groups.slice(0, -KEEP_RAW_TURN_GROUPS).flat()]; + if (!compacted.length) return null; + + setStatus("Compacting…"); + try { + const before = { + message_count: messages.length, + turn_group_count: groups.length, + estimated_tokens: estimatedTokens, + }; + const { minuteBucket, response } = await chatWithMinuteDelay({ + chat, + apiKey, + baseURL, + model, + messages: [ + { + role: "system", + content: "Summarize older IFC chat context for continuation. Preserve user goals, model state and schema, edits already applied, important ids, names, selectors, and unresolved questions. Be concise, factual, and use short markdown bullets. Do not mention that this is a summary." + }, + { role: "user", content: JSON.stringify(compacted) }, + ], + }); + const summary = response.choices?.[0]?.message?.content?.trim(); + + if (!summary) return minuteBucket; + + messages = [ + { role: "assistant", content: `[Context summary]\n${summary}` }, + ...groups.slice(-KEEP_RAW_TURN_GROUPS).flat(), + ]; + console.log("History compaction before", before); + console.log("History compaction after", { + message_count: messages.length, + turn_group_count: messages.filter((message) => message.role === "user").length, + estimated_tokens: Math.max( + 1, + Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN) + ), + }); + return minuteBucket; + } finally { + setStatus("Thinking…"); + } +} + async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); @@ -541,32 +633,26 @@ async function runAgentTurn(userText) { let firstIterationMinuteBucket = null; messages.push({ role: "user", content: userText }); - trimHistory(); for (let i = 0; i < 64; i++) { + const compactedMinuteBucket = await compactHistoryWithLLM(chat, apiKey, baseURL, modelEl.value); + if (firstIterationMinuteBucket === null && compactedMinuteBucket !== null) { + firstIterationMinuteBucket = compactedMinuteBucket; + } + if (messages.length > MAX_HISTORY_MESSAGES * 2) trimHistory(); + const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]; - const estimatedTokens = Math.max(1, Math.ceil(JSON.stringify(messages_with_system).length / ESTIMATED_CHARS_PER_TOKEN)); - const now = Date.now(); - let currentMinuteBucket = Math.floor(now / 60000); - if (firstIterationMinuteBucket === null) { - firstIterationMinuteBucket = currentMinuteBucket; - } - const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens; - - if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) { - currentMinuteBucket += 1; - await new Promise((resolve) => setTimeout(() => resolve(), 60000)); - } - - minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens); - - const response = await chat({ + const { minuteBucket, response } = await chatWithMinuteDelay({ + chat, apiKey, baseURL, model: modelEl.value, messages: messages_with_system, tools, }); + if (firstIterationMinuteBucket === null) { + firstIterationMinuteBucket = minuteBucket; + } const message = response.choices?.[0]?.message; if (!message) throw new Error("No message in response"); diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index daf6146126..7ccb678591 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -111,6 +111,10 @@ thinking...
    +
    From 27d9cae8ff534922dc367b78970711bdbba6bd37 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Tue, 7 Apr 2026 17:59:33 +0100 Subject: [PATCH 41/76] Bonsai, bump ifcmerge.exe to working version with deps Don't leave a broken repo if ifcmerge is misinstalled. Fix bug where only local branches could be merged. Fix gitch where merge commits were not considered relevant. --- src/bonsai/Makefile | 2 +- src/bonsai/bonsai/core/ifcgit.py | 7 +++++-- src/bonsai/bonsai/tool/ifcgit.py | 24 +++++++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index b517bc572b..bd7bbbf597 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -64,7 +64,7 @@ PYNUMBER:=3$(PYMINOR) PYPI_VERSION:=3.$(PYMINOR) endif # def PYVERSION -IFCMERGE_VERSION:=2026-04-02 +IFCMERGE_VERSION:=2026-04-07 ifdef PLATFORM SUPPORTED_PLATFORMS := linux macos macosm1 win diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 289337908a..875a37f8c0 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -151,8 +151,11 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t conflicts = ifcgit.git_mergetool(mergetool, path_ifc) if conflicts is not None: ifcgit.git_merge_abort() - ifcgit.store_merge_conflicts(conflicts) - operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") + if conflicts: + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") + else: + operator.report({"ERROR"}, "Merge tool failed — check that ifcmerge is installed correctly") return False ifcgit.commit_merge(path_ifc) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index ae7176eb6f..9c7199e839 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -217,7 +217,7 @@ class IfcGit: rev=[props.display_branch], ) ) - commits_relevant = list( + commits_relevant = set( git.objects.commit.Commit.iter_items( repo=repo, rev=[props.display_branch], @@ -225,11 +225,17 @@ class IfcGit: ) ) + def is_relevant(commit): + if commit in commits_relevant: + return True + # Merge commits are relevant too + return len(commit.parents) > 1 and any(p in commits_relevant for p in commit.parents) + for commit in commits: if props.ifcgit_filter == "tagged" and commit.hexsha not in lookup: continue - elif props.ifcgit_filter == "relevant" and commit not in commits_relevant: + elif props.ifcgit_filter == "relevant" and not is_relevant(commit): continue props.ifcgit_commits.add() @@ -239,7 +245,7 @@ class IfcGit: 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: + if is_relevant(commit): list_item.relevant = True if commit.hexsha in lookup: for tag in lookup[commit.hexsha]: @@ -589,7 +595,7 @@ class IfcGit: """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] + branch = repo.refs[branch_name] try: repo.git.merge(branch) return None @@ -603,7 +609,7 @@ class IfcGit: """Attempt a git merge without committing (always leaves a merge state to abort). Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure.""" repo = IfcGitRepo.repo - branch = repo.branches[branch_name] + branch = repo.refs[branch_name] try: repo.git.merge(branch, no_commit=True, no_ff=True) return None @@ -619,8 +625,8 @@ class IfcGit: report_path = path_ifc + ".ifcmerge" try: repo.git.mergetool(tool=mergetool) - except git.exc.GitCommandError: - pass + except git.exc.GitCommandError as e: + print(f"ifcgit: mergetool failed: {e}") conflicts = None if os.path.exists(report_path): @@ -636,6 +642,10 @@ class IfcGit: os.remove(report_path) except OSError: pass + + if conflicts is None and repo.index.unmerged_blobs(): + conflicts = [] + return conflicts @classmethod From 26a1955cbab622eceda9f1bab33a1ef0e8098cb5 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Tue, 7 Apr 2026 23:34:30 +0100 Subject: [PATCH 42/76] Fix compilation failure introduced in 24acfea --- src/svgfill/src/arrange_polygons.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 3ce49011f7..0d5cfd96e3 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -618,7 +618,7 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS std::cerr << "processing: " << edge.first << " " << edge.second << std::endl; std::cerr << "area before: " << poly1->area() << " " << poly2->area() << std::endl; - bool is_ = edge == std::make_pair(25, 27); + bool is_ = edge == std::make_pair(25, 27); bool success = false; if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) { @@ -1550,7 +1550,7 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ } auto res = walk_pl.locate(best_point); - if (auto* v = boost::get(&res)) { + if (auto* v = variant_get(&res)) { if (visited_faces_on_right.count(*v) > 0) { return_values.push_back(0); } else { From 3fbf01f44676662a6f44bf7ed5301bd5b6fa37fb Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Wed, 8 Apr 2026 13:48:23 +0200 Subject: [PATCH 43/76] partial revert of 24acfea --- .../mapping/IfcPointByDistanceExpression.cpp | 9 - src/ifcopenshell-python/ifcopenshell/draw.py | 3 +- src/ifcparse/IfcSchema.h | 1 - src/ifcwrap/IfcGeomWrapper.i | 5 +- src/svgfill/src/arrange_polygons.cpp | 1038 ++++------------- src/svgfill/src/svgfill.cpp | 12 - src/svgfill/src/svgfill.h | 21 +- 7 files changed, 233 insertions(+), 856 deletions(-) diff --git a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp index f07234a8f1..180226fb82 100644 --- a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp +++ b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp @@ -52,15 +52,6 @@ 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()) { diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 962dbbb34f..5f6d761ceb 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -42,7 +42,6 @@ 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: @@ -537,7 +536,7 @@ def main( *(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i)) ) - arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies) + arranged = W.arrange_polygons(polies) svg_data_3 = W.polygons_to_svg(arranged, False) dom3 = parseString(svg_data_3) svg3 = dom3.childNodes[0] diff --git a/src/ifcparse/IfcSchema.h b/src/ifcparse/IfcSchema.h index 349a81532d..3dedd47a8e 100644 --- a/src/ifcparse/IfcSchema.h +++ b/src/ifcparse/IfcSchema.h @@ -358,7 +358,6 @@ class IFC_PARSE_API entity : public declaration { const std::vector& subtypes() const { return subtypes_; } const std::vector& attributes() const { return attributes_; } - const std::vector& inverse_attributes() const { return inverse_attributes_; } const std::vector& derived() const { return derived_; } const std::vector all_attributes() const { diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index e992e4beab..e155c2837e 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -1166,7 +1166,6 @@ 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>; %template(svg_groups_of_line_segments) std::vector>>; @@ -1288,9 +1287,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type } } - std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons) { + std::vector arrange_polygons(const std::vector& polygons) { std::vector r; - if (svgfill::arrange_polygons(settings, polygons, r)) { + if (svgfill::arrange_polygons(polygons, r)) { return r; } else { throw std::runtime_error("Failed to arrange polygons"); diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 0d5cfd96e3..4fa20c68dc 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -350,8 +350,6 @@ find_overlaps(const std::vector& polygons) { class DebugWriter { public: - DebugWriter() : enabled_(false) {} - DebugWriter(bool enabled, const std::string& filename_prefix) : enabled_(enabled) { if (enabled_) { @@ -370,43 +368,6 @@ class DebugWriter { } } - DebugWriter(const DebugWriter&) = delete; - - DebugWriter(DebugWriter&& other) noexcept - : obj(std::move(other.obj)), vi(other.vi), svg(std::move(other.svg)), enabled_(other.enabled_), last_segment_name_(std::move(other.last_segment_name_)) - { - other.enabled_ = false; - other.vi = 1; - other.last_segment_name_.clear(); - } - - DebugWriter& operator=(const DebugWriter&) = delete; - - DebugWriter& operator=(DebugWriter&& other) noexcept { - if (this == &other) { - return *this; - } - - if (enabled_) { - svg << "\n"; - obj << std::flush; - obj.close(); - svg.close(); - } - - obj = std::move(other.obj); - svg = std::move(other.svg); - vi = other.vi; - enabled_ = other.enabled_; - last_segment_name_ = std::move(other.last_segment_name_); - - other.enabled_ = false; - other.vi = 1; - other.last_segment_name_.clear(); - - return *this; - } - void write_polygon(const Polygon_2& polygon, const std::string& name) { if (enabled_) { write_polygon_to_obj_(obj, vi, true, polygon, name); @@ -426,7 +387,7 @@ class DebugWriter { obj << "l " << vi++; obj << " " << vi++ << "\n"; - svg << "\n"; + svg << ""; obj << std::flush; } @@ -507,7 +468,7 @@ class DebugWriter { } }; -void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { +void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { // solve overlaps by means of subtraction // loop over overlaps and subtract the smaller polygon from the larger one @@ -615,40 +576,11 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS std::swap(poly1, poly2); } - std::cerr << "processing: " << edge.first << " " << edge.second << std::endl; - std::cerr << "area before: " << poly1->area() << " " << poly2->area() << std::endl; - - bool is_ = edge == std::make_pair(25, 27); - bool success = false; if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) { - if (is_) { - debug_writer.write_polygon(*mp1, "mp1"); - } - smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp1); - if (is_) { - debug_writer.write_polygon(*mp1, "mp1b"); - } if ((mp2 = subtract_retain_largest(*poly1, *mp1))) { - if (is_) { - debug_writer.write_polygon(*mp2, "mp2"); - } - smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp2); - if (is_) { - debug_writer.write_polygon(*mp2, "mp2b"); - } if ((mp3 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE * 2, *mp2)))) { - if (is_) { - debug_writer.write_polygon(*mp3, "mp3"); - } - smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp3); - if (is_) { - debug_writer.write_polygon(*mp3, "mp3b"); - } if ((mp4 = subtract_retain_largest(*poly2, *mp3))) { - if (is_) { - debug_writer.write_polygon(*mp4, "mp4"); - } *poly1 = *mp2; *poly2 = *mp4; success = true; @@ -657,8 +589,6 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS } } - std::cerr << "area after: " << poly1->area() << " " << poly2->area() << std::endl; - if (!success) { eliminated_polies.insert(swap ? edge.first : edge.second); continue; @@ -847,19 +777,14 @@ Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_h std::tuple< std::map>, std::map>, - std::map, std::vector*>>, - std::map -> -build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) -{ - + std::map, std::vector*>>> +build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) { // Build maps of triangle -> edge and edge -> triangle in order to do traversal on the 'corridor mesh' std::map, std::vector*>> segment_to_facet; std::map, std::vector*>> segment_to_input_facet; std::map, Point_2> segment_to_midpoint; std::map> midpoint_to_segment; std::map*, std::vector>> facet_to_segment; - std::map midpoint_to_edge_length; for (auto& tri : triangular_polygons) { for (size_t i = 0; i < 3; ++i) { @@ -888,7 +813,6 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se if (p1index != input_polygons.end() && p2index != input_polygons.end() && p1index != p2index) { segment_to_midpoint[p.first] = center; midpoint_to_segment[center] = p.first; - midpoint_to_edge_length[center] = std::sqrt(CGAL::to_double(CGAL::squared_distance(p.first.first, p.first.second))); } } @@ -908,7 +832,7 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se } } - return {line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length}; + return {line_graph, midpoint_to_segment, segment_to_input_facet}; } std::set> find_triangles(const std::map>& line_graph) { @@ -1194,91 +1118,66 @@ std::list> extend_end_vertices_based_on_input( const Graph2D& G, const std::map>& midpoint_to_segment, const std::map, std::vector*>>& segment_to_input_facet, - const Polygon_list& outer_perimiter, - const SegmentLookup& segment_lookup, - const K::FT& max_projection_distance + const Polygon_list& inner_offset, + const SegmentLookup& segment_lookup ){ std::list> constructed_segments; - std::set processed_vertices; + for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { + if (it->second.size() == 1) { + auto& M = it->first; - while (true) { - // The idea was to peal off 1-degree vertices when projecting them did not result into - // nearby intersections with the outer perimiter. This in case there would be turns near - // the perimeter, which would be eliminated by pealing off the vertices, which would then - // require out of the loop because of invalidated iterators. For now we decided to stick - // to a projection of the vertex onto the perimeter segment when the projection distance - // exceeds a threshold. - bool broke_out = false; + const std::pair* q = nullptr; - for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { - if (it->second.size() == 1) { - auto& M = it->first; - - if (processed_vertices.find(M) != processed_vertices.end()) { - continue; - } - - const std::pair* q = nullptr; - - if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { - typename K::FT min_sq_distance = std::numeric_limits::infinity(); - for (auto& pa : midpoint_to_segment) { - if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { - q = &pa.second; - min_sq_distance = CGAL::squared_distance(pa.first, M); - } + if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { + typename K::FT min_sq_distance = std::numeric_limits::infinity(); + for (auto& pa : midpoint_to_segment) { + if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { + q = &pa.second; + min_sq_distance = CGAL::squared_distance(pa.first, M); } - } else { - q = &midpoint_to_segment.find(M)->second; } + } else { + q = &midpoint_to_segment.find(M)->second; + } - if (q == nullptr) { - continue; - } + if (q == nullptr) { + continue; + } - bool handled_as_graph_path = false; + bool handled_as_graph_path = false; - // distance from unioned - shoot ray? - if (segment_to_input_facet.find(*q)->second.size() == 2) { - for (auto& bnd : outer_perimiter) { - // if point M is contained in bnd interior: - // if (!bnd.has_on_unbounded_side(M)) { - if (bnd.has_on_bounded_side(M)) { - auto& incoming = *it->second.begin(); - // create ray incoming -> M - CGAL::Ray_2 ray(incoming, M - incoming); - - std::cerr << "Extending end vertex " << M << " along ray " << ray << " to boundary of input polygon" << std::endl; - - // intersect ray with boundary - boost::optional> closest_segment; - boost::optional> closest_intersection_point; - K::FT sq_distance_along_ray = std::numeric_limits::infinity(); - for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { - const auto& seg = *jt; - auto x = CGAL::intersection(ray, seg); - if (x) { - if (auto* xp = variant_get>(&*x)) { - auto dist = ((*xp) - M).squared_length(); - std::cerr << " - found " << *xp << " on segment " << seg << " with distance " << std::sqrt(CGAL::to_double(dist)) << std::endl; - if (dist < sq_distance_along_ray) { - if (dist < (max_projection_distance * max_projection_distance)) { - closest_segment = seg; - closest_intersection_point = *xp; - sq_distance_along_ray = dist; - } else { - - } - } + // distance from unioned - shoot ray? + if (segment_to_input_facet.find(*q)->second.size() == 2) { + for (auto& bnd : inner_offset) { + // if point M is contained in bnd interior: + // if (!bnd.has_on_unbounded_side(M)) { + if (bnd.has_on_bounded_side(M)) { + auto& incoming = *it->second.begin(); + // create ray incoming -> M + CGAL::Ray_2 ray(incoming, M - incoming); + // intersect ray with boundary + boost::optional> closest_segment; + boost::optional> closest_intersection_point; + K::FT sq_distance_along_ray = std::numeric_limits::infinity(); + for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { + const auto& seg = *jt; + auto x = CGAL::intersection(ray, seg); + if (x) { + if (auto* xp = variant_get>(&*x)) { + auto dist = ((*xp) - M).squared_length(); + if (dist < sq_distance_along_ray) { + closest_segment = seg; + closest_intersection_point = *xp; + sq_distance_along_ray = dist; } } } + } - if (closest_intersection_point) { - constructed_segments.push_front({M, *closest_intersection_point}); - processed_vertices.insert(M); - break; + if (closest_intersection_point) { + constructed_segments.push_front({M, *closest_intersection_point}); + break; #if 0 Graph2D GGG(bnd); GGG.refine(*GGG.query(*closest_intersection_point, 0.01), *closest_intersection_point); @@ -1318,33 +1217,12 @@ std::list> extend_end_vertices_based_on_input( break; } #endif - } else { - - // Loop over boundary segments, and project point onto it, take the closest - K::FT closest_distance = std::numeric_limits::infinity(); - boost::optional> closest_point; - for (auto& poly : outer_perimiter) { - for (auto jt = poly.edges_begin(); jt != poly.edges_end(); ++jt) { - auto seg = *jt; - auto Pp = seg.supporting_line().projection(M); - if (seg.has_on(Pp)) { - auto d = CGAL::squared_distance(Pp, M); - if (d < closest_distance) { - closest_distance = d; - closest_point = Pp; - } - } - } - } - - if (closest_point) { - constructed_segments.push_front({M, *closest_point}); - processed_vertices.insert(M); - } - } + } else { + std::cerr << "Warning: no intersection found when extending end vertex, this will likely result in invalid topology" << std::endl; } } } + } #if 0 if (!handled_as_graph_path) { @@ -1389,11 +1267,6 @@ std::list> extend_end_vertices_based_on_input( constructed_segments.push_front({avg, R}); } #endif - } - } - - if (!broke_out) { - break; } } @@ -1482,6 +1355,8 @@ void fuse_corridor_halves_with_input(Arrangement_2& arr, Graph2D& G, SegmentL } } +#include + class Segment_2_less { public: bool operator()(const Segment_2& a, const Segment_2& b) const { @@ -1492,112 +1367,7 @@ class Segment_2_less { } }; -std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& right) { - - using Walk_pl = CGAL::Arr_walk_along_line_point_location; - Walk_pl walk_pl(right); - - std::set visited_faces_on_right; - - std::vector return_values; - - for (auto it = left.faces_begin(); it != left.faces_end(); ++it) { - if (!it->is_unbounded()) { - // convert arr facet to polygon with holes - auto polygon_exterior = circ_to_poly(it->outer_ccb()); - Polygon_with_holes_2 pwh(polygon_exterior); - for (auto hit = it->inner_ccbs_begin(); hit != it->inner_ccbs_end(); ++hit) { - pwh.add_hole(circ_to_poly(*hit)); - } - - CGAL::Polygon_triangulation_decomposition_2 decompositor; - std::vector temp; - decompositor(pwh, std::back_inserter(temp)); - - std::set visited_points; - - while (true) { - // select triangle edge that has largest squared edge length times distance from polygon exterior - K::FT max_score = -std::numeric_limits::infinity(); - Point_2 best_point; - for (auto& tri : temp) { - for (size_t i = 0; i < 3; ++i) { - size_t j = (i + 1) % 3; - auto& pi = tri.vertex(i); - auto& pj = tri.vertex(j); - - auto center_point = CGAL::ORIGIN + (((pi - CGAL::ORIGIN) + (pj - CGAL::ORIGIN)) / 2); - - K::FT min_dist = std::numeric_limits::infinity(); - for (auto eit = polygon_exterior.edges_begin(); eit != polygon_exterior.edges_end(); ++eit) { - auto ep = eit->source(); - auto eq = eit->target(); - Segment_2 seg(ep, eq); - auto dist = CGAL::squared_distance(center_point, seg); - if (dist < min_dist) { - min_dist = dist; - } - } - - auto sq_length = CGAL::squared_distance(pi, pj); - - auto score = sq_length * min_dist; - if (score > max_score && visited_points.count(center_point) == 0) { - max_score = score; - best_point = center_point; - } - } - } - - auto res = walk_pl.locate(best_point); - if (auto* v = variant_get(&res)) { - if (visited_faces_on_right.count(*v) > 0) { - return_values.push_back(0); - } else { - // convert arr facet to polygon with holes - auto polygon_exterior = circ_to_poly((*v)->outer_ccb()); - Polygon_with_holes_2 pwh_right(polygon_exterior); - for (auto hit = (*v)->inner_ccbs_begin(); hit != (*v)->inner_ccbs_end(); ++hit) { - pwh_right.add_hole(circ_to_poly(*hit)); - } - - // compute intersection over union of pwh and the original polygon - if (CGAL::do_intersect(pwh, pwh_right)) { - std::vector result; - CGAL::intersection(pwh, pwh_right, std::back_inserter(result)); - typename K::FT intersection_area = 0; - for (auto& r : result) { - auto poly_area = r.outer_boundary().area(); - for (auto& h : r.holes()) { - poly_area -= h.area(); - } - intersection_area += poly_area; - } - CGAL::Polygon_with_holes_2 poly12; - CGAL::join(pwh, pwh_right, poly12); - typename K::FT union_area = poly12.outer_boundary().area(); - for (auto& h : poly12.holes()) { - union_area -= h.area(); - } - return_values.push_back(intersection_area / union_area); - } else { - return_values.push_back(0); - } - } - visited_faces_on_right.insert(*v); - break; - } else { - // Not in facet on right, retry another point - continue; - } - } - } - } - - return return_values; -} - -void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) { +void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { using SK = CGAL::Simple_cartesian; CGAL::Cartesian_converter C{}; @@ -1648,17 +1418,15 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo auto [dv, dl] = get_dir(s); best = std::min(best, angle(dv)); } - return (best + 0.01) / own_length; + return (best + 0.1) / own_length; }; - std::cerr << "badnesses:"; std::map badnesses; for (auto& e : edges) { badnesses[e] = edge_badness(e); - std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses[e] << ";"; } - std::cerr << std::endl; + double thr; { std::vector tmp; tmp.reserve(badnesses.size()); @@ -1667,14 +1435,12 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo } std::nth_element(tmp.begin(), tmp.begin() + tmp.size() / 2, tmp.end()); double med = tmp[tmp.size() / 2]; - threshold = 4.0 * med; + thr = 10.0 * med; } - std::cerr << "badness threshold: " << threshold << std::endl; - std::set bad_edges; for (auto& p : badnesses) { - if (p.second > threshold) { + if (p.second > thr) { bad_edges.insert(p.first); } } @@ -1802,57 +1568,10 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo return best_x; }; - auto process_modifications = [&]( - Arrangement_2& arr_, - const std::set>& to_remove_, - const std::vector>& to_insert_) { - for (auto& e : to_remove_) { - bool removed = false; - for (auto he = arr_.edges_begin(); he != arr_.edges_end(); ++he) { - auto a = he->source()->point(); - auto b = he->target()->point(); - if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { - CGAL::remove_edge(arr_, he); - removed = true; - break; - } - } - if (!removed) { - std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; - } - } - - for (auto& pq : to_insert_) { - if (pq.first == pq.second) { - continue; - } - CGAL::insert(arr_, Segment_2(pq.first, pq.second)); - } - }; - - size_t path_index = 0; for (auto& path : bad_paths) { - decltype(to_remove) to_remove_this_path; - decltype(to_insert) to_insert_this_path; - - std::cerr << "Processing bad path:"; - for (size_t i = 0; i < path.size() - 1; ++i) { - auto& a = path[i]; - auto& b = path[i + 1]; - std::cerr << " (" << a.x() << "," << a.y() << ") - (" << b.x() << "," << b.y() << ");"; - } - std::cerr << std::endl; - - for (size_t i = 0; i < path.size() - 1; ++i) { - auto& a = path[i]; - auto& b = path[i + 1]; - - debug_output.write_segment(a, b, "arr_bad_path path_nr_" + std::to_string(path_index)); - } - auto x = collapse_path(path); if (!x) { - std::cerr << "Unable to collapse path, skipping" << std::endl; + // std::cerr << "Unable to collapse path, skipping" << std::endl; continue; } @@ -1866,7 +1585,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo double new_length = std::sqrt(CGAL::to_double((path.front() - *x).squared_length())) + std::sqrt(CGAL::to_double((path.back() - *x).squared_length())); if (new_length > orig_length * 2 || orig_length > new_length * 2) { - std::cerr << "Collapsing path would increase length too much, skipping" << std::endl; + // std::cerr << "Collapsing path would increase length too much, skipping" << std::endl; continue; } @@ -1885,326 +1604,75 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo auto& b = path[i + 1]; if (a < b) { to_remove.insert({a, b}); - to_remove_this_path.insert({a, b}); } else { to_remove.insert({b, a}); - to_remove_this_path.insert({b, a}); } } auto s = path.front(); auto t = path.back(); if (s != *x) { to_insert.push_back({s, *x}); - to_insert_this_path.push_back({s, *x}); - - debug_output.write_segment(s, *x, "corrected_path path_nr_" + std::to_string(path_index)); } if (t != *x) { to_insert.push_back({t, *x}); - to_insert_this_path.push_back({t, *x}); - - debug_output.write_segment(t, *x, "corrected_path path_nr_" + std::to_string(path_index)); } - - path_index += 1; - -#if 1 - process_modifications(arr, to_remove_this_path, to_insert_this_path); -#else - auto arr_copy = arr; - process_modifications(arr_copy, to_remove_this_path, to_insert_this_path); - auto ious = arrangement_cell_iou(arr, arr_copy); - for (auto& iou : ious) { - std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl; - } - std::swap(arr_copy, arr); -#endif } - process_modifications(arr, to_remove, to_insert); -} + /* + using Walk_pl = CGAL::Arr_walk_along_line_point_location; + Walk_pl walk_pl(arr); -template -void next_circular(typename Vec::const_iterator& it, const Vec& vec) { - std::advance(it, 1); - if (it == vec.end()) { - it = vec.begin(); - } -} + for (auto& e : to_remove) { + // debug_output.write_segment(e->source()->point(), e->target()->point(), "arr_bad_remove"); + auto res = walk_pl.locate(e.first); + if (auto* v = boost::get(&res)) { + Arrangement_2::Halfedge_around_vertex_circulator first, curr; + first = curr = (*v)->incident_halfedges(); + size_t i = 0; + std::array pts; + std::array hes; + do { + Arrangement_2::Vertex_const_handle u = curr->source(); + hes[i] = curr; + pts[i++] = u->point(); + } while (++curr != first); -template -void previous_circular(typename Vec::const_iterator& it, const Vec& vec) { - if (it == vec.begin()) { - it = vec.end(); - } - std::advance(it, -1); -} -template -std::size_t circular_distance(typename Vec::const_iterator first, - typename Vec::const_iterator last, - const Vec& vec) { - if (first <= last) { - return static_cast(last - first); - } - return static_cast(vec.end() - first) + static_cast(last - vec.begin()); -} - -template -std::pair -longest_wrapping_true_run(const Vec& v, Pred pred) { - using It = typename Vec::const_iterator; - - const auto n = v.size(); - if (n == 0) { - return {v.end(), v.end()}; - } - - // Find best non-wrapping run - std::size_t best_len = 0; - std::size_t best_start = 0; - - std::size_t curr_len = 0; - std::size_t curr_start = 0; - - for (std::size_t i = 0; i < n; ++i) { - if (pred(v[i])) { - if (curr_len == 0) { - curr_start = i; - } - ++curr_len; - if (curr_len > best_len) { - best_len = curr_len; - best_start = curr_start; + if ((*v)->point() != e.first) { + std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; + continue; } } else { - curr_len = 0; + std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; + continue; + } + } + */ + + for (auto& e : to_remove) { + bool removed = false; + for (auto he = arr.edges_begin(); he != arr.edges_end(); ++he) { + auto a = he->source()->point(); + auto b = he->target()->point(); + if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { + CGAL::remove_edge(arr, he); + removed = true; + break; + } + } + if (!removed) { + std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; } } - // Count leading true - std::size_t leading = 0; - while (leading < n && pred(v[leading])) { - ++leading; - } - - // All true - if (leading == n) { - return {v.begin(), v.end()}; - } - - // Count trailing true - std::size_t trailing = 0; - while (trailing < n && pred(v[n - 1 - trailing])) { - ++trailing; - } - - // Wrapped run = [n - trailing, n) + [0, leading) - const std::size_t wrapped_len = leading + trailing; - - if (wrapped_len > best_len) { - It first = v.begin() + static_cast(n - trailing); - It last = v.begin() + static_cast(leading); - return {first, last}; - } - - It first = v.begin() + static_cast(best_start); - It last = first + static_cast(best_len); - return {first, last}; -} - -void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double threshold) { - using SK = CGAL::Simple_cartesian; - CGAL::Cartesian_converter C{}; - - auto other = [](const Segment_2& e, const Point_2& v) { - return (e.source() == v) ? e.target() : e.source(); - }; - - auto edge_badness = [&](const Segment_2& e) -> double { - auto closest = segment_lookup.n_closest_input_segments(e, 2); - if (closest.size() != 2) { - throw std::runtime_error("Unable to locate two nearby edges"); - } - - auto get_dir = [&](const Segment_2& s) { - auto a = C(s.source()); - auto b = C(s.target()); - SK::Vector_2 v = b - a; - double l = std::sqrt(v.squared_length()); - if (l <= 1e-12) { - return std::make_pair(SK::Vector_2(0, 0), 0.); - } - return std::make_pair(v / l, l); - }; - - auto [own_dir, own_length] = get_dir(e); - - auto angle = [&](const SK::Vector_2& ov) { - double d = std::abs(own_dir * ov); - if (d > 1.0) { - d = 1.0; - } - return std::acos(d); - }; - - double best = std::numeric_limits::infinity(); - for (auto& s : closest) { - auto [dv, dl] = get_dir(s); - best = std::min(best, angle(dv)); - } - return (best + 0.01) / own_length; - }; - - size_t facet_index = 0; - for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it, ++facet_index) { - std::cout << "facet_index " << facet_index << std::endl; - if (!it->is_unbounded()) { - std::set> to_remove; - std::vector> to_insert; - - std::vector segs; - std::vector vertices; - std::vector halfedges; - - auto circ = it->outer_ccb(); - do { - auto a = circ->source()->point(); - auto b = circ->target()->point(); - segs.emplace_back(a, b); - vertices.push_back(circ->source()); - halfedges.push_back(circ); - ++circ; - } while (circ != it->outer_ccb()); - - std::cerr << "badnesses:"; - std::vector badnesses; - for (auto& e : segs) { - badnesses.push_back(edge_badness(e)); - std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses.back() << ";"; - } - std::cerr << std::endl; - - auto bit = std::min_element(badnesses.begin(), badnesses.end()); - if (*bit > threshold) { - std::cerr << "All edges are good, skipping" << std::endl; - continue; - } - - auto it_pair = longest_wrapping_true_run(badnesses, [&](double d) { return d > threshold; }); - auto N = circular_distance(it_pair.first, it_pair.second, badnesses); - - if (N == 0) { - std::cerr << "Unable to find run of bad edges, skipping" << std::endl; - continue; - } - - std::vector> incoming_paths; - - std::cout << "range " << std::distance(badnesses.cbegin(), it_pair.first) << " to " << std::distance(badnesses.cbegin(), it_pair.second) << " length " << N << std::endl; - - auto jt = it_pair.first; - for (std::size_t k = 0; k < N; ++k, next_circular(jt, badnesses)) { - - std::cout << " at " << std::distance(badnesses.cbegin(), jt) << " badness: " << *jt << std::endl; - - auto he = halfedges[std::distance(badnesses.cbegin(), jt)]; - to_remove.insert({he->source()->point(), he->target()->point()}); - debug_output.write_segment(he->source()->point(), he->target()->point(), "arr_bad_bound facet_" + std::to_string(facet_index)); - - Arrangement_2::Vertex_handle v = he->source(); - - // circle around other edges onto v - Arrangement_2::Halfedge_around_vertex_circulator first, curr; - first = curr = v->incident_halfedges(); - do { - Arrangement_2::Vertex_handle u = curr->source(); - if (curr->face() != it && curr->twin()->face() != it) { - - // loop until we find a 3-degree vertex, or we come back to the start - std::vector path{v->point(), u->point()}; - auto he = curr; - - while (u->degree() == 2 && u != v && path.size() < 10) { - std::vector hes; - - { - Arrangement_2::Halfedge_around_vertex_circulator first, curr; - first = curr = u->incident_halfedges(); - do { - hes.push_back(curr); - curr++; - } while (curr != first); - } - - auto next_he = hes.front() != he && hes.front() != he->twin() ? hes.front() : hes.back(); - auto next_v = next_he->target() != u ? next_he->target() : next_he->source(); - - path.push_back(next_v->point()); - u = next_v; - } - incoming_paths.push_back(std::move(path)); - } - } while (++curr != first); - } - - const std::size_t start = - static_cast(std::distance(badnesses.cbegin(), it_pair.first)); - - auto n = badnesses.size(); - - auto wrap = [n](std::ptrdiff_t i) -> std::size_t { - i %= static_cast(n); - if (i < 0) { - i += static_cast(n); - } - return static_cast(i); - }; - - const std::size_t ib = start; - const std::size_t ia = wrap(static_cast(start) - 1); - const std::size_t ic = wrap(static_cast(start + N)); - const std::size_t id = wrap(static_cast(start + N + 1)); - - auto a = vertices.begin() + static_cast(ia); - auto b = vertices.begin() + static_cast(ib); - auto c = vertices.begin() + static_cast(ic); - auto d = vertices.begin() + static_cast(id); - - CGAL::Ray_2 r1((*a)->point(), (*b)->point()); - CGAL::Ray_2 r2((*d)->point(), (*c)->point()); - - std::cout << "a: (" << (*a)->point().x() << "," << (*a)->point().y() << ") b: (" << (*b)->point().x() << "," << (*b)->point().y() << ") c: (" << (*c)->point().x() << "," << (*c)->point().y() << ") d: (" << (*d)->point().x() << "," << (*d)->point().y() << ")" << std::endl; - - auto x = CGAL::intersection(r1, r2); - if (x) { - if (auto* xp = variant_get>(&*x)) { - std::cout << "ray xp: (" << xp->x() << "," << xp->y() << ")" << std::endl; - to_insert.emplace_back((*b)->point(), *xp); - to_insert.emplace_back((*c)->point(), *xp); - - debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); - debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); - } - } else { - CGAL::Line_2 r1((*a)->point(), (*b)->point()); - CGAL::Line_2 r2((*d)->point(), (*c)->point()); - - auto x = CGAL::intersection(r1, r2); - if (x) { - if (auto* xp = variant_get>(&*x)) { - std::cout << "line xp: (" << xp->x() << "," << xp->y() << ")" << std::endl; - to_insert.emplace_back((*b)->point(), *xp); - to_insert.emplace_back((*c)->point(), *xp); - - debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); - debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); - } - } - } + for (auto& pq : to_insert) { + if (pq.first == pq.second) { + continue; } + CGAL::insert(arr, Segment_2(pq.first, pq.second)); + // debug_output.write_segment(pq.first, pq.second, "arr_bad_insert"); } + } void remove_colinear_vertices(Arrangement_2& arr) { @@ -2257,31 +1725,20 @@ class timer { public: class entry { public: - entry() {} - entry(std::map::const_iterator start_it) : start_it(start_it) {} - void stop() { - if (start_it) { - auto end = std::chrono::high_resolution_clock::now(); - auto duration = std::chrono::duration(end - start_it.value()->second).count(); - std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl; - } + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration(end - start_it->second).count(); + std::cerr << "Timing for " << start_it->first << ": " << duration << " ms" << std::endl; } private: - std::optional::const_iterator> start_it; + std::map::const_iterator start_it; }; - timer(bool enabled = true) : enabled_(enabled) {} - entry start(const std::string& name) { - if (enabled_) { - return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); - } else { - return entry(); - } + return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); } private: @@ -2289,30 +1746,27 @@ class timer { std::string, std::chrono::high_resolution_clock::time_point> timings_; - - bool enabled_; }; -void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { +void arrange_cgal_polygons(const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1; // even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied // no, `1.e-2 + 1.e-5` creates issues with the outer perimeter, are there other tolerances in play? static const double OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT = 1.e-5; - DebugWriter debug_output; - if (settings.debug_output) { - auto t = std::time(nullptr); - auto tm = *std::localtime(&t); +#ifdef SVGFILL_DEBUG + auto t = std::time(nullptr); + auto tm = *std::localtime(&t); - std::ostringstream oss; - oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); - auto now = oss.str(); - debug_output = DebugWriter(true, now); - } else { - debug_output = DebugWriter(false, ""); - } + std::ostringstream oss; + oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); + auto now = oss.str(); + DebugWriter debug_output(true, now); +#else + DebugWriter debug_output(false, ""); +#endif - timer timer(settings.debug_output); + timer timer; auto t0 = timer.start("input"); @@ -2340,7 +1794,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std t0.stop(); t0 = timer.start("overlap elimination"); - eliminate_overlaps(debug_output, OVERLAP_RESOLUTION_DISTANCE, input_polygons); + eliminate_overlaps(OVERLAP_RESOLUTION_DISTANCE, input_polygons); t0.stop(); @@ -2359,80 +1813,80 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std debug_output.write_polygons(input_polygons, "processed_input"); - std::vector outer_perimiter; - if (settings.outer_perimiter_algo == 0) { - t0 = timer.start("outer perimeter"); +#if 1 + t0 = timer.start("outer perimeter"); - // Find the outer perimeter using offset - union - negative offset - std::vector offset_polygons; - for (auto& r : input_polygons) { - auto R = r; - if (!R.is_counterclockwise_oriented()) { - R.reverse_orientation(); - } - - // Overlap removal can also result in close points causing problems when converted into non-exact nt - remove_close_points(R); - - auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); - for (auto& p : ps) { - if (!p.is_simple()) { - throw std::runtime_error("Complex polygon originated from offset"); - } - } - offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); + // Find the outer perimeter using offset - union - negative offset + std::vector offset_polygons; + for (auto& r : input_polygons) { + auto R = r; + if (!R.is_counterclockwise_oriented()) { + R.reverse_orientation(); } - debug_output.write_polygons(offset_polygons, "offset_input"); + // Overlap removal can also result in close points causing problems when converted into non-exact nt + remove_close_points(R); - // Perform Boolean union on the offset polygons - std::vector unioned_polygons; - CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); - - if (unioned_polygons.size() > 1) { - // @todo this is currently one of the major limitations in the code that still can be eliminated - // by grouping the input polygons by their perimiter polygon in unioned_polygons - std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); - } - - debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); - - Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); - remove_close_points(fused_removed_close_points, 1.e-4); - - // Apply negative offset to get the outer perimeter polygon - outer_perimiter = create_and_convert_offset_polygon( - // Because polygon_offset is inexact, make sure our inset distance is slightly larger - // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), - - // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter - -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, - fused_removed_close_points); - - debug_output.write_polygons(outer_perimiter, "outer_perimiter"); - } else { - std::map> neighbour_map; - build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); - - auto start_vertex = neighbour_map.rbegin()->first; - auto next_vertex = neighbour_map.rbegin()->second.front(); - - std::vector cycle = {start_vertex, next_vertex}; - while (cycle.back() != cycle.front()) { - const auto& incoming_from = *(cycle.rbegin() + 1); - const auto& nb = neighbour_map[cycle.back()]; - auto it = std::find(nb.begin(), nb.end(), incoming_from); - // cycle it -1 around nb - if (it == nb.begin()) { - it = nb.end() - 1; - } else { - --it; + auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); + for (auto& p : ps) { + if (!p.is_simple()) { + throw std::runtime_error("Complex polygon originated from offset"); } - cycle.push_back(*it); } - outer_perimiter.emplace_back(cycle.begin(), cycle.end()); + offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); } + debug_output.write_polygons(offset_polygons, "offset_input"); + + // Perform Boolean union on the offset polygons + std::vector unioned_polygons; + CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); + + if (unioned_polygons.size() > 1) { + // @todo this is currently one of the major limitations in the code that still can be eliminated + // by grouping the input polygons by their perimiter polygon in unioned_polygons + std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); + } + + debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); + + Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); + remove_close_points(fused_removed_close_points, 1.e-4); + + // Apply negative offset to get the outer perimeter polygon + auto outer_perimiter = create_and_convert_offset_polygon( + // Because polygon_offset is inexact, make sure our inset distance is slightly larger + // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), + + // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter + -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, + fused_removed_close_points); + + debug_output.write_polygons(outer_perimiter, "outer_perimiter"); +#else + std::map> neighbour_map; + build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); + + auto start_vertex = neighbour_map.rbegin()->first; + auto next_vertex = neighbour_map.rbegin()->second.front(); + + std::vector cycle = {start_vertex, next_vertex}; + while (cycle.back() != cycle.front()) { + const auto& incoming_from = *(cycle.rbegin() + 1); + const auto& nb = neighbour_map[cycle.back()]; + auto it = std::find(nb.begin(), nb.end(), incoming_from); + // cycle it -1 around nb + if (it == nb.begin()) { + it == nb.end() - 1; + } else { + --it; + } + cycle.push_back(*it); + } + std::vector outer_perimiter; + outer_perimiter.emplace_back(cycle.begin(), cycle.end()); +#endif + t0.stop(); t0 = timer.start("corridor creation"); @@ -2457,10 +1911,8 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std // subdivide difference_result to have better more detailed triangulation and therefore less-pronounced artefacts in midpoint network - auto subdivision_length = polygon_offset_distance / settings.subdivision_factor; - for (auto& pwh : difference_result) { - difference_result_subdivided.push_back(subdivide_polygon(subdivision_length, pwh)); + difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 8., pwh)); // difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 64., pwh)); } @@ -2488,43 +1940,13 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std SegmentLookup segment_lookup(input_polygons); - auto [line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); + auto [line_graph, midpoint_to_segment, segment_to_input_facet] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); for (auto& p : line_graph) { for (auto& q : p.second) { debug_output.write_segment(p.first, q, "network_1"); } } - // Write a JSON structure with center line topology with the midpoint_to_edge_length as per-point data - { - std::ofstream ofs("center_line_topology.json"); - ofs << "{\n"; - ofs << " \"vertices\": [\n"; - bool first_vertex = true; - for (auto& p : line_graph) { - if (!first_vertex) { - ofs << ",\n"; - } - first_vertex = false; - ofs << " {\n"; - ofs << " \"point\": [" << p.first.x() << ", " << p.first.y() << "],\n"; - ofs << " \"width\":" << midpoint_to_edge_length.find(p.first)->second << ",\n"; - ofs << " \"connected_to\": [\n"; - bool first_connected = true; - for (auto& q : p.second) { - if (!first_connected) { - ofs << ",\n"; - } - first_connected = false; - ofs << " [" << q.x() << ", " << q.y() << "]"; - } - ofs << "\n ]\n"; - ofs << " }"; - } - ofs << "\n ]\n"; - ofs << "}\n"; - } - t0.stop(); t0 = timer.start("center line cleaning"); @@ -2559,7 +1981,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std t0 = timer.start("topology"); - auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4); + auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup); // Now plot the edges on an arrangement in order to find planar cycles // and merge the corridor-halves with their neighbouring input polygon @@ -2575,31 +1997,31 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std debug_output.write_segment(pq.first, pq.second, "extended_segments"); } - if (settings.topology_reconstruction_algo != 0) { - // Write input polygons to arrangement_2 - // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter - for (auto& poly : input_polygons) { - for (size_t i = 0; i != poly.size(); ++i) { - auto j = (i + 1) % poly.size(); - if (poly.vertex(i) == poly.vertex(j)) { - continue; - } - CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); - } - } - } else { - // Write outer perimeter to arrangement_2 - for (auto& p : outer_perimiter) { - for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { - auto source = it->source(); - auto target = it->target(); - if (source == target) { - continue; - } - CGAL::insert(arr, Segment_2(source, target)); +#if 0 + // Write input polygons to arrangement_2 + // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter + for (auto& poly : input_polygons) { + for (size_t i = 0; i != poly.size(); ++i) { + auto j = (i + 1) % poly.size(); + if (poly.vertex(i) == poly.vertex(j)) { + continue; } + CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); } } +#else + // Write outer perimeter to arrangement_2 + for (auto& p : outer_perimiter) { + for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source = it->source(); + auto target = it->target(); + if (source == target) { + continue; + } + CGAL::insert(arr, Segment_2(source, target)); + } + } +#endif // Just for the automatic numbering, create a full vector std::vector temp; @@ -2625,17 +2047,12 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std // corridor network we know it needs to be joined with an input polygon. In that // case the edges need to be eliminated that correspond to original geometry. - if (settings.topology_reconstruction_algo != 0) { - fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); - } - - if (settings.perform_cleanup) { - remove_colinear_vertices(arr); - double threshold; - clean_noisy_paths(debug_output, arr, segment_lookup, threshold); - remove_colinear_vertices(arr); - clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); - } +#if 0 + fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); +#else + remove_colinear_vertices(arr); + clean_noisy_paths(arr, segment_lookup); +#endif t0.stop(); @@ -2651,7 +2068,8 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std #ifndef SVGFILL_MAIN -bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged) { +bool svgfill::arrange_polygons(const std::vector& polygons, std::vector& arranged) +{ std::vector cgal_polygons, cgal_polygons_out; std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) { Polygon_2 result; @@ -2660,7 +2078,7 @@ bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vec }); return result; }); - arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out); + arrange_cgal_polygons(cgal_polygons, cgal_polygons_out); std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) { svgfill::polygon_2 result; std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) { @@ -2710,7 +2128,7 @@ int main(int argc, char** argv) { input_polygons.back().push_back(CGAL::Point_2(x, y)); } } - arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); + arrange_cgal_polygons(input_polygons, output); break; } return 0; @@ -2723,7 +2141,7 @@ int main(int argc, char** argv) { input_polygons = { rect1, rect2, rect3, rect4, rect5 }; } - arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); + arrange_cgal_polygons(input_polygons, output); return 0; } diff --git a/src/svgfill/src/svgfill.cpp b/src/svgfill/src/svgfill.cpp index cf45881c93..8a2a1bb008 100644 --- a/src/svgfill/src/svgfill.cpp +++ b/src/svgfill/src/svgfill.cpp @@ -483,18 +483,6 @@ 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& edge_indices) { if (edge_indices.empty()) { return; diff --git a/src/svgfill/src/svgfill.h b/src/svgfill/src/svgfill.h index fab4dd0142..396fc9c924 100644 --- a/src/svgfill/src/svgfill.h +++ b/src/svgfill/src/svgfill.h @@ -67,7 +67,6 @@ namespace svgfill { virtual std::vector 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 { @@ -102,7 +101,6 @@ namespace svgfill { void write(std::vector>&); 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_; @@ -115,22 +113,7 @@ namespace svgfill { SVGFILL_API std::string polygons_to_svg(const std::vector>& polygons, bool random_color=false); SVGFILL_API std::string polygons_to_svg(const std::vector& polygons, bool random_color = false); SVGFILL_API bool svg_to_polygons(const std::string& data, const boost::optional& class_name, std::vector& polygons); - - 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& polygons, std::vector& arranged); - } + SVGFILL_API bool arrange_polygons(const std::vector& polygons, std::vector& arranged); +} #endif From 90bd7d26acc91670d2d5362f9d9ab50e67652cab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:45:51 +0000 Subject: [PATCH 44/76] Bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish-aichat-app.yaml | 2 +- .github/workflows/publish-pyodide-demo-app.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-aichat-app.yaml b/.github/workflows/publish-aichat-app.yaml index 776781428e..917b0b282f 100644 --- a/.github/workflows/publish-aichat-app.yaml +++ b/.github/workflows/publish-aichat-app.yaml @@ -32,7 +32,7 @@ jobs: submodules: recursive fetch-depth: 0 - name: Checkout intermediate Pages repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: IfcOpenShell/aichat_ifcopenshell_org_static_html ref: gh-pages diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml index 9f4c3ddbf8..6b0141fc29 100644 --- a/.github/workflows/publish-pyodide-demo-app.yml +++ b/.github/workflows/publish-pyodide-demo-app.yml @@ -32,7 +32,7 @@ jobs: submodules: recursive fetch-depth: 0 - name: Checkout intermediate Pages repo - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: IfcOpenShell/wasm_ifcopenshell_org_static_html ref: gh-pages From 06cfd0931c43482646821bdc594dad82fc7ce874 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:45:08 +0000 Subject: [PATCH 45/76] Bump actions/setup-python from 5 to 6 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish-aichat-app.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-aichat-app.yaml b/.github/workflows/publish-aichat-app.yaml index 917b0b282f..20369577f5 100644 --- a/.github/workflows/publish-aichat-app.yaml +++ b/.github/workflows/publish-aichat-app.yaml @@ -42,7 +42,7 @@ jobs: run: | rsync -av --delete --exclude='.git/' src/ifcchat/ output/ - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.x" - name: Download wheels From ebd5fe854f9e815f324fd1f4af11799858a7e907 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:45:03 +0000 Subject: [PATCH 46/76] Bump ruff from 0.15.8 to 0.15.9 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.8 to 0.15.9. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.8...0.15.9) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.15.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 330f6150d0..289a6c6415 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "IfcOpenShell" version = "0.0.0" dependencies = [ "black==26.3.1", - "ruff==0.15.8", + "ruff==0.15.9", "poethepoet", "gersemi==0.26.1", ] From b4558f7f759dda23d097494c11a49bd6a3c28433 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Thu, 9 Apr 2026 00:43:04 +0100 Subject: [PATCH 47/76] Fix ruff import ordering complaints --- src/bonsai/bonsai/tool/ifcgit.py | 4 ++-- src/bonsai/bonsai/tool/raycast.py | 5 ++--- src/bonsai/test/core/test_ifcgit.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 9c7199e839..2db7a5a171 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -288,9 +288,9 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] - from bonsai.bim.module.root.data import IfcClassData - from bonsai.bim.module.model.data import AuthoringData import bonsai.bim.handler + from bonsai.bim.module.model.data import AuthoringData + from bonsai.bim.module.root.data import IfcClassData AuthoringData.type_thumbnails = {} diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 38882a5095..dc45b4e9bb 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -25,13 +25,12 @@ import bmesh import bpy import mathutils import numpy as np -from mathutils import Vector - from bpy_extras import view3d_utils +from mathutils import Vector import bonsai.core.tool import bonsai.tool as tool -from bpy_extras import view3d_utils + class Raycast(bonsai.core.tool.Raycast): offset = 10 diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py index 539e4a082c..4884497c8b 100644 --- a/src/bonsai/test/core/test_ifcgit.py +++ b/src/bonsai/test/core/test_ifcgit.py @@ -20,7 +20,7 @@ import pytest import bonsai.core.ifcgit as subject -from test.core.bootstrap import ifcgit, ifc +from test.core.bootstrap import ifc, ifcgit class MockOperator: From 217bfed847b1c63458dff197bc4da270ecd93b6d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 10 Apr 2026 19:05:54 +1000 Subject: [PATCH 48/76] Add py313 to stable build --- .github/workflows/ci-bonsai.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-bonsai.yml b/.github/workflows/ci-bonsai.yml index 4c8364a958..6fcc61658f 100644 --- a/.github/workflows/ci-bonsai.yml +++ b/.github/workflows/ci-bonsai.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py311, py312] + pyver: [py311, py312, py313] config: - { name: "Windows Build", @@ -42,6 +42,11 @@ jobs: name: "MacOS ARM Build", short_name: macosm1, } + exclude: + # Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0. + - pyver: py313 + config: + short_name: macos steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 # https://github.com/actions/setup-python From c509f1d3eed76520e6300598293d1c98e9af8ecd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:29:45 +0000 Subject: [PATCH 49/76] Bump vite from 6.4.1 to 6.4.2 in /src/ifctester/webapp Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.4.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- src/ifctester/webapp/package-lock.json | 8 ++++---- src/ifctester/webapp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 1244317fd2..90a6743443 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -33,7 +33,7 @@ "tailwindcss": "^4.0.0", "tw-animate-css": "^1.3.2", "typescript": "^5.8.3", - "vite": "^6.4.1" + "vite": "^6.4.2" } }, "node_modules/@ampproject/remapping": { @@ -3264,9 +3264,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/ifctester/webapp/package.json b/src/ifctester/webapp/package.json index 14b54ad8ed..f36baf96ec 100644 --- a/src/ifctester/webapp/package.json +++ b/src/ifctester/webapp/package.json @@ -30,7 +30,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.8.3", "tw-animate-css": "^1.3.2", - "vite": "^6.4.1" + "vite": "^6.4.2" }, "dependencies": { "eventemitter3": "^5.0.1", From 16899602576c6460dc4ad54e32fd92aee508f2cc Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 16:19:36 +0500 Subject: [PATCH 50/76] Maintenence - document ci-bonsai.yml update --- src/bonsai/docs/guides/development/maintenance.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/bonsai/docs/guides/development/maintenance.rst b/src/bonsai/docs/guides/development/maintenance.rst index e1728fd7a5..e35306de1f 100644 --- a/src/bonsai/docs/guides/development/maintenance.rst +++ b/src/bonsai/docs/guides/development/maintenance.rst @@ -44,6 +44,8 @@ When a new Blender version is released and supported: * - File - What to update + * - ``.github/workflows/ci-bonsai.yml`` + - ``pyver`` matrix * - ``.github/workflows/ci-bonsai-daily.yml`` - Blender download URL From 6242251d3cd8bcce47a283dfed3f3e94e3312e07 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 1 Apr 2026 11:11:47 +0500 Subject: [PATCH 51/76] Fix typo --- nix/build-all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/build-all.py b/nix/build-all.py index 46b7b5c30c..f5436262d2 100644 --- a/nix/build-all.py +++ b/nix/build-all.py @@ -1515,7 +1515,7 @@ if "IfcOpenShell-Python" in targets: ) # Copy setup.py where pyodide build system expects it. shutil.copy(REPO_PATH / "pyodide" / "setup.py", REPO_PATH) - # Empty pyproject so it's contents won't affect the resulting wheelthe the + # Empty pyproject so it's contents won't affect the resulting wheel # otherwise the wheel will use version and dependencies from toml, not setup.py. (REPO_PATH / "pyproject.toml").write_text("") From c8f46cfb697454b38abaa206c7484cf7346a7b49 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 3 Apr 2026 12:37:53 +0500 Subject: [PATCH 52/76] build_pyodide.sh - use emsdk from pyodide --- pyodide/build_pyodide.sh | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pyodide/build_pyodide.sh b/pyodide/build_pyodide.sh index 20ad946162..db5c5f08b0 100755 --- a/pyodide/build_pyodide.sh +++ b/pyodide/build_pyodide.sh @@ -14,18 +14,11 @@ source .venv/bin/activate uv pip install pyodide-build # `uv run` is required, so xbuildenv would skip using `pip`. uv run pyodide xbuildenv install +uv run pyodide xbuildenv install-emscripten -# Emscripten doesn't come with xbuildenv. -if [ ! -d emsdk ]; then - git clone https://github.com/emscripten-core/emsdk -fi -pushd emsdk -PYODIDE_EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version) -./emsdk install ${PYODIDE_EMSCRIPTEN_VERSION} -./emsdk activate ${PYODIDE_EMSCRIPTEN_VERSION} -source emsdk_env.sh +EMSDK_ROOT=$(pyodide config get emscripten_dir) +source ${EMSDK_ROOT}/emsdk_env.sh which emcc -popd mkdir -p packages/ifcopenshell VERSION=`cat IfcOpenShell/VERSION` From b9d4ea38b015b336686609ce05a38644c223a8fb Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 12:12:27 +0500 Subject: [PATCH 53/76] Script for packing pyodide wheel --- pyodide/pack_wheel.py | 232 ++++++++++++++++++++++++++++++++++++++++++ pyodide/setup.py | 40 +++++++- 2 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 pyodide/pack_wheel.py diff --git a/pyodide/pack_wheel.py b/pyodide/pack_wheel.py new file mode 100644 index 0000000000..7b6c2a63d5 --- /dev/null +++ b/pyodide/pack_wheel.py @@ -0,0 +1,232 @@ +# +# /// script +# # Latest Pyodide build env versions are listed here: +# # https://pyodide.github.io/pyodide/api/pyodide-cross-build-environments.json +# # https://github.com/pyodide/pyodide-build/blob/main/pyodide_build/xbuildenv_releases.py +# requires-python = "==3.13.2" +# dependencies = [ +# "requests", +# "setuptools", +# ] +# /// +""" +Pack an IfcOpenShell WASM wheel using Pyodide build system. + +Usage: + uv run make_wheel.py # Show this help + uv run make_wheel.py --build # Build wheel + uv run make_wheel.py --clean # Clean build artifacts and exit +""" + +import argparse +import os +import re +import shutil +import subprocess +import time +import zipfile +from pathlib import Path +from urllib.parse import quote + +import requests + +# Get repo root (parent of this script's parent directory) +REPO_ROOT = Path(__file__).parent.parent +PYODIDE_DIR = REPO_ROOT / "pyodide" +BUILD_DIR = PYODIDE_DIR / "build" + +# Hardcoded path (Windows packing workaround with --dev flag) +PYODIDE_BUILD = Path(r"L:\Projects\Github\pyodide-build") + +# Wheel platform tag (from PYODIDE_EMSCRIPTEN_VERSION in pyodide-build/Makefile.envs) +WHEEL_PLATFORM_TAG = "emscripten_4_0_9_wasm32" + +# Location where ifcopenshell will be extracted +IFCOPENSHELL_DIR = PYODIDE_DIR / "ifcopenshell" + + +class WheelBuilder: + @staticmethod + def extract_ifcopenshell_from_git(dst: Path) -> None: + """Extract ifcopenshell directory from git repo into destination.""" + Tools.rmrf(dst) + + print(f"Extracting ifcopenshell from git to {dst}...") + # Use git ls-files piped to git checkout-index to avoid copying + # untracked or ignored files from the actual repo. + ls_proc = subprocess.Popen( + ["git", "ls-files", "-z", "src/ifcopenshell-python/ifcopenshell"], + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + checkout_proc = subprocess.Popen( + ["git", "checkout-index", "-z", "--prefix", "pyodide/", "--stdin"], + cwd=REPO_ROOT, + stdin=ls_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + assert ls_proc.stdout is not None + ls_proc.stdout.close() + checkout_proc.communicate() + + if checkout_proc.returncode != 0: + assert checkout_proc.stderr is not None + raise RuntimeError(f"Failed to extract: {checkout_proc.stderr.decode()}") + + # Move src/ifcopenshell-python/ifcopenshell to ifcopenshell. + temp_src = PYODIDE_DIR / "src" / "ifcopenshell-python" / "ifcopenshell" + shutil.move(temp_src, dst) + + # Clean up temporary src directory. + Tools.rmrf(PYODIDE_DIR / "src") + + print("✓ Extracted ifcopenshell from git") + + @staticmethod + def get_wheel_url(makefile_path: Path) -> str: + """Get S3 wheel URL based on BINARY_VERSION and BUILD_COMMIT from Makefile.""" + + def parse_makefile_vars() -> dict[str, str]: + content = makefile_path.read_text() + vars: dict[str, str] = {} + for match in re.finditer(r"^(BINARY_VERSION|BUILD_COMMIT):=(.+)$", content, re.MULTILINE): + vars[match.group(1)] = match.group(2).strip() + return vars + + vars: dict[str, str] = parse_makefile_vars() + binary_version = vars["BINARY_VERSION"] + build_commit = vars["BUILD_COMMIT"] + filename = f"ifcopenshell-{binary_version}+{build_commit}-cp313-cp313-pyodide_2025_0_wasm32.whl" + encoded_filename = quote(filename, safe="") + return f"https://s3.amazonaws.com/ifcopenshell-builds/{encoded_filename}" + + @staticmethod + def download_and_extract_so(url: str, build_dir: Path) -> tuple[Path, Path]: + """Download wheel from URL and extract .so and .py files.""" + py_wrapper_filename = "ifcopenshell_wrapper.py" + build_dir.mkdir(parents=True, exist_ok=True) + + wheel_path = build_dir / url.rsplit("/", 1)[-1] + + if wheel_path.exists(): + print(f"Using cached wheel: {wheel_path}") + else: + print(f"Downloading {url}...") + response = requests.get(url) + response.raise_for_status() + wheel_path.write_bytes(response.content) + + print("Extracting _ifcopenshell_wrapper files...") + with zipfile.ZipFile(wheel_path) as zf: + so_files = [f for f in zf.namelist() if f.endswith(".so")] + py_files = [f for f in zf.namelist() if f.endswith(py_wrapper_filename)] + + assert so_files, "No .so file found in wheel" + assert py_files, f"No {py_wrapper_filename} file found in wheel" + + so_file = so_files[0] + so_dst = build_dir / Path(so_file).name + so_dst.write_bytes(zf.read(so_file)) + + py_file = py_files[0] + py_dst = build_dir / Path(py_file).name + py_dst.write_bytes(zf.read(py_file)) + + return so_dst, py_dst + + +class Tools: + @staticmethod + def run( + cmd: list[str], + cwd: Path | None = None, + ) -> None: + print(f"$ {' '.join(cmd)}") + subprocess.check_call(cmd, cwd=cwd) + + @staticmethod + def create_symlink(dst: Path, src: Path) -> None: + Tools.rmrf(dst) + dst.symlink_to(src) + + @staticmethod + def rmrf(path: Path) -> None: + if path.exists() or path.is_symlink(): + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def clean() -> None: + """Remove build artifacts.""" + paths_to_remove = ( + BUILD_DIR, + PYODIDE_DIR / ".pyodide_build", + PYODIDE_DIR / "dist", + PYODIDE_DIR / "ifcopenshell.egg-info", + PYODIDE_DIR / "src", + IFCOPENSHELL_DIR, + ) + for path in paths_to_remove: + if path.exists() or path.is_symlink(): + print(f"Removing {path}...") + Tools.rmrf(path) + print("✓ Clean complete") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, add_help=False) + parser.add_argument("--build", action="store_true", help="Build the wheel") + parser.add_argument("--clean", action="store_true", help="Clean build folder") + parser.add_argument( + "--dev", + action="store_true", + help="Use editable pyodide-build from hardcoded path (Windows packing workaround)", + ) + args = parser.parse_args() + + if not args.build and not args.clean: + print(__doc__) + return + + if args.clean: + clean() + return + + start_time = time.time() + + WheelBuilder.extract_ifcopenshell_from_git(IFCOPENSHELL_DIR) + + print("Downloading and extracting _ifcopenshell_wrapper files...") + makefile = REPO_ROOT / "src" / "ifcopenshell-python" / "Makefile" + wheel_url = WheelBuilder.get_wheel_url(makefile) + so_file, py_file = WheelBuilder.download_and_extract_so(wheel_url, BUILD_DIR) + + Tools.create_symlink(IFCOPENSHELL_DIR / Path(so_file).name, so_file) + Tools.create_symlink(IFCOPENSHELL_DIR / Path(py_file).name, py_file) + + print("Installing pyodide-build...") + if args.dev: + Tools.run(["uv", "pip", "install", "-e", str(PYODIDE_BUILD)]) + else: + Tools.run(["uv", "pip", "install", "pyodide-build"]) + + print("Building with pyodide...") + # Use --no-isolation due to pyodide-build Windows support issues: + # symlink_unisolated_packages fails with missing `_sysconfigdata_$(CPYTHON_ABI_FLAGS)_emscripten_wasm32-emscripten.py`. + # Hardcode platform name since pyodide doesn't yet support overriding wheel tags on Windows. + # + # Use `LEGACY_PLATFORM` since pyodide 0.34.1 introduced new tag for wheels `pyemscripten`, + # which doesn't work with pyodide itself yet - https://github.com/pyodide/pyodide/issues/6177. + os.environ["USE_LEGACY_PLATFORM"] = "1" + Tools.run(["pyodide", "build", f"-C--build-option=--plat-name={WHEEL_PLATFORM_TAG}"]) + + elapsed = time.time() - start_time + print(f"\n✓ Done! ({elapsed:.1f}s)") + + +if __name__ == "__main__": + main() diff --git a/pyodide/setup.py b/pyodide/setup.py index 9678de0ac3..474a0b45a4 100644 --- a/pyodide/setup.py +++ b/pyodide/setup.py @@ -2,12 +2,16 @@ # because `tool.setuptools.ext-modules` is still experimental in pyproject.toml # and we need it to get the wheel suffix right. import os +import sys from pathlib import Path import tomllib from setuptools import Extension, find_packages, setup +from setuptools.command.build_ext import build_ext -REPO_FOLDER = Path(__file__).parent +# Detect repo folder: if setup.py is in pyodide folder, go to parent +SETUP_DIR = Path(__file__).parent +REPO_FOLDER = SETUP_DIR.parent if SETUP_DIR.name == "pyodide" else SETUP_DIR def get_version() -> str: @@ -25,6 +29,39 @@ def get_dependencies() -> list[str]: return dependencies +class UnixBuildExt(build_ext): + """Customize ``build_ext`` to support packing on Windows.""" + + def finalize_options(self): + from distutils import sysconfig + + super().finalize_options() + if sys.platform == "win32": + self.compiler = "unix" + + # Configure sysconfig for Windows builds + # CCSHARED is the only variable that's not customizable with env vars. + # Basically avoiding this: + # File ".venv\Lib\site-packages\setuptools\_distutils\sysconfig.py", line 366, in customize_compiler + # compiler_so=cc_cmd + ' ' + ccshared, + # ~~~~~~~~~~~~~^~~~~~~~~~ + # TypeError: can only concatenate str (not "NoneType") to str + sysconfig.get_config_vars() # Initialize config cache + if sysconfig._config_vars.get("CCSHARED") is None: + sysconfig._config_vars["CCSHARED"] = "-fPIC" + # Override compiler type before it's instantiated + + # Set Emscripten compiler environment variables + os.environ["CC"] = "emcc" + os.environ["CXX"] = "em++" + os.environ["CFLAGS"] = "" + os.environ["CXXFLAGS"] = "" + os.environ["LDSHARED"] = "emcc -shared" + os.environ["AR"] = "emar" + os.environ["ARFLAGS"] = "rcs" + os.environ["SETUPTOOLS_EXT_SUFFIX"] = ".cpython-313-wasm32-emscripten.so" + + setup( name="ifcopenshell", version=get_version(), @@ -44,4 +81,5 @@ setup( }, # Has to provide extension to get the correct wheel suffix. ext_modules=[Extension("ifcopenshell._ifcopenshell_wrapper", sources=[])], + cmdclass={"build_ext": UnixBuildExt}, ) From 7169dcd0537ac6e9336c2788ccd8f4d6ebf24967 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 17:29:17 +0500 Subject: [PATCH 54/76] Create ci-pyodide-wasm-release.yml --- .github/workflows/ci-pyodide-wasm-release.yml | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/ci-pyodide-wasm-release.yml diff --git a/.github/workflows/ci-pyodide-wasm-release.yml b/.github/workflows/ci-pyodide-wasm-release.yml new file mode 100644 index 0000000000..eff7bc30d9 --- /dev/null +++ b/.github/workflows/ci-pyodide-wasm-release.yml @@ -0,0 +1,43 @@ +name: Release Pyodide WASM Wheel + +on: + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout IfcOpenShell + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Build wheel + working-directory: pyodide + run: uv run pack_wheel.py --build + + - name: Find wheel + id: wheel + run: | + WHEEL=$(ls pyodide/dist/ifcopenshell-*.whl) + echo "path=$WHEEL" >> $GITHUB_OUTPUT + echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT + + - name: Checkout wasm-wheels + uses: actions/checkout@v6 + with: + repository: IfcOpenShell/wasm-wheels + path: wasm-wheels + token: ${{ secrets.WASM_WHEELS_TOKEN }} + + - name: Commit and push wheel to wasm-wheels + run: | + WHEEL_NAME="${{ steps.wheel.outputs.name }}" + cp "${{ steps.wheel.outputs.path }}" "wasm-wheels/$WHEEL_NAME" + cd wasm-wheels + git config user.name "IfcOpenBot" + git config user.email "ifcopenbot@ifcopenshell.org" + git add "$WHEEL_NAME" + git commit -m "Add $WHEEL_NAME" + git push origin main From 51a338e4c87a46e47c31f02468f85b4f8a59ea75 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sat, 21 Mar 2026 10:54:42 +0100 Subject: [PATCH 55/76] Suppress reportRedeclaration in Pyright config --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 289a6c6415..13169b7c0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ extend-exclude = ''' reportInvalidTypeForm = false disableBytesTypePromotions = true reportUnnecessaryTypeIgnoreComment = true +reportRedeclaration = false # Pylance doesn't respect gitignore, so we have to exclude files manually here # to avoid VS Code slowing down. # https://github.com/microsoft/pylance-release/issues/5169 From 8eb0060d4a1d7f970b69087e5ab8f5f24702c60e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 17:55:06 +0500 Subject: [PATCH 56/76] Get rid of pyright ignore reportRedeclaration noise Welp, it was helping to point out untyped props, but it is getting too noisy now. --- .../bonsai/bim/module/attribute/operator.py | 8 +- .../bonsai/bim/module/attribute/prop.py | 14 ++-- .../bonsai/bim/module/clash/operator.py | 8 +- src/bonsai/bonsai/bim/module/clash/prop.py | 8 +- src/bonsai/bonsai/bim/module/cost/operator.py | 2 +- .../bonsai/bim/module/debug/operator.py | 10 +-- .../bonsai/bim/module/drawing/operator.py | 18 ++-- src/bonsai/bonsai/bim/module/drawing/prop.py | 4 +- .../bonsai/bim/module/group/operator.py | 6 +- .../bonsai/bim/module/ifcgit/operator.py | 6 +- .../bonsai/bim/module/light/operator.py | 16 ++-- src/bonsai/bonsai/bim/module/misc/operator.py | 10 +-- src/bonsai/bonsai/bim/module/misc/prop.py | 42 +++++----- src/bonsai/bonsai/bim/module/model/product.py | 2 +- src/bonsai/bonsai/bim/module/model/profile.py | 2 +- src/bonsai/bonsai/bim/module/model/prop.py | 8 +- .../bonsai/bim/module/owner/operator.py | 54 ++++++------ .../bonsai/bim/module/project/operator.py | 82 +++++++++---------- src/bonsai/bonsai/bim/module/project/prop.py | 2 +- src/bonsai/bonsai/bim/module/pset/operator.py | 14 ++-- src/bonsai/bonsai/bim/module/pset/prop.py | 6 +- .../bonsai/bim/module/search/operator.py | 8 +- .../bonsai/bim/module/sequence/operator.py | 12 +-- src/bonsai/bonsai/bim/module/sequence/prop.py | 6 +- .../bonsai/bim/module/spatial/operator.py | 2 +- .../bonsai/bim/module/structural/operator.py | 6 +- .../bonsai/bim/module/system/operator.py | 2 +- src/bonsai/bonsai/bim/module/web/prop.py | 6 +- src/bonsai/bonsai/bim/operator.py | 12 +-- src/bonsai/bonsai/bim/prop.py | 4 +- src/bonsai/bonsai/bim/ui.py | 2 +- .../nodes/ifc/shape_builder/extrude.py | 2 +- 32 files changed, 192 insertions(+), 192 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/attribute/operator.py b/src/bonsai/bonsai/bim/module/attribute/operator.py index 13901c0f81..8069aa29ce 100644 --- a/src/bonsai/bonsai/bim/module/attribute/operator.py +++ b/src/bonsai/bonsai/bim/module/attribute/operator.py @@ -295,13 +295,13 @@ class ExplorerShowUIPopup(bpy.types.Operator): bl_description = "Show Explorer UI to select element as attribute value or edit it." bl_options = {"REGISTER", "UNDO"} - ifc_class: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + ifc_class: bpy.props.StringProperty() """Element IFC class.""" - attribute_name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + attribute_name: bpy.props.StringProperty() """IFC class attribute name.""" - data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + data_path: bpy.props.StringProperty() """Full data path""" - preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) """IFC id to preselect in the popup.""" if TYPE_CHECKING: diff --git a/src/bonsai/bonsai/bim/module/attribute/prop.py b/src/bonsai/bonsai/bim/module/attribute/prop.py index acff2424b7..425c875e37 100644 --- a/src/bonsai/bonsai/bim/module/attribute/prop.py +++ b/src/bonsai/bonsai/bim/module/attribute/prop.py @@ -41,7 +41,7 @@ class BIMAttributeProperties(PropertyGroup): class ExplorerEntity(PropertyGroup): - ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + ifc_definition_id: bpy.props.IntProperty() if TYPE_CHECKING: ifc_definition_id: int @@ -60,7 +60,7 @@ class BIMExplorerProperties(PropertyGroup): self.property_unset("editing_entity_id") self.entity_attributes.clear() - is_loaded: BoolProperty( # pyright: ignore[reportRedeclaration] + is_loaded: BoolProperty( name="Toggle Explorer UI", update=update_is_loaded, ) @@ -76,15 +76,15 @@ class BIMExplorerProperties(PropertyGroup): def update_ifc_class(self, context: object) -> None: tool.Attribute.refresh_uilist_entities() - ifc_class: EnumProperty( # pyright: ignore[reportRedeclaration] + ifc_class: EnumProperty( name="IFC Class To Search", items=get_ifc_class, update=update_ifc_class, ) - entities: CollectionProperty(type=ExplorerEntity) # pyright: ignore[reportRedeclaration] - active_entity_index: IntProperty() # pyright: ignore[reportRedeclaration] - editing_entity_id: IntProperty() # pyright: ignore[reportRedeclaration] - entity_attributes: CollectionProperty(type=Attribute) # pyright: ignore[reportRedeclaration] + entities: CollectionProperty(type=ExplorerEntity) + active_entity_index: IntProperty() + editing_entity_id: IntProperty() + entity_attributes: CollectionProperty(type=Attribute) if TYPE_CHECKING: is_loaded: bool diff --git a/src/bonsai/bonsai/bim/module/clash/operator.py b/src/bonsai/bonsai/bim/module/clash/operator.py index 3788130a12..fb1af71774 100644 --- a/src/bonsai/bonsai/bim/module/clash/operator.py +++ b/src/bonsai/bonsai/bim/module/clash/operator.py @@ -201,16 +201,16 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): "ALT+click to run a quick clash without selecting a file to save." ) - filter_glob: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + filter_glob: bpy.props.StringProperty( default="*.bcf;*.json", options={"HIDDEN"} ) - format: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + format: bpy.props.EnumProperty( name="Format", items=[(i, i, "") for i in ("bcf", "json")] ) - filepath: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + filepath: bpy.props.StringProperty( subtype="FILE_PATH", options={"SKIP_SAVE"} ) - quick_clash: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + quick_clash: bpy.props.BoolProperty( options={"SKIP_SAVE"}, ) diff --git a/src/bonsai/bonsai/bim/module/clash/prop.py b/src/bonsai/bonsai/bim/module/clash/prop.py index 8bcd71632b..1ef3403e64 100644 --- a/src/bonsai/bonsai/bim/module/clash/prop.py +++ b/src/bonsai/bonsai/bim/module/clash/prop.py @@ -37,12 +37,12 @@ from bonsai.bim.prop import BIMFilterGroup, StrProperty class ClashSource(PropertyGroup): - name: StringProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty( name="File", description="Absolute filepath to existing .ifc file to use as a clash source.", ) - filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") # pyright: ignore[reportRedeclaration] - mode: EnumProperty( # pyright: ignore[reportRedeclaration] + filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") + mode: EnumProperty( items=[ ("a", "All Elements", "All elements will be used for clashing"), ("i", "Include", "Only the selected elements are included for clashing"), @@ -62,7 +62,7 @@ class Clash(PropertyGroup): b_global_id: StringProperty(name="B") a_name: StringProperty(name="A Name") b_name: StringProperty(name="B Name") - clash_type: EnumProperty( # pyright: ignore[reportRedeclaration] + clash_type: EnumProperty( name="Clash Type", items=tuple((i, i, "") for i in CLASH_TYPE_ITEMS), ) diff --git a/src/bonsai/bonsai/bim/module/cost/operator.py b/src/bonsai/bonsai/bim/module/cost/operator.py index b612100a1d..ac9d2d4bf1 100644 --- a/src/bonsai/bonsai/bim/module/cost/operator.py +++ b/src/bonsai/bonsai/bim/module/cost/operator.py @@ -87,7 +87,7 @@ class CopyCostSchedule(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy Cost Schedule" bl_description = "Create a duplicate of the provided cost schedule." bl_options = {"REGISTER", "UNDO"} - cost_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + cost_schedule: bpy.props.IntProperty() if TYPE_CHECKING: cost_schedule: int diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 9315386008..c88ea4a00e 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -260,14 +260,14 @@ class CreateAllShapes(bpy.types.Operator): ) bl_options = {"REGISTER"} - geometry_library: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + geometry_library: bpy.props.EnumProperty( name="Geometry Library", description="Geometry library to use for testing shape creation.", items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)], # By default use the same library as used for importing ifc project. default="hybrid-cgal-simple-opencascade", ) - custom_geometry_library: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + custom_geometry_library: bpy.props.StringProperty( name="Custom Geometry Library", description="Provide a custom geometry library name, will override the 'geometry library' property.", ) @@ -781,7 +781,7 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Purge Unused Objects" bl_options = {"REGISTER", "UNDO"} - object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + object_type: bpy.props.EnumProperty( name="Object Type", items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)), ) @@ -827,7 +827,7 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): ) bl_options = {"REGISTER", "UNDO"} - object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + object_type: bpy.props.EnumProperty( name="Object Type", items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)), ) @@ -1073,7 +1073,7 @@ class ChangeLogLevel(bpy.types.Operator): bl_options = {"REGISTER"} bl_description = "Change general log level across all Python code in Blender" - log_level: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + log_level: bpy.props.EnumProperty( name="Log Level", items=[(i, i, "") for i in get_args(LogLevelType)], default="WARNING", diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 45f67b0769..d33047378a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -246,17 +246,17 @@ class CreateDrawing(bpy.types.Operator): + "Add the CTRL modifier to optionally open drawings to view them as\n" + "they are created" ) - print_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + print_all: bpy.props.BoolProperty( name="Print All", default=False, options={"SKIP_SAVE"}, ) - open_viewer: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + open_viewer: bpy.props.BoolProperty( name="Open in Viewer", default=False, options={"SKIP_SAVE"}, ) - sync: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + sync: bpy.props.BoolProperty( name="Sync Before Creating Drawing", description="Could save some time if you're sure IFC and current Blender session are already in sync", default=True, @@ -2322,14 +2322,14 @@ class ActivateDrawingBase(tool.Ifc.Operator): + "SHIFT+CLICK to load a quick preview of the drawing view" ) - drawing: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - should_view_from_camera: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + drawing: bpy.props.IntProperty() + should_view_from_camera: bpy.props.BoolProperty( name="Should View From Camera", description="Move view to the activated drawing's camera position.", default=True, options={"SKIP_SAVE"}, ) - use_quick_preview: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + use_quick_preview: bpy.props.BoolProperty( name="Use Quick Preview", description="Just move the camera to the drawing view, without loading anything else.", default=False, @@ -3635,12 +3635,12 @@ class ToggleTargetView(bpy.types.Operator): bl_label = "Toggle Target View" bl_options = {"REGISTER", "UNDO"} - target_view: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] - toggle_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + target_view: bpy.props.StringProperty() + toggle_all: bpy.props.BoolProperty( default=False, options={"SKIP_SAVE"}, ) - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + option: bpy.props.EnumProperty( items=[(i, i, "") for i in get_args(ToggleOption)] ) diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 57c182c02e..1647d44773 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -860,13 +860,13 @@ class BIMTextProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) literals: CollectionProperty(name="Literals", type=LiteralProps) newline_at: IntProperty(name="Newline At") - symbol: EnumProperty( # pyright: ignore[reportRedeclaration] + symbol: EnumProperty( name="Symbol", description="Symbol from symbols.svg to use for this text.", items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS], default="NO SYMBOL", ) - custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration] + custom_symbol: StringProperty( name="Custom Symbol", description="Non-default symbol to use for this text.", ) diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py index adea9ee48c..f58ba72763 100644 --- a/src/bonsai/bonsai/bim/module/group/operator.py +++ b/src/bonsai/bonsai/bim/module/group/operator.py @@ -43,11 +43,11 @@ class ToggleGroup(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Toggle Group" bl_options = {"REGISTER", "UNDO"} - ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - group_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + ifc_definition_id: bpy.props.IntProperty() + group_type: bpy.props.EnumProperty( items=[(i, i, "") for i in get_args(tool.Group.GroupType)], ) - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + option: bpy.props.EnumProperty( items=[(i, i, "") for i in get_args(tool.Group.ToggleOption)], ) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index cc0f75577a..65bec6b252 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -314,7 +314,7 @@ class SelectConflictEntity(bpy.types.Operator): bl_idname = "ifcgit.select_conflict_entity" bl_options = {"REGISTER"} - step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + step_id: bpy.props.IntProperty() if TYPE_CHECKING: step_id: int @@ -515,7 +515,7 @@ class RunGitDiff(bpy.types.Operator): ) bl_options = set() - save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: save_to_temp: bool @@ -547,7 +547,7 @@ class RenameBranch(bpy.types.Operator): bl_idname = "ifcgit.rename_branch" bl_options = {"REGISTER"} - new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration] + new_name: bpy.props.StringProperty(name="New name") if TYPE_CHECKING: new_name: str diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index 6ee37f2285..c3f377e9b9 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -272,21 +272,21 @@ class RadianceRender(bpy.types.Operator): + '''" map_u map_v 0 1 0.5 - + # This is a multiplier to colour balance the env map # In this case, it provides a rough ground luminance from 3k-5k env_map colorfunc env_colour 4 100 100 100 . 0 0 - + # .37 .57 1.5 is measured from a HDRI image # It is multiplied by a factor such that grey(r,g,b) = 1 skyfunc colorfunc sky_colour 4 .64 .99 2.6 . 0 0 - + void mixpict composite 7 env_colour sky_colour grey "''' + hdr_mask_path @@ -295,22 +295,22 @@ void mixpict composite + """" map_u map_v 0 2 0.5 1 - + composite glow env_map_glow 0 0 4 1 1 1 0 - + env_map_glow source sky 0 0 4 0 0 1 180 - + env_colour glow ground_glow 0 0 4 1 1 1 0 - + ground_glow source ground 0 0 @@ -566,7 +566,7 @@ class LightPickCoordinates(bpy.types.Operator): ) bl_options = {"REGISTER", "UNDO"} - use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: use_current_location: bool diff --git a/src/bonsai/bonsai/bim/module/misc/operator.py b/src/bonsai/bonsai/bim/module/misc/operator.py index 33da9a05dc..31317f5b95 100644 --- a/src/bonsai/bonsai/bim/module/misc/operator.py +++ b/src/bonsai/bonsai/bim/module/misc/operator.py @@ -136,7 +136,7 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator): "Will unassign element from a type if type has a representation." ) bl_options = {"REGISTER", "UNDO"} - mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + mode: bpy.props.EnumProperty( default="BOOLEAN", items=tuple((i, i, "") for i in get_args(SplitAlongEdgeMode)), ) @@ -359,7 +359,7 @@ class ConfirmQuickFavoriteOperator(bpy.types.Operator): bl_idname = "bim.confirm_quick_favorite_operator" bl_label = "Confirm Operator" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() if TYPE_CHECKING: index: int @@ -452,8 +452,8 @@ class MoveQuickFavoritesItem(bpy.types.Operator): bl_idname = "bim.move_quick_favorites_item" bl_label = "Move Quick Favorites Item" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() + direction: bpy.props.EnumProperty( items=[("UP", "Up", ""), ("DOWN", "Down", "")] ) @@ -474,7 +474,7 @@ class RemoveQuickFavoritesItem(bpy.types.Operator): bl_idname = "bim.remove_quick_favorites_item" bl_label = "Remove Quick Favorites Item" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() if TYPE_CHECKING: index: int diff --git a/src/bonsai/bonsai/bim/module/misc/prop.py b/src/bonsai/bonsai/bim/module/misc/prop.py index ddeda73b88..74a82f06cd 100644 --- a/src/bonsai/bonsai/bim/module/misc/prop.py +++ b/src/bonsai/bonsai/bim/module/misc/prop.py @@ -36,9 +36,9 @@ QuickFavoriteValueType = Literal["float_value", "bool_value", "int_value", "stri class QuickFavoriteEnumItem(PropertyGroup): - name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration] - display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration] - description: StringProperty(name="Description", default="") # pyright: ignore[reportRedeclaration] + name: StringProperty(name="Name", default="") + display_name: StringProperty(name="Display Name", default="") + description: StringProperty(name="Description", default="") if TYPE_CHECKING: name: str @@ -51,19 +51,19 @@ def get_enum_items(self: "QuickFavoriteProperty", context: bpy.types.Context | N class QuickFavoriteProperty(PropertyGroup): - name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration] - display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration] - value_prop: EnumProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty(name="Name", default="") + display_name: StringProperty(name="Display Name", default="") + value_prop: EnumProperty( name="Value Prop", items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)), ) - string_value: StringProperty(name="String Value", default="") # pyright: ignore[reportRedeclaration] - float_value: FloatProperty(name="Float Value", default=0.0) # pyright: ignore[reportRedeclaration] - int_value: IntProperty(name="Int Value", default=0) # pyright: ignore[reportRedeclaration] - bool_value: BoolProperty(name="Bool Value", default=False) # pyright: ignore[reportRedeclaration] - enum_value: EnumProperty(name="Enum Value", items=get_enum_items) # pyright: ignore[reportRedeclaration] - enum_items: CollectionProperty(type=QuickFavoriteEnumItem) # pyright: ignore[reportRedeclaration] - is_active: BoolProperty( # pyright: ignore[reportRedeclaration] + string_value: StringProperty(name="String Value", default="") + float_value: FloatProperty(name="Float Value", default=0.0) + int_value: IntProperty(name="Int Value", default=0) + bool_value: BoolProperty(name="Bool Value", default=False) + enum_value: EnumProperty(name="Enum Value", items=get_enum_items) + enum_items: CollectionProperty(type=QuickFavoriteEnumItem) + is_active: BoolProperty( name="Is Active", description="Only active properties will be added to the operator when invoked from Quick Favorites", default=False, @@ -100,20 +100,20 @@ def get_operator_suggestions(self: "QuickFavoritesItem", context: bpy.types.Cont class QuickFavoritesItem(PropertyGroup): - is_expanded: BoolProperty(name="Is Expanded", default=False) # pyright: ignore[reportRedeclaration] - search: StringProperty( # pyright: ignore[reportRedeclaration] + is_expanded: BoolProperty(name="Is Expanded", default=False) + search: StringProperty( name="Search", default="", search=get_operator_suggestions, # Resetting `search_options`, allowing users only to use suggestions. search_options=set(), ) - properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration] - operator_id: StringProperty( # pyright: ignore[reportRedeclaration] + properties: CollectionProperty(type=QuickFavoriteProperty) + operator_id: StringProperty( name="Operator ID", default="", ) - label: StringProperty( # pyright: ignore[reportRedeclaration] + label: StringProperty( name="Label", description="Label that will be used in Quick Favorites for this operator", default="", @@ -139,15 +139,15 @@ class QuickFavoritesItem(PropertyGroup): class BIMMiscProperties(PropertyGroup): - total_storeys: IntProperty( # pyright: ignore[reportRedeclaration] + total_storeys: IntProperty( name="Total Storeys", description="Number of storeys above object's storey to take into account for resizing", default=1, ) - override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration] + override_colour: FloatVectorProperty( name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4 ) - quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration] + quick_favorites: CollectionProperty(type=QuickFavoritesItem) if TYPE_CHECKING: total_storeys: int diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index 4cf4e00172..75f5441827 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -545,7 +545,7 @@ class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_type_page" bl_label = "Change Type Page" bl_options = {"REGISTER"} - page: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + page: bpy.props.IntProperty() if TYPE_CHECKING: page: int diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index 1369ad3cb6..e5c0991e8e 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -271,7 +271,7 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.extend_profile" bl_label = "Extend Profile" bl_options = {"REGISTER", "UNDO"} - join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + join_type: bpy.props.EnumProperty( items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")], default="-", ) diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index c4956056aa..ff6ea96130 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -1729,20 +1729,20 @@ def poll_sverchok_nodes(self: "BIMExternalParametricGeometryProperties", node_tr class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup): - is_editing: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + is_editing: bpy.props.BoolProperty( name="Is Editing Paramteric Geometry", description="Toggle editing parametric geometry.", default=False, update=update_is_editing, ) - geometry_source: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + geometry_source: bpy.props.EnumProperty( name="Geometry Source", items=[ ("GEONODES", "Geometry Nodes", ""), ("IFCSVERCHOK", "IFC Sverchok", ""), ], ) - geo_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration] + geo_nodes: bpy.props.PointerProperty( name="Geometry Nodes", description="Geometry nodes tree to use as a source for representation.", type=bpy.types.GeometryNodeTree, @@ -1750,7 +1750,7 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup): poll=lambda self, node_tree: not node_tree.name.startswith("BBIM_EPG"), ) - sverchok_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration] + sverchok_nodes: bpy.props.PointerProperty( name="Sverchok Nodes", description="Sverchok node tree to use as a source for representation.", type=bpy.types.NodeTree, diff --git a/src/bonsai/bonsai/bim/module/owner/operator.py b/src/bonsai/bonsai/bim/module/owner/operator.py index 2470fd8227..b934337b64 100644 --- a/src/bonsai/bonsai/bim/module/owner/operator.py +++ b/src/bonsai/bonsai/bim/module/owner/operator.py @@ -33,7 +33,7 @@ class EnableEditingPerson(bpy.types.Operator): bl_idname = "bim.enable_editing_person" bl_label = "Enable Editing Person" bl_options = {"REGISTER", "UNDO"} - person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person: bpy.props.IntProperty() if TYPE_CHECKING: person: int @@ -75,7 +75,7 @@ class RemovePerson(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_person" bl_label = "Remove Person" bl_options = {"REGISTER", "UNDO"} - person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person: bpy.props.IntProperty() if TYPE_CHECKING: person: int @@ -88,7 +88,7 @@ class AddPersonAttribute(bpy.types.Operator): bl_idname = "bim.add_person_attribute" bl_label = "Add Person Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)), ) @@ -104,10 +104,10 @@ class RemovePersonAttribute(bpy.types.Operator): bl_idname = "bim.remove_person_attribute" bl_label = "Remove Person Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)), ) - id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + id: bpy.props.IntProperty() if TYPE_CHECKING: name: tool.Owner.PersonAttributeType # pyright: ignore[reportIncompatibleVariableOverride] @@ -122,7 +122,7 @@ class EnableEditingRole(bpy.types.Operator): bl_idname = "bim.enable_editing_role" bl_label = "Enable Editing Role" bl_options = {"REGISTER", "UNDO"} - role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + role: bpy.props.IntProperty() if TYPE_CHECKING: role: int @@ -146,7 +146,7 @@ class AddRole(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_role" bl_label = "Add Role" bl_options = {"REGISTER", "UNDO"} - parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + parent: bpy.props.IntProperty() if TYPE_CHECKING: parent: int @@ -168,7 +168,7 @@ class RemoveRole(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_role" bl_label = "Remove Role" bl_options = {"REGISTER", "UNDO"} - role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + role: bpy.props.IntProperty() if TYPE_CHECKING: role: int @@ -181,8 +181,8 @@ class AddAddress(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_address" bl_label = "Add Address" bl_options = {"REGISTER", "UNDO"} - parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - ifc_class: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + parent: bpy.props.IntProperty() + ifc_class: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(ADDRESS_TYPE)), ) @@ -198,7 +198,7 @@ class AddAddressAttribute(bpy.types.Operator): bl_idname = "bim.add_address_attribute" bl_label = "Add Address Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)), ) @@ -214,10 +214,10 @@ class RemoveAddressAttribute(bpy.types.Operator): bl_idname = "bim.remove_address_attribute" bl_label = "Remove Address Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)), ) - id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + id: bpy.props.IntProperty() if TYPE_CHECKING: name: tool.Owner.AddressAttributeType # pyright: ignore[reportIncompatibleVariableOverride] @@ -232,7 +232,7 @@ class EnableEditingAddress(bpy.types.Operator): bl_idname = "bim.enable_editing_address" bl_label = "Enable Editing Address" bl_options = {"REGISTER", "UNDO"} - address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + address: bpy.props.IntProperty() if TYPE_CHECKING: address: int @@ -265,7 +265,7 @@ class RemoveAddress(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_address" bl_label = "Remove Address" bl_options = {"REGISTER", "UNDO"} - address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + address: bpy.props.IntProperty() if TYPE_CHECKING: address: int @@ -278,7 +278,7 @@ class EnableEditingOrganisation(bpy.types.Operator): bl_idname = "bim.enable_editing_organisation" bl_label = "Enable Editing Organisation" bl_options = {"REGISTER", "UNDO"} - organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + organisation: bpy.props.IntProperty() if TYPE_CHECKING: organisation: int @@ -320,7 +320,7 @@ class RemoveOrganisation(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_organisation" bl_label = "Remove Organisation" bl_options = {"REGISTER", "UNDO"} - organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + organisation: bpy.props.IntProperty() if TYPE_CHECKING: organisation: int @@ -333,8 +333,8 @@ class AddPersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_person_and_organisation" bl_label = "Add Person And Organisation" bl_options = {"REGISTER", "UNDO"} - person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person: bpy.props.IntProperty() + organisation: bpy.props.IntProperty() if TYPE_CHECKING: person: int @@ -350,7 +350,7 @@ class RemovePersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_person_and_organisation" bl_label = "Remove Person And Organisation" bl_options = {"REGISTER", "UNDO"} - person_and_organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person_and_organisation: bpy.props.IntProperty() if TYPE_CHECKING: person_and_organisation: int @@ -365,7 +365,7 @@ class SetUser(bpy.types.Operator): bl_idname = "bim.set_user" bl_label = "Set User" bl_options = {"REGISTER", "UNDO"} - user: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + user: bpy.props.IntProperty() if TYPE_CHECKING: user: int @@ -401,7 +401,7 @@ class EnableEditingActor(bpy.types.Operator): bl_idname = "bim.enable_editing_actor" bl_label = "Enable Editing Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -434,7 +434,7 @@ class RemoveActor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_actor" bl_label = "Remove Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -447,7 +447,7 @@ class AssignActor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_actor" bl_label = "Assign Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -462,7 +462,7 @@ class UnassignActor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_actor" bl_label = "Unassign Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -481,7 +481,7 @@ class RemoveApplication(bpy.types.Operator, tool.Ifc.Operator): "Remove provided IfcApplication." "\n\nFor safety will only work on applications without inverses (they are typically marked as '(unused)'." ) - application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + application_id: bpy.props.IntProperty() if TYPE_CHECKING: application_id: int @@ -525,7 +525,7 @@ class EnableEditingApplication(bpy.types.Operator): bl_idname = "bim.enable_editing_application" bl_label = "Enable Editing Application" bl_options = {"REGISTER", "UNDO"} - application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + application_id: bpy.props.IntProperty() if TYPE_CHECKING: application_id: int diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index e24f6f41d8..9ad9c082c8 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -86,7 +86,7 @@ class NewProject(bpy.types.Operator): bl_label = "New Project" bl_options = {"REGISTER", "UNDO"} bl_description = "Start a new IFC project in a fresh session" - preset: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + preset: bpy.props.EnumProperty( items=[(i, i, "") for i in get_args(PresetType)] ) @@ -180,11 +180,11 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper): ) filter_glob: bpy.props.StringProperty( default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"} - ) # pyright: ignore[reportRedeclaration] - append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration] + ) + append_all: bpy.props.BoolProperty(default=False) use_relative_path: bpy.props.BoolProperty( name="Use Relative Path", default=False - ) # pyright: ignore[reportRedeclaration] + ) if TYPE_CHECKING: filter_glob: str @@ -568,7 +568,7 @@ class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.append_library_element_by_query" bl_label = "Append Library Element By Query" - query: bpy.props.StringProperty(name="Query") # pyright: ignore[reportRedeclaration] + query: bpy.props.StringProperty(name="Query") if TYPE_CHECKING: query: str @@ -600,11 +600,11 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): "Append element to the current project.\n\n" "ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)" ) - definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + definition: bpy.props.IntProperty() + prop_index: bpy.props.IntProperty() assume_unique_by_name: bpy.props.BoolProperty( name="Assume Unique By Name", default=True, options={"SKIP_SAVE"} - ) # pyright: ignore[reportRedeclaration] + ) if TYPE_CHECKING: definition: int @@ -961,26 +961,26 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_description = "Load an existing IFC project" filepath: bpy.props.StringProperty( subtype="FILE_PATH", options={"SKIP_SAVE"} - ) # pyright: ignore[reportRedeclaration] + ) filter_glob: bpy.props.StringProperty( default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"} - ) # pyright: ignore[reportRedeclaration] - is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + ) + is_advanced: bpy.props.BoolProperty( name="Enable Advanced Mode", description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings", default=False, ) - use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + use_relative_path: bpy.props.BoolProperty( name="Use Relative Path", description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved", default=False, ) - should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + should_start_fresh_session: bpy.props.BoolProperty( name="Should Start Fresh Session", description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option", default=True, ) - import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + import_without_ifc_data: bpy.props.BoolProperty( name="Import Without IFC Data", description=( "Import IFC objects as Blender objects without any IFC metadata and authoring capabilities." @@ -990,7 +990,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): ) use_detailed_tooltip: bpy.props.BoolProperty( default=False, options={"HIDDEN"} - ) # pyright: ignore[reportRedeclaration] + ) filename_ext = ".ifc" if TYPE_CHECKING: @@ -1300,7 +1300,7 @@ class ToggleFilterCategories(bpy.types.Operator): bl_idname = "bim.toggle_filter_categories" bl_label = "Toggle Filter Categories" bl_options = {"REGISTER", "UNDO"} - should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration] + should_select: bpy.props.BoolProperty(name="Should Select", default=True) if TYPE_CHECKING: should_select: bool @@ -1327,7 +1327,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): default=False, ) use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) - query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + query: bpy.props.StringProperty( name="Query", description=( "Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n" @@ -1404,7 +1404,7 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Remove the selected file from the link list" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") if TYPE_CHECKING: link_index: int @@ -1428,7 +1428,7 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Unload the selected linked file" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") if TYPE_CHECKING: link_index: int @@ -1454,9 +1454,9 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Load the selected file" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] - use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration] - query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") + use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) + query: bpy.props.StringProperty() if TYPE_CHECKING: link_index: int @@ -1631,7 +1631,7 @@ class ReloadLink(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Reload the selected file" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") if TYPE_CHECKING: link_index: int @@ -1647,7 +1647,7 @@ class ToggleLinkSelectability(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Toggle selectability" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") if TYPE_CHECKING: link_index: int @@ -1679,8 +1679,8 @@ class ToggleLinkVisibility(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Toggle visibility between SOLID and WIREFRAME" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] - mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") + mode: bpy.props.EnumProperty( name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")), ) @@ -1821,7 +1821,7 @@ class SelectLinkHandle(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Select link empty object handle" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") if TYPE_CHECKING: link_index: int @@ -1843,7 +1843,7 @@ class SelectLinkedModelElement(bpy.types.Operator): bl_options = {"REGISTER"} bl_description = "Select an element in the currently selected linked model by providing GlobalId." - guid: bpy.props.StringProperty(name="GlobalId") # pyright: ignore[reportRedeclaration] + guid: bpy.props.StringProperty(name="GlobalId") if TYPE_CHECKING: guid: str @@ -1884,19 +1884,19 @@ class ExportIFC(bpy.types.Operator, ExportHelper): supported_filexts = (".ifc", ".ifczip", ".ifcjson") filter_glob: bpy.props.StringProperty( default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"} - ) # pyright: ignore[reportRedeclaration] + ) json_version: bpy.props.EnumProperty( items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version" - ) # pyright: ignore[reportRedeclaration] + ) json_compact: bpy.props.BoolProperty( name="Export Compact IFCJSON", default=False - ) # pyright: ignore[reportRedeclaration] + ) should_save_as: bpy.props.BoolProperty( name="Should Save As", default=False, options={"HIDDEN"} - ) # pyright: ignore[reportRedeclaration] + ) use_relative_path: bpy.props.BoolProperty( name="Use Relative Path", default=False - ) # pyright: ignore[reportRedeclaration] + ) if TYPE_CHECKING: filter_glob: str @@ -2053,7 +2053,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file." bl_options = {"REGISTER", "UNDO"} - query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + query: bpy.props.StringProperty() """See ``bim.link_ifc``.""" if TYPE_CHECKING: @@ -2443,8 +2443,8 @@ class HideQueriedLinkedElement(bpy.types.Operator): ) bl_options = {"REGISTER", "UNDO"} - unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] - hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) + hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: unhide_all: bool @@ -2920,10 +2920,10 @@ class IFCFileHandlerOperator(bpy.types.Operator): directory: bpy.props.StringProperty( subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"} - ) # pyright: ignore[reportRedeclaration] + ) files: bpy.props.CollectionProperty( type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"} - ) # pyright: ignore[reportRedeclaration] + ) if TYPE_CHECKING: directory: str @@ -2978,7 +2978,7 @@ class MeasureTool(bpy.types.Operator, PolylineOperator): bl_label = "Measure Tool" bl_options = {"REGISTER", "UNDO"} - measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + measure_type: bpy.props.StringProperty() if TYPE_CHECKING: measure_type: str @@ -3077,7 +3077,7 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator): bl_label = "Measure Face Area Tool" bl_options = {"REGISTER", "UNDO"} - measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + measure_type: bpy.props.StringProperty() if TYPE_CHECKING: measure_type: str @@ -3379,7 +3379,7 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator): bl_idname = "bim.load_blend_metadata_and_ifc" bl_label = "Load Blend Metadata and IFC" bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration] + filepath: bpy.props.StringProperty(name="IFC File Path", default="") if TYPE_CHECKING: filepath: str diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 5ba0cf6f28..1408d2f786 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -345,7 +345,7 @@ class BIMProjectProperties(PropertyGroup): ), default=False, ) - should_cache: BoolProperty( # pyright: ignore[reportRedeclaration] + should_cache: BoolProperty( name="Cache", description=( "Cache loaded geometry to .h5 file in your cache directory (see in preferences) " diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index d7755cf80a..ea1d39fb96 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -240,7 +240,7 @@ class CopyPropertyToSelection(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy Property To Selection" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + name: bpy.props.StringProperty() if TYPE_CHECKING: name: str @@ -280,10 +280,10 @@ class BIM_OT_add_property_to_edit(bpy.types.Operator): bl_label = "Add Property to Edit" bl_idname = "bim.add_property_to_edit" bl_options = {"REGISTER", "UNDO"} - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + option: bpy.props.EnumProperty( items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES], ) - index: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty(default=-1) if TYPE_CHECKING: option: tool.Pset.BulkOperationType @@ -307,9 +307,9 @@ class BIM_OT_remove_property_to_edit(bpy.types.Operator): bl_label = "Remove Property from Editing" bl_idname = "bim.remove_property_to_edit" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - index2: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration] - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() + index2: bpy.props.IntProperty(default=-1) + option: bpy.props.EnumProperty( items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES], ) @@ -336,7 +336,7 @@ class BIM_OT_bulk_edit_clear_list(bpy.types.Operator): bl_label = "Clear List of Properties" bl_idname = "bim.pset_bulk_edit_clear_list" bl_options = {"REGISTER", "UNDO"} - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + option: bpy.props.EnumProperty( items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES], ) diff --git a/src/bonsai/bonsai/bim/module/pset/prop.py b/src/bonsai/bonsai/bim/module/pset/prop.py index 1777fa0f94..786e6a62a2 100644 --- a/src/bonsai/bonsai/bim/module/pset/prop.py +++ b/src/bonsai/bonsai/bim/module/pset/prop.py @@ -368,9 +368,9 @@ class GlobalPsetProperties(PropertyGroup): qto_filter: StringProperty(name="Qto Filter", options={"TEXTEDIT_UPDATE"}) # Bulk operations. - psets_to_delete: CollectionProperty(type=DeletePsetEntry) # pyright: ignore[reportRedeclaration] - psets_to_rename: CollectionProperty(type=RenamePropertyEntry) # pyright: ignore[reportRedeclaration] - psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry) # pyright: ignore[reportRedeclaration] + psets_to_delete: CollectionProperty(type=DeletePsetEntry) + psets_to_rename: CollectionProperty(type=RenamePropertyEntry) + psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry) if TYPE_CHECKING: pset_filter: str diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index d5a6b9b1e6..f55dcf31b7 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -799,7 +799,7 @@ class SelectQueryElements(Operator): bl_description = "Select elements matching an provided selector query" bl_options = {"REGISTER", "UNDO"} - query: StringProperty(name="Query") # pyright: ignore[reportRedeclaration] + query: StringProperty(name="Query") if TYPE_CHECKING: query: str @@ -829,12 +829,12 @@ class SaveSearch(Operator, tool.Ifc.Operator): # Extra item so it will be easy to select current text. return [text] + SaveSearch.name_search_items - name: StringProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty( name="Name", search=get_name_search_items, search_options={"SORT"}, ) - module: StringProperty() # pyright: ignore[reportRedeclaration] + module: StringProperty() def update_use_all_ifcgroups(self, context: object = None) -> None: ifc_file = tool.Ifc.get() @@ -845,7 +845,7 @@ class SaveSearch(Operator, tool.Ifc.Operator): } self.name_search_items[:] = natsorted(groups) - use_all_ifcgroups: BoolProperty( # pyright: ignore[reportRedeclaration] + use_all_ifcgroups: BoolProperty( name="Use Any IfcGroup", description=( "By default we're targeting only IfcGroups with SEARCH ObjectType " diff --git a/src/bonsai/bonsai/bim/module/sequence/operator.py b/src/bonsai/bonsai/bim/module/sequence/operator.py index 2b4c317cd8..fb97d7152a 100644 --- a/src/bonsai/bonsai/bim/module/sequence/operator.py +++ b/src/bonsai/bonsai/bim/module/sequence/operator.py @@ -106,7 +106,7 @@ class ActivateStatusFilters(bpy.types.Operator): bl_description = "Filter and display objects based on currently selected IFC statuses" bl_options = {"REGISTER", "UNDO"} - only_if_enabled: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + only_if_enabled: bpy.props.BoolProperty( name="Only If Filters are Enabled", description="Activate status filters only in case if they were enabled from the UI before.", default=False, @@ -137,7 +137,7 @@ class SelectStatusFilter(bpy.types.Operator): bl_description = "Select elements with currently selected status" bl_options = {"REGISTER", "UNDO"} - status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + status: bpy.props.StringProperty() if TYPE_CHECKING: status: tool.Sequence.ElementStatusUI @@ -156,7 +156,7 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Assign status to the selected elements.\n\nAlt+CLICK to unassign the status." bl_options = {"REGISTER", "UNDO"} - should_override_previous_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + should_override_previous_status: bpy.props.BoolProperty( name="Override Previous Status", description=( "Whether assigning new status should override previous one.\n\n" @@ -165,8 +165,8 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator): ), default=True, ) - status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] - should_unassign_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + status: bpy.props.StringProperty() + should_unassign_status: bpy.props.BoolProperty( options={"SKIP_SAVE"}, ) @@ -415,7 +415,7 @@ class CopyWorkSchedule(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy Work Schedule" bl_description = "Create a duplicate of the provided work schedule." bl_options = {"REGISTER", "UNDO"} - work_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + work_schedule: bpy.props.IntProperty() if TYPE_CHECKING: work_schedule: int diff --git a/src/bonsai/bonsai/bim/module/sequence/prop.py b/src/bonsai/bonsai/bim/module/sequence/prop.py index abb1aa9f0c..97f274c421 100644 --- a/src/bonsai/bonsai/bim/module/sequence/prop.py +++ b/src/bonsai/bonsai/bim/module/sequence/prop.py @@ -412,7 +412,7 @@ WorkPlanEditingType = Literal["-", "ATTRIBUTES", "SCHEDULES", "WORK_SCHEDULE", " class BIMWorkPlanProperties(PropertyGroup): work_plan_attributes: CollectionProperty(name="Work Plan Attributes", type=Attribute) - editing_type: EnumProperty( # pyright: ignore[reportRedeclaration] + editing_type: EnumProperty( items=[(i, i, "") for i in get_args(WorkPlanEditingType)], ) work_plans: CollectionProperty(name="Work Plans", type=WorkPlan) @@ -430,8 +430,8 @@ class BIMWorkPlanProperties(PropertyGroup): class IFCStatus(PropertyGroup): - name: StringProperty() # pyright: ignore[reportRedeclaration] - is_visible: BoolProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty() + is_visible: BoolProperty( name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0] ) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index f1eb4ec4ee..cd6bc6cab2 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -220,7 +220,7 @@ class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy to Container" bl_options = {"REGISTER", "UNDO"} - container: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + container: bpy.props.IntProperty() if TYPE_CHECKING: container: int diff --git a/src/bonsai/bonsai/bim/module/structural/operator.py b/src/bonsai/bonsai/bim/module/structural/operator.py index 20b32c7a0c..825f52e954 100644 --- a/src/bonsai/bonsai/bim/module/structural/operator.py +++ b/src/bonsai/bonsai/bim/module/structural/operator.py @@ -167,7 +167,7 @@ class EnableEditingStructuralBoundaryCondition(bpy.types.Operator): bl_idname = "bim.enable_editing_structural_boundary_condition" bl_label = "Enable Editing Structural Boundary Condition" bl_options = {"REGISTER", "UNDO"} - boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + boundary_condition: bpy.props.IntProperty() if TYPE_CHECKING: boundary_condition: int @@ -186,7 +186,7 @@ class EditStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_structural_boundary_condition" bl_label = "Edit Structural Boundary Condition" bl_options = {"REGISTER", "UNDO"} - connection: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + connection: bpy.props.IntProperty() if TYPE_CHECKING: connection: int @@ -917,7 +917,7 @@ class EnableEditingBoundaryCondition(bpy.types.Operator): bl_idname = "bim.enable_editing_boundary_condition" bl_label = "Enable Editing Boundary Condition" bl_options = {"REGISTER", "UNDO"} - boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + boundary_condition: bpy.props.IntProperty() if TYPE_CHECKING: boundary_condition: int diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index e1f6f5e9e4..5fe47564c0 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -54,7 +54,7 @@ class AddSystem(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Add System" bl_options = {"REGISTER", "UNDO"} - parent_system_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + parent_system_id: bpy.props.IntProperty() if TYPE_CHECKING: parent_system_id: int diff --git a/src/bonsai/bonsai/bim/module/web/prop.py b/src/bonsai/bonsai/bim/module/web/prop.py index 42bd272578..e9e5a33185 100644 --- a/src/bonsai/bonsai/bim/module/web/prop.py +++ b/src/bonsai/bonsai/bim/module/web/prop.py @@ -26,16 +26,16 @@ from bpy.types import PropertyGroup class WebProperties(PropertyGroup): - webserver_port: IntProperty( # pyright: ignore[reportRedeclaration] + webserver_port: IntProperty( name="Webserver Port", min=0, max=65535, ) - is_running: BoolProperty( # pyright: ignore[reportRedeclaration] + is_running: BoolProperty( name="Webserver Running Status", default=False, ) - is_connected: BoolProperty( # pyright: ignore[reportRedeclaration] + is_connected: BoolProperty( name="Connection Status", default=False, ) diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index 3216478132..f1ab24c7b0 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -159,9 +159,9 @@ class SelectURIAttribute(bpy.types.Operator, ImportHelper): bl_label = "Select URI Attribute" bl_options = {"REGISTER", "UNDO"} bl_description = "Select a local file" - attribute_data_path: bpy.props.StringProperty(name="Data Path") # pyright: ignore[reportRedeclaration] + attribute_data_path: bpy.props.StringProperty(name="Data Path") """Full data path to `Attribute`/string property.""" - use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + use_relative_path: bpy.props.BoolProperty( name="Use Relative Path", default=False, ) @@ -601,7 +601,7 @@ class CreateMacBonsaiApp(bpy.types.Operator): "ALT+click to uninstall Bonsai app if it was installed previously." ) - uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: uninstall: bool @@ -1667,7 +1667,7 @@ class BIM_OT_attribute_add_subitem(bpy.types.Operator): bl_description = "Add subitem to the current attribute" bl_options = {"REGISTER", "UNDO"} - data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + data_path: bpy.props.StringProperty() """Full data path.""" if TYPE_CHECKING: @@ -1691,9 +1691,9 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator): bl_description = "Add subitem to the current attribute" bl_options = {"REGISTER", "UNDO"} - data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + data_path: bpy.props.StringProperty() """Full data path.""" - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() if TYPE_CHECKING: data_path: str diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index e5d495c62a..e10243d266 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -333,7 +333,7 @@ class Attribute(PropertyGroup): filter_glob: StringProperty() is_null: BoolProperty(name="Is Null", update=update_is_null) is_selected: BoolProperty(name="Is Selected", default=False) - subitems_values: CollectionProperty(type=StrProperty) # pyright: ignore[reportRedeclaration] + subitems_values: CollectionProperty(type=StrProperty) # Attribute parameters. is_optional: BoolProperty(name="Is Optional") @@ -342,7 +342,7 @@ class Attribute(PropertyGroup): value_max: FloatProperty(description="This is used to validate int_value and float_value") value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound") special_type: StringProperty(name="Special Value Type", default="") - use_explorer_ui: BoolProperty() # pyright: ignore[reportRedeclaration] + use_explorer_ui: BoolProperty() metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute") update: StringProperty(name="Update", description="Custom update function to be executed") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 09eee51401..97980d92dd 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -665,7 +665,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False ) should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False) - should_always_cache: BoolProperty( # pyright: ignore[reportRedeclaration] + should_always_cache: BoolProperty( name="Always Cache Geometry", description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.", ) diff --git a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py index 1725f37aa2..350f4ac71f 100644 --- a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py +++ b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py @@ -32,7 +32,7 @@ class SvIfcSbExtrude(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv bl_idname = "SvIfcSbExtrude" bl_label = "IFC Extrude" - extrude_axis: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + extrude_axis: bpy.props.EnumProperty( default="Z", items=[ ("X", "X", "Interpret curve as in XY plane and extrude along X+."), From 80a9df8f52da44ecb3bf42c49d2e902aa4d67f10 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 18:01:16 +0500 Subject: [PATCH 57/76] Ignore pyright warnings for bpy stubs See https://github.com/nutti/fake-bpy-module/discussions/440 --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 13169b7c0b..235d3f6e6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ reportInvalidTypeForm = false disableBytesTypePromotions = true reportUnnecessaryTypeIgnoreComment = true reportRedeclaration = false +# Ignore warnings from bpy stubs missing actual source files. +reportMissingModuleSource = false # Pylance doesn't respect gitignore, so we have to exclude files manually here # to avoid VS Code slowing down. # https://github.com/microsoft/pylance-release/issues/5169 From 5a3160eb626f82761d58ac8d5f756f6ce4787bce Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 18:02:44 +0500 Subject: [PATCH 58/76] black . --- .../bonsai/bim/module/clash/operator.py | 12 +--- .../bonsai/bim/module/drawing/operator.py | 4 +- src/bonsai/bonsai/bim/module/misc/operator.py | 4 +- .../bonsai/bim/module/project/operator.py | 56 +++++-------------- 4 files changed, 19 insertions(+), 57 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/clash/operator.py b/src/bonsai/bonsai/bim/module/clash/operator.py index fb1af71774..ae5f622bbd 100644 --- a/src/bonsai/bonsai/bim/module/clash/operator.py +++ b/src/bonsai/bonsai/bim/module/clash/operator.py @@ -201,15 +201,9 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): "ALT+click to run a quick clash without selecting a file to save." ) - filter_glob: bpy.props.StringProperty( - default="*.bcf;*.json", options={"HIDDEN"} - ) - format: bpy.props.EnumProperty( - name="Format", items=[(i, i, "") for i in ("bcf", "json")] - ) - filepath: bpy.props.StringProperty( - subtype="FILE_PATH", options={"SKIP_SAVE"} - ) + filter_glob: bpy.props.StringProperty(default="*.bcf;*.json", options={"HIDDEN"}) + format: bpy.props.EnumProperty(name="Format", items=[(i, i, "") for i in ("bcf", "json")]) + filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) quick_clash: bpy.props.BoolProperty( options={"SKIP_SAVE"}, ) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index d33047378a..d6594364bf 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3640,9 +3640,7 @@ class ToggleTargetView(bpy.types.Operator): default=False, options={"SKIP_SAVE"}, ) - option: bpy.props.EnumProperty( - items=[(i, i, "") for i in get_args(ToggleOption)] - ) + option: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(ToggleOption)]) if TYPE_CHECKING: target_view: str diff --git a/src/bonsai/bonsai/bim/module/misc/operator.py b/src/bonsai/bonsai/bim/module/misc/operator.py index 31317f5b95..203bc9dd6c 100644 --- a/src/bonsai/bonsai/bim/module/misc/operator.py +++ b/src/bonsai/bonsai/bim/module/misc/operator.py @@ -453,9 +453,7 @@ class MoveQuickFavoritesItem(bpy.types.Operator): bl_label = "Move Quick Favorites Item" bl_options = {"REGISTER", "UNDO"} index: bpy.props.IntProperty() - direction: bpy.props.EnumProperty( - items=[("UP", "Up", ""), ("DOWN", "Down", "")] - ) + direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")]) if TYPE_CHECKING: index: int diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 9ad9c082c8..d38c4f3b68 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -86,9 +86,7 @@ class NewProject(bpy.types.Operator): bl_label = "New Project" bl_options = {"REGISTER", "UNDO"} bl_description = "Start a new IFC project in a fresh session" - preset: bpy.props.EnumProperty( - items=[(i, i, "") for i in get_args(PresetType)] - ) + preset: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(PresetType)]) if TYPE_CHECKING: preset: PresetType @@ -178,13 +176,9 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_description = ( "Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file." ) - filter_glob: bpy.props.StringProperty( - default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"} - ) + filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) append_all: bpy.props.BoolProperty(default=False) - use_relative_path: bpy.props.BoolProperty( - name="Use Relative Path", default=False - ) + use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) if TYPE_CHECKING: filter_glob: str @@ -602,9 +596,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): ) definition: bpy.props.IntProperty() prop_index: bpy.props.IntProperty() - assume_unique_by_name: bpy.props.BoolProperty( - name="Assume Unique By Name", default=True, options={"SKIP_SAVE"} - ) + assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}) if TYPE_CHECKING: definition: int @@ -959,12 +951,8 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): bl_label = "Load Project" bl_options = {"REGISTER", "UNDO"} bl_description = "Load an existing IFC project" - filepath: bpy.props.StringProperty( - subtype="FILE_PATH", options={"SKIP_SAVE"} - ) - filter_glob: bpy.props.StringProperty( - default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"} - ) + filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) + filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}) is_advanced: bpy.props.BoolProperty( name="Enable Advanced Mode", description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings", @@ -988,9 +976,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): ), default=False, ) - use_detailed_tooltip: bpy.props.BoolProperty( - default=False, options={"HIDDEN"} - ) + use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) filename_ext = ".ifc" if TYPE_CHECKING: @@ -1882,21 +1868,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper): bl_options = {"REGISTER", "UNDO"} filename_ext = ".ifc" supported_filexts = (".ifc", ".ifczip", ".ifcjson") - filter_glob: bpy.props.StringProperty( - default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"} - ) - json_version: bpy.props.EnumProperty( - items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version" - ) - json_compact: bpy.props.BoolProperty( - name="Export Compact IFCJSON", default=False - ) - should_save_as: bpy.props.BoolProperty( - name="Should Save As", default=False, options={"HIDDEN"} - ) - use_relative_path: bpy.props.BoolProperty( - name="Use Relative Path", default=False - ) + filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}) + json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version") + json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False) + should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) + use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) if TYPE_CHECKING: filter_glob: str @@ -2918,12 +2894,8 @@ class IFCFileHandlerOperator(bpy.types.Operator): bl_label = "Import .ifc file" bl_options = {"REGISTER", "UNDO", "INTERNAL"} - directory: bpy.props.StringProperty( - subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"} - ) - files: bpy.props.CollectionProperty( - type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"} - ) + directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}) + files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}) if TYPE_CHECKING: directory: str From 98338e08316967a3c0ef45af368ead6db5d3a6e5 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 18:06:21 +0500 Subject: [PATCH 59/76] Rename ci-black-formatting workflow to ci-lint --- .github/workflows/{ci-black-formatting.yaml => ci-lint.yaml} | 2 +- src/bonsai/docs/guides/development/code_style.rst | 2 +- src/bonsai/docs/guides/development/maintenance.rst | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename .github/workflows/{ci-black-formatting.yaml => ci-lint.yaml} (99%) diff --git a/.github/workflows/ci-black-formatting.yaml b/.github/workflows/ci-lint.yaml similarity index 99% rename from .github/workflows/ci-black-formatting.yaml rename to .github/workflows/ci-lint.yaml index f081f02945..ddd1f70189 100644 --- a/.github/workflows/ci-black-formatting.yaml +++ b/.github/workflows/ci-lint.yaml @@ -1,4 +1,4 @@ -name: ci-black-formatting +name: ci-lint on: push: diff --git a/src/bonsai/docs/guides/development/code_style.rst b/src/bonsai/docs/guides/development/code_style.rst index cdc506d5ab..96f3d2b9cc 100644 --- a/src/bonsai/docs/guides/development/code_style.rst +++ b/src/bonsai/docs/guides/development/code_style.rst @@ -7,7 +7,7 @@ Python code formatters For Python code formatting, we use `Black code formatter `__, black settings are stored in the repository's pyproject.toml. -We have GitHub workflow `ci-black-formatting` to maintain black formatting across the repository. +We have GitHub workflow `ci-lint` to maintain black formatting across the repository. ``black`` can be installed using ``pip install black`` and files can be formatted with the following example command: diff --git a/src/bonsai/docs/guides/development/maintenance.rst b/src/bonsai/docs/guides/development/maintenance.rst index e35306de1f..d0de81c757 100644 --- a/src/bonsai/docs/guides/development/maintenance.rst +++ b/src/bonsai/docs/guides/development/maintenance.rst @@ -13,7 +13,7 @@ When adding or removing a supported Python version, update the following: * - File - What to update - * - ``.github/workflows/ci-black-formatting.yaml`` + * - ``.github/workflows/ci-lint.yaml`` - ``MIN_IOS_PY_VERSION`` * - ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` - ``pyver`` matrix @@ -59,7 +59,7 @@ When Blender ships with a new Python version: * - File - What to update - * - ``.github/workflows/ci-black-formatting.yaml`` + * - ``.github/workflows/ci-lint.yaml`` - ``MIN_BLENDER_PY_VERSION`` * - ``src/bonsai/Makefile`` - ``SUPPORTED_PYVERSIONS`` From 0cf831133e159ccb8e0309f0e48cf20d2335de24 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 18:20:47 +0500 Subject: [PATCH 60/76] ci-lint - add `ty` type check --- .github/workflows/ci-lint.yaml | 11 +++++++++++ pyproject.toml | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index ddd1f70189..6dc18453ad 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -30,6 +30,7 @@ jobs: uv tool install ruff uv tool install black uv tool install poethepoet + uv tool install ty # black doesn't catch all syntax errors, so we check them explicitly. - name: Check syntax errors @@ -57,6 +58,13 @@ jobs: black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py continue-on-error: true + - name: ty check + id: ty + run: | + poe ty-venv + poe ty + continue-on-error: true + - name: Ruff check id: ruff run: | @@ -105,4 +113,7 @@ jobs: if [ "${{ steps.ruff.outcome }}" != "success" ]; then echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1 fi + if [ "${{ steps.ty.outcome }}" != "success" ]; then + echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1 + fi exit $ERROR diff --git a/pyproject.toml b/pyproject.toml index 235d3f6e6a..2b9552cdd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ dependencies = [ "black==26.3.1", "ruff==0.15.9", "poethepoet", + "ty==0.0.29", "gersemi==0.26.1", ] @@ -227,7 +228,7 @@ ty.sequence = ["ty-bonsai", "ty-ios"] ty.help = "Run ty type checker. Requires ty-venv to be set up first." ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv" -ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"] +ty-venv.sequence = ["bonsai-deps", "ty-venv-bonsai", "ty-venv-ios"] ty-venv-bonsai.sequence = [ {cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"}, From fa8770c14d9d48057a8534272e60bcedab6a6773 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 18:21:03 +0500 Subject: [PATCH 61/76] ty - drop rules removed from recent version of ty --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2b9552cdd5..eb6b4620d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,6 @@ all = "ignore" # Structural rules (no deep type inference needed, easier to adapt). abstract-method-in-final-class = "error" ambiguous-protocol-member = "error" -byte-string-type-annotation = "error" conflicting-declarations = "error" conflicting-metaclass = "error" cyclic-class-definition = "error" @@ -100,7 +99,6 @@ empty-body = "error" escape-character-in-forward-annotation = "error" final-on-non-method = "error" final-without-value = "error" -fstring-type-annotation = "error" ignore-comment-unknown-rule = "error" implicit-concatenated-string-type-annotation = "error" inconsistent-mro = "error" From 588f3653662a66986c1b2fb63c25b931366ae4c6 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 18:55:49 +0500 Subject: [PATCH 62/76] Remove unused ty ignores - issue is resolved upsteam in stubs --- src/bonsai/bonsai/bim/module/aggregate/decorator.py | 2 +- src/bonsai/bonsai/bim/module/nest/decorator.py | 2 +- src/bonsai/bonsai/bim/module/structural/shader.py | 6 +++--- src/bonsai/bonsai/tool/ifcgit.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/aggregate/decorator.py b/src/bonsai/bonsai/bim/module/aggregate/decorator.py index 2cd1c1bca0..eb389a58bd 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/decorator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/decorator.py @@ -101,7 +101,7 @@ class AggregateDecorator: cls.is_installed = False def dotted_line_shader(self): - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments] + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") vert_out.smooth("FLOAT", "v_ArcLength") shader_info = gpu.types.GPUShaderCreateInfo() diff --git a/src/bonsai/bonsai/bim/module/nest/decorator.py b/src/bonsai/bonsai/bim/module/nest/decorator.py index 4a3637caa6..28c3835ba7 100644 --- a/src/bonsai/bonsai/bim/module/nest/decorator.py +++ b/src/bonsai/bonsai/bim/module/nest/decorator.py @@ -101,7 +101,7 @@ class NestDecorator: cls.is_installed = False def dotted_line_shader(self): - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments] + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") vert_out.smooth("FLOAT", "v_ArcLength") shader_info = gpu.types.GPUShaderCreateInfo() diff --git a/src/bonsai/bonsai/bim/module/structural/shader.py b/src/bonsai/bonsai/bim/module/structural/shader.py index 9688ce9f0e..b9b5a5c7bc 100644 --- a/src/bonsai/bonsai/bim/module/structural/shader.py +++ b/src/bonsai/bonsai/bim/module/structural/shader.py @@ -83,7 +83,7 @@ class DecorationShader: PARALLEL DISTRIBUTED FORCE, DISTRIBUTED MOMENT, """ - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments] + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") vert_out.smooth("VEC3", "forces") vert_out.smooth("VEC3", "co") @@ -203,7 +203,7 @@ class DecorationShader: """param: pattern: type of pattern SINGLE FORCE, SINGLE MOMENT""" - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments] + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") vert_out.smooth("VEC3", "co") shader_info = gpu.types.GPUShaderCreateInfo() @@ -253,7 +253,7 @@ class DecorationShader: def get_planar_shader(self) -> gpu.types.GPUShader: """shader for planar loads""" - vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments] + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") vert_out.smooth("VEC3", "co") shader_info = gpu.types.GPUShaderCreateInfo() diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index 2db7a5a171..49e6440bae 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -286,7 +286,7 @@ class IfcGit: if re.match("^Ifc", obj.name): bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument] + bpy.data.orphans_purge(do_recursive=True) import bonsai.bim.handler from bonsai.bim.module.model.data import AuthoringData From 4896946e784ff2b25035de767b5e9fe2efd79540 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 19:06:14 +0500 Subject: [PATCH 63/76] ty ignore some upstream bpy stubs issues --- .../bonsai/bim/module/geometry/operator.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 7382b093f4..8d075a9605 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -85,7 +85,7 @@ class EditObjectPlacement(bpy.types.Operator, tool.Ifc.Operator): class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_mesh_separate" bl_label = "IFC Mesh Separate" - blender_op = bpy.ops.mesh.separate.get_rna_type() + blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument] bl_description = blender_op.description + ".\nAlso makes sure changes are in sync with IFC." bl_options = {"REGISTER", "UNDO"} blender_type_prop = blender_op.properties["type"] @@ -246,7 +246,7 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): class OverrideOriginSet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_origin_set" - blender_op = bpy.ops.object.origin_set.get_rna_type() + blender_op = bpy.ops.object.origin_set.get_rna_type() # ty: ignore[missing-argument] bl_label = "IFC Origin Set" bl_description = ( blender_op.description + ".\nAlso makes sure changes are in sync with IFC (operator works only on IFC objects)" @@ -801,7 +801,7 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context class OverrideDelete(bpy.types.Operator): bl_idname = "bim.override_object_delete" bl_label = "IFC Delete" - blender_op = bpy.ops.object.delete.get_rna_type() + blender_op = bpy.ops.object.delete.get_rna_type() # ty: ignore[missing-argument] bl_description = ( blender_op.description + ".\nAlso makes sure changes in sync with IFC." @@ -821,7 +821,7 @@ class OverrideDelete(bpy.types.Operator): def poll(cls, context): # Match `object.delete` poll for consistency. # `object.delete` poll just checks for OBJECT mode. - poll = bpy.ops.object.delete.poll() + poll = bpy.ops.object.delete.poll() # ty: ignore[missing-argument] if poll: return True cls.poll_message_set("Only available in OBJECT mode") @@ -1045,7 +1045,7 @@ class SelectedIdsData(NamedTuple): class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_outliner_delete" bl_label = "IFC Delete" - blender_op = bpy.ops.outliner.delete.get_rna_type() + blender_op = bpy.ops.outliner.delete.get_rna_type() # ty: ignore[missing-argument] bl_description = ( blender_op.description + ".\nAlso makes sure changes in sync with IFC." @@ -1060,7 +1060,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator): def poll(cls, context) -> bool: # Match `outliner.delete` poll for consistency. # `outliner.delete` just checks `area.type` == `OUTLINER`. - poll = bpy.ops.outliner.delete.poll() + poll = bpy.ops.outliner.delete.poll() # ty: ignore[missing-argument] if poll: return True cls.poll_message_set("Only available from Outliner.") @@ -1164,7 +1164,7 @@ class OverrideDuplicateMove(bpy.types.Operator): def poll(cls, context) -> bool: # Match `object.duplicate_move` poll for consistency. # `object.duplicate_move` poll checks for OBJECT mode. - poll = bpy.ops.object.duplicate_move.poll() + poll = bpy.ops.object.duplicate_move.poll() # ty: ignore[missing-argument] if poll: return True cls.poll_message_set("Only available in OBJECT mode") @@ -1908,7 +1908,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_object_join" bl_label = "IFC Join" - blender_op = bpy.ops.mesh.separate.get_rna_type() + blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument] bl_description = ( blender_op.description + ".\nAlso makes sure changes are in sync with IFC." @@ -1926,7 +1926,7 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - if not bpy.ops.object.join.poll(): + if not bpy.ops.object.join.poll(): # ty: ignore[missing-argument] cls.poll_message_set("Active object is not EDITable.") return False if not context.selected_editable_objects: From a3efa7e9ee6391946bfae8931e8fbadf0fd22092 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 10 Apr 2026 19:10:15 +0500 Subject: [PATCH 64/76] util.element - fix IfcComplexProperty KeyError when verbose=True (#7921) Introduced by me in b77df1892 --- .../ifcopenshell/util/element.py | 2 +- .../test/util/test_element.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 1c52ebc49d..8a8a817336 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -469,7 +469,7 @@ def get_properties( del data["HasProperties"] results[prop_name] = data if verbose: - results[prop_name] = {"id": data["id"], "class": data["class"], "value": results[prop_name]} + results[prop_name] = {"id": data["id"], "class": data["type"], "value": results[prop_name]} return results diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 5eb6034402..ffd5789f01 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -307,6 +307,35 @@ class TestGetPropertiesIFC4(test.bootstrap.IFC4): } } + def test_getting_complex_properties_verbose(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="pset") + complex_property = self.file.create_entity("IfcComplexProperty", Name="prop", UsageName="usage_name") + ifcopenshell.api.pset.edit_pset(self.file, pset=complex_property, properties={"a": "b"}) + pset.HasProperties = [complex_property] + properties = subject.get_properties(pset.HasProperties, verbose=True) + prop_value = properties["prop"]["value"] + nested_prop = prop_value["properties"]["a"] + assert properties == { + "prop": { + "id": complex_property.id(), + "class": "IfcComplexProperty", + "value": { + "UsageName": "usage_name", + "id": complex_property.id(), + "type": "IfcComplexProperty", + "properties": { + "a": { + "id": nested_prop["id"], + "class": "IfcPropertySingleValue", + "value": "b", + "value_type": "IfcLabel", + } + }, + }, + } + } + class TestGetElementsUsingPset(test.bootstrap.IFC4): def test_run(self): From 158756e9218358a719393bdc739f644bb15d67d9 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 10 Apr 2026 21:45:14 +0200 Subject: [PATCH 65/76] arrange_polygons: settings, simplify based on growing boxes; more... --- src/ifcopenshell-python/ifcopenshell/draw.py | 3 +- src/ifcwrap/IfcGeomWrapper.i | 5 +- src/svgfill/src/arrange_polygons.cpp | 1710 +++++++++++++++--- src/svgfill/src/graph_2d.h | 7 + src/svgfill/src/svgfill.cpp | 12 + src/svgfill/src/svgfill.h | 24 +- 6 files changed, 1503 insertions(+), 258 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 5f6d761ceb..962dbbb34f 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -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] diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index e155c2837e..e992e4beab 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -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>; %template(svg_groups_of_line_segments) std::vector>>; @@ -1287,9 +1288,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type } } - std::vector arrange_polygons(const std::vector& polygons) { + std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons) { std::vector 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"); diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 4fa20c68dc..ba8785a267 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -350,6 +350,8 @@ find_overlaps(const std::vector& polygons) { class DebugWriter { public: + DebugWriter() : enabled_(false) {} + DebugWriter(bool enabled, const std::string& filename_prefix) : enabled_(enabled) { if (enabled_) { @@ -368,6 +370,43 @@ class DebugWriter { } } + DebugWriter(const DebugWriter&) = delete; + + DebugWriter(DebugWriter&& other) noexcept + : obj(std::move(other.obj)), vi(other.vi), svg(std::move(other.svg)), enabled_(other.enabled_), last_segment_name_(std::move(other.last_segment_name_)) + { + other.enabled_ = false; + other.vi = 1; + other.last_segment_name_.clear(); + } + + DebugWriter& operator=(const DebugWriter&) = delete; + + DebugWriter& operator=(DebugWriter&& other) noexcept { + if (this == &other) { + return *this; + } + + if (enabled_) { + svg << "\n"; + obj << std::flush; + obj.close(); + svg.close(); + } + + obj = std::move(other.obj); + svg = std::move(other.svg); + vi = other.vi; + enabled_ = other.enabled_; + last_segment_name_ = std::move(other.last_segment_name_); + + other.enabled_ = false; + other.vi = 1; + other.last_segment_name_.clear(); + + return *this; + } + void write_polygon(const Polygon_2& polygon, const std::string& name) { if (enabled_) { write_polygon_to_obj_(obj, vi, true, polygon, name); @@ -387,7 +426,7 @@ class DebugWriter { obj << "l " << vi++; obj << " " << vi++ << "\n"; - svg << ""; + svg << "\n"; obj << std::flush; } @@ -468,7 +507,7 @@ class DebugWriter { } }; -void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { +void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { // solve overlaps by means of subtraction // loop over overlaps and subtract the smaller polygon from the larger one @@ -576,11 +615,37 @@ void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vector(25, 27); + bool success = false; if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) { + if (is_) { + debug_writer.write_polygon(*mp1, "mp1"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp1); + if (is_) { + debug_writer.write_polygon(*mp1, "mp1b"); + } if ((mp2 = subtract_retain_largest(*poly1, *mp1))) { + if (is_) { + debug_writer.write_polygon(*mp2, "mp2"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp2); + if (is_) { + debug_writer.write_polygon(*mp2, "mp2b"); + } if ((mp3 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE * 2, *mp2)))) { + if (is_) { + debug_writer.write_polygon(*mp3, "mp3"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp3); + if (is_) { + debug_writer.write_polygon(*mp3, "mp3b"); + } if ((mp4 = subtract_retain_largest(*poly2, *mp3))) { + if (is_) { + debug_writer.write_polygon(*mp4, "mp4"); + } *poly1 = *mp2; *poly2 = *mp4; success = true; @@ -777,14 +842,19 @@ Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_h std::tuple< std::map>, std::map>, - std::map, std::vector*>>> -build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) { + std::map, std::vector*>>, + std::map +> +build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) +{ + // Build maps of triangle -> edge and edge -> triangle in order to do traversal on the 'corridor mesh' std::map, std::vector*>> segment_to_facet; std::map, std::vector*>> segment_to_input_facet; std::map, Point_2> segment_to_midpoint; std::map> midpoint_to_segment; std::map*, std::vector>> facet_to_segment; + std::map midpoint_to_edge_length; for (auto& tri : triangular_polygons) { for (size_t i = 0; i < 3; ++i) { @@ -813,6 +883,7 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se if (p1index != input_polygons.end() && p2index != input_polygons.end() && p1index != p2index) { segment_to_midpoint[p.first] = center; midpoint_to_segment[center] = p.first; + midpoint_to_edge_length[center] = std::sqrt(CGAL::to_double(CGAL::squared_distance(p.first.first, p.first.second))); } } @@ -832,7 +903,682 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se } } - return {line_graph, midpoint_to_segment, segment_to_input_facet}; + return {line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length}; +} + +using DPoint = CGAL::Simple_cartesian::Point_2; +using DDir = CGAL::Simple_cartesian::Vector_2; +using DBox = std::array; + +struct CenterLineGraphData { + std::vector points; + std::vector points_double; + std::vector widths; + std::vector> edges; + std::vector> incident_edges; +}; + +struct LineRun { + Point_2 start_exact; + Point_2 end_exact; + DPoint start; + DPoint end; + DDir direction; + double avg_width; + double length; + size_t vertex_count; +}; + +struct RunBoxRecord { + size_t run_index; + DPoint start; + DPoint end; + DDir direction; + double width; + double length; + std::array corners; + DBox bbox; +}; + +struct MergedBoxRecord { + DPoint start; + DPoint end; + DDir direction; + DDir normal; + double avg_width; + double length; + size_t member_count; + std::vector members; + std::array corners; + DBox bbox; + Point_2 exact_start; + Point_2 exact_end; +}; + +struct BoxCluster { + std::vector members; + MergedBoxRecord box; +}; + +struct SnapCandidate { + size_t box_index; + double box_distance; + double line_distance; + Point_2 projection; +}; + +DDir unit(const DDir& a) { + auto n = std::sqrt(a.squared_length()); + if (n < 1.e-9) { + return {0., 0.}; + } + return a / n; +} + +DDir perpendicular(const DDir& a) { + return DDir(-a.y(), a.x()); +} + +DDir canonicalize_like(const DDir& a, const DDir& ref) { + return (a * ref) < 0. ? -a : a; +} + +DPoint to_double_point(const Point_2& p) { + return {CGAL::to_double(p.x()), CGAL::to_double(p.y())}; +} + +Point_2 to_exact_point(const DPoint& p) { + return Point_2(p.x(), p.y()); +} + +double point_line_distance(const DPoint& p, const DPoint& line_point, const DDir& line_dir) { + auto u = unit(line_dir); + auto delta = (p - line_point); + if (u.squared_length() < 1.e-18) { + return std::sqrt(delta.squared_length()); + } + return std::abs(CGAL::determinant(u.x(), u.y(), delta.x(), delta.y())); +} + +double angle_between_dirs_deg(const DDir& a, const DDir& b) { + auto u = unit(a); + auto v = unit(b); + auto c = std::abs(u * v); + if (c > 1.) { + c = 1.; + } + return std::acos(c) * 180. / 3.14159265358979323846; +} + +std::array rectangle_corners(const DPoint& start, const DPoint& end, double width) { + auto u = unit(end - start); + if (u.squared_length() < 1.e-18) { + u = {1., 0.}; + } + auto n = perpendicular(u); + auto ext = width; + auto p0 = start - u * ext; + auto p1 = end + u * ext; + auto w = n * (width / 2.); + return {p0 + w, p1 + w, p1 - w, p0 - w}; +} + +DBox aabb_from_points(const std::array& corners) { + DBox bbox{corners[0], corners[0]}; + for (auto& p : corners) { + bbox[0] = {std::min(bbox[0].x(), p.x()), std::min(bbox[0].y(), p.y())}; + bbox[1] = {std::max(bbox[1].x(), p.x()), std::max(bbox[1].y(), p.y())}; + } + return bbox; +} + +bool aabb_overlap(const DBox& a, const DBox& b, double eps = 1.e-9) { + return a[0].x() <= b[1].x() + eps && + a[1].x() + eps >= b[0].x() && + a[0].y() <= b[1].y() + eps && + a[1].y() + eps >= b[0].y(); +} + +CenterLineGraphData make_center_line_graph_data( + const std::map>& line_graph, + const std::map& midpoint_to_edge_length) +{ + CenterLineGraphData graph; + std::map point_to_index; + + auto ensure_point = [&](const Point_2& p) { + auto it = point_to_index.find(p); + if (it != point_to_index.end()) { + return it->second; + } + auto i = graph.points.size(); + point_to_index[p] = i; + graph.points.push_back(p); + graph.points_double.push_back(to_double_point(p)); + auto wt = midpoint_to_edge_length.find(p); + graph.widths.push_back(wt == midpoint_to_edge_length.end() ? 0. : wt->second); + graph.incident_edges.emplace_back(); + return i; + }; + + for (auto& p : line_graph) { + ensure_point(p.first); + for (auto& q : p.second) { + ensure_point(q); + } + } + + std::set> seen_edges; + for (auto& p : line_graph) { + auto i = ensure_point(p.first); + for (auto& q : p.second) { + auto j = ensure_point(q); + if (i == j) { + continue; + } + auto e = i < j ? std::make_pair(i, j) : std::make_pair(j, i); + if (seen_edges.insert(e).second) { + auto k = graph.edges.size(); + graph.edges.push_back(e); + graph.incident_edges[e.first].push_back(k); + graph.incident_edges[e.second].push_back(k); + } + } + } + + return graph; +} + +double segment_width(const CenterLineGraphData& graph, const std::pair& edge) { + return 0.5 * (graph.widths[edge.first] + graph.widths[edge.second]); +} + +bool edge_supports_same_line( + const DPoint& seed_a, + const DPoint& seed_b, + const DPoint& test_a, + const DPoint& test_b, + double angle_tol_deg = 3., + double line_dist_tol = 0.15) +{ + auto d_seed = seed_b - seed_a; + auto d_test = test_b - test_a; + if (d_seed.squared_length() < 1.e-18 || d_test.squared_length() < 1.e-18) { + return false; + } + if (angle_between_dirs_deg(d_seed, d_test) > angle_tol_deg) { + return false; + } + return + point_line_distance(test_a, seed_a, d_seed) <= line_dist_tol && + point_line_distance(test_b, seed_a, d_seed) <= line_dist_tol; +} + +std::vector runs_from_graph(const CenterLineGraphData& graph, double angle_tol_deg = 3., double line_dist_tol = 0.15) { + std::vector visited(graph.edges.size(), false); + std::vector runs; + + for (size_t seed_ei = 0; seed_ei < graph.edges.size(); ++seed_ei) { + if (visited[seed_ei]) { + continue; + } + + const auto& seed_edge = graph.edges[seed_ei]; + auto seed_a = graph.points_double[seed_edge.first]; + auto seed_b = graph.points_double[seed_edge.second]; + auto seed_dir = seed_b - seed_a; + if (seed_dir.squared_length() < 1.e-18) { + visited[seed_ei] = true; + continue; + } + + std::vector queue = {seed_ei}; + std::set component_edges; + + while (!queue.empty()) { + auto ei = queue.back(); + queue.pop_back(); + if (!component_edges.insert(ei).second) { + continue; + } + + const auto& edge = graph.edges[ei]; + std::array vertices = {edge.first, edge.second}; + for (auto v : vertices) { + for (auto ej : graph.incident_edges[v]) { + if (ej == ei || visited[ej] || component_edges.count(ej)) { + continue; + } + const auto& candidate = graph.edges[ej]; + auto test_a = graph.points_double[candidate.first]; + auto test_b = graph.points_double[candidate.second]; + if (edge_supports_same_line(seed_a, seed_b, test_a, test_b, angle_tol_deg, line_dist_tol)) { + queue.push_back(ej); + } + } + } + } + + for (auto ei : component_edges) { + visited[ei] = true; + } + + std::set component_vertices; + auto ref = unit(seed_dir); + DDir direction_sum{0., 0.}; + double total_length = 0.; + double weighted_width_sum = 0.; + + for (auto ei : component_edges) { + const auto& edge = graph.edges[ei]; + component_vertices.insert(edge.first); + component_vertices.insert(edge.second); + + auto d = graph.points_double[edge.second] - graph.points_double[edge.first]; + auto u = canonicalize_like(unit(d), ref); + direction_sum = direction_sum + u; + + auto len = std::sqrt(d.squared_length()); + total_length += len; + weighted_width_sum += len * segment_width(graph, edge); + } + + auto run_direction = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum); + + double min_t = std::numeric_limits::infinity(); + double max_t = -std::numeric_limits::infinity(); + size_t start_index = *component_vertices.begin(); + size_t end_index = start_index; + for (auto vi : component_vertices) { + auto t = (graph.points_double[vi] - CGAL::ORIGIN) * run_direction; + if (t < min_t) { + min_t = t; + start_index = vi; + } + if (t > max_t) { + max_t = t; + end_index = vi; + } + } + + auto avg_width = total_length < 1.e-9 ? segment_width(graph, seed_edge) : weighted_width_sum / total_length; + + runs.push_back({ + graph.points[start_index], + graph.points[end_index], + graph.points_double[start_index], + graph.points_double[end_index], + run_direction, + avg_width, + std::sqrt((graph.points_double[end_index] - graph.points_double[start_index]).squared_length()), + component_vertices.size() + }); + } + + return runs; +} + +std::vector build_run_box_records(const std::vector& runs) { + std::vector records; + records.reserve(runs.size()); + for (size_t i = 0; i < runs.size(); ++i) { + auto corners = rectangle_corners(runs[i].start, runs[i].end, runs[i].avg_width); + records.push_back({ + i, + runs[i].start, + runs[i].end, + unit(runs[i].end - runs[i].start), + runs[i].avg_width, + runs[i].length, + corners, + aabb_from_points(corners) + }); + } + return records; +} + +template +std::pair projected_interval_on_axis(const T& box, const DDir& axis_u) { + auto u = unit(axis_u); + auto ta = (box.start - CGAL::ORIGIN) * u; + auto tb = (box.end - CGAL::ORIGIN) * u; + return {std::min(ta, tb), std::max(ta, tb)}; +} + +double interval_overlap_length(const std::pair& a, const std::pair& b) { + return std::max(0., std::min(a.second, b.second) - std::max(a.first, b.first)); +} + +template +double boxes_overlap_along_merge_axis(const T& a, const T& b) { + auto d1 = unit(a.end - a.start); + auto d2 = unit(b.end - b.start); + if (d1 * d2 < 0.) { + d2 = {-d2.x(), -d2.y()}; + } + auto merge_axis = unit(d1 + d2); + if (merge_axis.squared_length() < 1.e-18) { + merge_axis = d1; + } + + auto i1 = projected_interval_on_axis(a, merge_axis); + auto i2 = projected_interval_on_axis(b, merge_axis); + auto overlap = interval_overlap_length(i1, i2); + auto small_length = std::min(i1.second - i1.first, i2.second - i2.first); + if (small_length < 1.e-9) { + return false; + } + return overlap / small_length; +} + +MergedBoxRecord merge_cluster_to_box(const std::vector& member_indices, const std::vector& records) { + auto ref = records[member_indices.front()].direction; + DDir direction_sum{0., 0.}; + for (auto i : member_indices) { + auto u = canonicalize_like(records[i].direction, ref); + direction_sum = direction_sum + u * std::max(records[i].length, 1.e-9); + } + + auto u = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum); + auto n = perpendicular(u); + + double tmin = std::numeric_limits::infinity(); + double tmax = -std::numeric_limits::infinity(); + double smin = std::numeric_limits::infinity(); + double smax = -std::numeric_limits::infinity(); + + for (auto i : member_indices) { + for (auto& corner : records[i].corners) { + auto t = (corner - CGAL::ORIGIN) * u; + auto s = (corner - CGAL::ORIGIN) * n; + tmin = std::min(tmin, t); + tmax = std::max(tmax, t); + smin = std::min(smin, s); + smax = std::max(smax, s); + } + } + + auto width = smax - smin; + auto sc = (smin + smax) / 2.; + auto start = u * tmin + n * sc; + auto end = u * tmax + n * sc; + auto corners = rectangle_corners(CGAL::ORIGIN + start, CGAL::ORIGIN + end, width); + + MergedBoxRecord box{ + CGAL::ORIGIN + start, + CGAL::ORIGIN + end, + u, + n, + width, + std::sqrt((end - start).squared_length()), + member_indices.size(), + member_indices, + corners, + aabb_from_points(corners), + to_exact_point(CGAL::ORIGIN + start), + to_exact_point(CGAL::ORIGIN + end) + }; + return box; +} + +std::pair merge_score(const MergedBoxRecord& a, const MergedBoxRecord& b) { + auto ang = angle_between_dirs_deg(a.direction, b.direction); + auto center_a = ((a.start - CGAL::ORIGIN) + (a.end - CGAL::ORIGIN)) / 2.; + auto center_b = ((b.start - CGAL::ORIGIN) + (b.end - CGAL::ORIGIN)) / 2.; + return {ang, std::sqrt((center_b - center_a).squared_length())}; +} + +bool clusters_can_merge(const BoxCluster& a, const BoxCluster& b, double angle_tol_deg = 5., double axis_overlap_ratio_limit = 0.5) { + if (!aabb_overlap(a.box.bbox, b.box.bbox)) { + return false; + } + if (angle_between_dirs_deg(a.box.direction, b.box.direction) > angle_tol_deg) { + return false; + } + if (boxes_overlap_along_merge_axis(a.box, b.box) > axis_overlap_ratio_limit) { + auto a_center = CGAL::ORIGIN + ((a.box.start - CGAL::ORIGIN) + (a.box.end - CGAL::ORIGIN)) / 2.; + auto b_center = CGAL::ORIGIN + ((b.box.start - CGAL::ORIGIN) + (b.box.end - CGAL::ORIGIN)) / 2.; + auto a_dir = a.box.direction; + auto b_dir = b.box.direction; + auto dist = a.box.length < b.box.length ? point_line_distance(a_center, b_center, b_dir) : point_line_distance(b_center, a_center, a_dir); + auto ref = a.box.length < b.box.length ? a.box.avg_width : b.box.avg_width; + return dist < (ref / 4.); + } + return true; +} + +std::vector merge_intersecting_parallel_boxes_iterative(const std::vector& runs) { + auto records = build_run_box_records(runs); + std::vector clusters; + clusters.reserve(records.size()); + for (size_t i = 0; i < records.size(); ++i) { + clusters.push_back({{i}, merge_cluster_to_box({i}, records)}); + } + + while (true) { + std::optional> best_pair; + std::pair best_score; + + for (size_t i = 0; i < clusters.size(); ++i) { + for (size_t j = i + 1; j < clusters.size(); ++j) { + if (!clusters_can_merge(clusters[i], clusters[j])) { + continue; + } + auto score = merge_score(clusters[i].box, clusters[j].box); + if (!best_pair || score < best_score) { + best_pair = std::make_pair(i, j); + best_score = score; + } + } + } + + if (!best_pair) { + break; + } + + auto i = best_pair->first; + auto j = best_pair->second; + std::vector members = clusters[i].members; + members.insert(members.end(), clusters[j].members.begin(), clusters[j].members.end()); + auto merged = BoxCluster{members, merge_cluster_to_box(members, records)}; + + std::vector next_clusters; + next_clusters.reserve(clusters.size() - 1); + for (size_t k = 0; k < clusters.size(); ++k) { + if (k != i && k != j) { + next_clusters.push_back(std::move(clusters[k])); + } + } + next_clusters.push_back(std::move(merged)); + clusters = std::move(next_clusters); + } + + std::vector merged_boxes; + merged_boxes.reserve(clusters.size()); + for (auto& cluster : clusters) { + merged_boxes.push_back(cluster.box); + } + return merged_boxes; +} + +Point_2 project_point_to_line_exact(const Point_2& p, const MergedBoxRecord& box) { + auto d = box.exact_end - box.exact_start; + if (d.squared_length() == 0) { + return box.exact_start; + } + auto t = ((p - box.exact_start) * d) / d.squared_length(); + return box.exact_start + d * t; +} + +boost::optional intersect_infinite_lines_exact(const MergedBoxRecord& a, const MergedBoxRecord& b) { + if (a.exact_start == a.exact_end || b.exact_start == b.exact_end) { + return boost::none; + } + auto x = CGAL::intersection(CGAL::Line_2(a.exact_start, a.exact_end), CGAL::Line_2(b.exact_start, b.exact_end)); + if (!x) { + return boost::none; + } + if (auto* xp = variant_get(&*x)) { + return *xp; + } + return boost::none; +} + +double point_to_oriented_box_distance(const DPoint& p, const MergedBoxRecord& box) { + auto d = box.end - box.start; + auto L = std::sqrt(d.squared_length()); + if (L < 1.e-9) { + return std::sqrt((p - box.start).squared_length()); + } + + auto u = d / L; + auto n = perpendicular(u); + auto rel = p - box.start; + auto t = rel * u; + auto s = rel * n; + + auto tmin = -box.avg_width / 2.; + auto tmax = L + box.avg_width / 2.; + auto smin = -box.avg_width / 2.; + auto smax = box.avg_width / 2.; + + double dt = 0.; + if (t < tmin) { + dt = tmin - t; + } else if (t > tmax) { + dt = t - tmax; + } + + double ds = 0.; + if (s < smin) { + ds = smin - s; + } else if (s > smax) { + ds = s - smax; + } + + return std::hypot(dt, ds); +} + +std::map> snap_points_to_box_axes( + const CenterLineGraphData& graph, + const std::vector& boxes) +{ + std::vector snapped_points(graph.points.size()); + + for (size_t i = 0; i < graph.points.size(); ++i) { + if (boxes.empty()) { + snapped_points[i] = graph.points[i]; + continue; + } + + std::vector candidates; + candidates.reserve(boxes.size()); + for (size_t j = 0; j < boxes.size(); ++j) { + candidates.push_back({ + j, + point_to_oriented_box_distance(graph.points_double[i], boxes[j]), + point_line_distance(graph.points_double[i], boxes[j].start, boxes[j].direction), + project_point_to_line_exact(graph.points[i], boxes[j]) + }); + } + + std::vector containing; + for (auto& candidate : candidates) { + if (candidate.box_distance <= 1.e-9) { + containing.push_back(candidate); + } + } + + auto less = [](const SnapCandidate& a, const SnapCandidate& b) { + if (a.line_distance != b.line_distance) { + return a.line_distance < b.line_distance; + } + return a.box_distance < b.box_distance; + }; + + if (containing.size() >= 2) { + std::sort(containing.begin(), containing.end(), less); + auto& c1 = containing[0]; + auto& c2 = containing[1]; + if (angle_between_dirs_deg(boxes[c1.box_index].direction, boxes[c2.box_index].direction) > 8.) { + if (auto x = intersect_infinite_lines_exact(boxes[c1.box_index], boxes[c2.box_index])) { + snapped_points[i] = *x; + continue; + } + } + snapped_points[i] = c1.projection; + continue; + } + + if (containing.size() == 1) { + snapped_points[i] = containing[0].projection; + continue; + } + + auto best = *std::min_element(candidates.begin(), candidates.end(), [](const SnapCandidate& a, const SnapCandidate& b) { + if (a.box_distance != b.box_distance) { + return a.box_distance < b.box_distance; + } + return a.line_distance < b.line_distance; + }); + snapped_points[i] = best.projection; + } + + std::map> adjacency; + for (auto& edge : graph.edges) { + auto a = snapped_points[edge.first]; + auto b = snapped_points[edge.second]; + if (a == b) { + continue; + } + adjacency[a].insert(b); + adjacency[b].insert(a); + } + + std::map> snapped_graph; + for (auto& p : adjacency) { + snapped_graph[p.first] = {p.second.begin(), p.second.end()}; + } + return snapped_graph; +} + +Graph2D join_segment_runs( + DebugWriter& debug, + const std::map>& line_graph, + const std::map& midpoint_to_edge_length) +{ + auto graph = make_center_line_graph_data(line_graph, midpoint_to_edge_length); + auto runs = runs_from_graph(graph); + runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) { + return run.vertex_count <= 5; + }), runs.end()); + + std::vector run_polygons; + for (auto& r : runs) { + auto ps = rectangle_corners(r.start, r.end, r.avg_width); + std::array exact_corners; + std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) { + return to_exact_point(p); + }); + run_polygons.emplace_back(exact_corners.begin(), exact_corners.end()); + } + debug.write_polygons(run_polygons, "initial_runs"); + run_polygons.clear(); + + auto boxes = merge_intersecting_parallel_boxes_iterative(runs); + + for (auto& r : boxes) { + auto ps = rectangle_corners(r.start, r.end, r.avg_width); + std::array exact_corners; + std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) { + return to_exact_point(p); + }); + run_polygons.emplace_back(exact_corners.begin(), exact_corners.end()); + } + debug.write_polygons(run_polygons, "merged_boxes"); + + auto snapped_graph = snap_points_to_box_axes(graph, boxes); + return Graph2D(snapped_graph); } std::set> find_triangles(const std::map>& line_graph) { @@ -1118,66 +1864,88 @@ std::list> extend_end_vertices_based_on_input( const Graph2D& G, const std::map>& midpoint_to_segment, const std::map, std::vector*>>& segment_to_input_facet, - const Polygon_list& inner_offset, - const SegmentLookup& segment_lookup + const Polygon_list& outer_perimiter, + const SegmentLookup& segment_lookup, + const K::FT& max_projection_distance ){ std::list> constructed_segments; - for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { - if (it->second.size() == 1) { - auto& M = it->first; + std::set processed_vertices; - const std::pair* q = nullptr; + while (true) { + // The idea was to peal off 1-degree vertices when projecting them did not result into + // nearby intersections with the outer perimiter. This in case there would be turns near + // the perimeter, which would be eliminated by pealing off the vertices, which would then + // require out of the loop because of invalidated iterators. For now we decided to stick + // to a projection of the vertex onto the perimeter segment when the projection distance + // exceeds a threshold. + bool broke_out = false; - if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { - typename K::FT min_sq_distance = std::numeric_limits::infinity(); - for (auto& pa : midpoint_to_segment) { - if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { - q = &pa.second; - min_sq_distance = CGAL::squared_distance(pa.first, M); - } + for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { + if (it->second.size() == 1) { + auto& M = it->first; + + if (processed_vertices.find(M) != processed_vertices.end()) { + continue; } - } else { - q = &midpoint_to_segment.find(M)->second; - } - if (q == nullptr) { - continue; - } + const std::pair* q = nullptr; - bool handled_as_graph_path = false; + if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { + typename K::FT min_sq_distance = std::numeric_limits::infinity(); + for (auto& pa : midpoint_to_segment) { + if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { + q = &pa.second; + min_sq_distance = CGAL::squared_distance(pa.first, M); + } + } + } else { + q = &midpoint_to_segment.find(M)->second; + } - // distance from unioned - shoot ray? - if (segment_to_input_facet.find(*q)->second.size() == 2) { - for (auto& bnd : inner_offset) { - // if point M is contained in bnd interior: - // if (!bnd.has_on_unbounded_side(M)) { - if (bnd.has_on_bounded_side(M)) { - auto& incoming = *it->second.begin(); - // create ray incoming -> M - CGAL::Ray_2 ray(incoming, M - incoming); - // intersect ray with boundary - boost::optional> closest_segment; - boost::optional> closest_intersection_point; - K::FT sq_distance_along_ray = std::numeric_limits::infinity(); - for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { - const auto& seg = *jt; - auto x = CGAL::intersection(ray, seg); - if (x) { - if (auto* xp = variant_get>(&*x)) { - auto dist = ((*xp) - M).squared_length(); - if (dist < sq_distance_along_ray) { - closest_segment = seg; - closest_intersection_point = *xp; - sq_distance_along_ray = dist; + if (q == nullptr) { + continue; + } + + bool handled_as_graph_path = false; + + // distance from unioned - shoot ray? + if (segment_to_input_facet.find(*q)->second.size() == 2) { + for (auto& bnd : outer_perimiter) { + // if point M is contained in bnd interior: + // if (!bnd.has_on_unbounded_side(M)) { + if (bnd.has_on_bounded_side(M)) { + auto& incoming = *it->second.begin(); + // create ray incoming -> M + CGAL::Ray_2 ray(incoming, M - incoming); + + // intersect ray with boundary + boost::optional> closest_segment; + boost::optional> closest_intersection_point; + K::FT sq_distance_along_ray = std::numeric_limits::infinity(); + for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { + const auto& seg = *jt; + auto x = CGAL::intersection(ray, seg); + if (x) { + if (auto* xp = variant_get>(&*x)) { + auto dist = ((*xp) - M).squared_length(); + if (dist < sq_distance_along_ray) { + if (dist < (max_projection_distance * max_projection_distance)) { + closest_segment = seg; + closest_intersection_point = *xp; + sq_distance_along_ray = dist; + } else { + + } + } } } } - } - if (closest_intersection_point) { - constructed_segments.push_front({M, *closest_intersection_point}); - break; + if (closest_intersection_point) { + constructed_segments.push_front({M, *closest_intersection_point}); + processed_vertices.insert(M); + break; #if 0 Graph2D GGG(bnd); GGG.refine(*GGG.query(*closest_intersection_point, 0.01), *closest_intersection_point); @@ -1217,12 +1985,35 @@ std::list> extend_end_vertices_based_on_input( break; } #endif - } else { - std::cerr << "Warning: no intersection found when extending end vertex, this will likely result in invalid topology" << std::endl; + } else { + + // Loop over boundary segments, and project point onto it, take the closest + K::FT closest_distance = std::numeric_limits::infinity(); + boost::optional> closest_point; + for (auto& poly : outer_perimiter) { + for (auto jt = poly.edges_begin(); jt != poly.edges_end(); ++jt) { + auto seg = *jt; + auto Pp = seg.supporting_line().projection(M); + if (seg.has_on(Pp)) { + auto d = CGAL::squared_distance(Pp, M); + if (d < (max_projection_distance * max_projection_distance)) { + if (d < closest_distance) { + closest_distance = d; + closest_point = Pp; + } + } + } + } + } + + if (closest_point) { + constructed_segments.push_front({M, *closest_point}); + processed_vertices.insert(M); + } + } } } } - } #if 0 if (!handled_as_graph_path) { @@ -1267,6 +2058,11 @@ std::list> extend_end_vertices_based_on_input( constructed_segments.push_front({avg, R}); } #endif + } + } + + if (!broke_out) { + break; } } @@ -1355,8 +2151,6 @@ void fuse_corridor_halves_with_input(Arrangement_2& arr, Graph2D& G, SegmentL } } -#include - class Segment_2_less { public: bool operator()(const Segment_2& a, const Segment_2& b) const { @@ -1367,7 +2161,112 @@ class Segment_2_less { } }; -void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { +std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& right) { + + using Walk_pl = CGAL::Arr_walk_along_line_point_location; + Walk_pl walk_pl(right); + + std::set visited_faces_on_right; + + std::vector return_values; + + for (auto it = left.faces_begin(); it != left.faces_end(); ++it) { + if (!it->is_unbounded()) { + // convert arr facet to polygon with holes + auto polygon_exterior = circ_to_poly(it->outer_ccb()); + Polygon_with_holes_2 pwh(polygon_exterior); + for (auto hit = it->inner_ccbs_begin(); hit != it->inner_ccbs_end(); ++hit) { + pwh.add_hole(circ_to_poly(*hit)); + } + + CGAL::Polygon_triangulation_decomposition_2 decompositor; + std::vector temp; + decompositor(pwh, std::back_inserter(temp)); + + std::set visited_points; + + while (true) { + // select triangle edge that has largest squared edge length times distance from polygon exterior + K::FT max_score = -std::numeric_limits::infinity(); + Point_2 best_point; + for (auto& tri : temp) { + for (size_t i = 0; i < 3; ++i) { + size_t j = (i + 1) % 3; + auto& pi = tri.vertex(i); + auto& pj = tri.vertex(j); + + auto center_point = CGAL::ORIGIN + (((pi - CGAL::ORIGIN) + (pj - CGAL::ORIGIN)) / 2); + + K::FT min_dist = std::numeric_limits::infinity(); + for (auto eit = polygon_exterior.edges_begin(); eit != polygon_exterior.edges_end(); ++eit) { + auto ep = eit->source(); + auto eq = eit->target(); + Segment_2 seg(ep, eq); + auto dist = CGAL::squared_distance(center_point, seg); + if (dist < min_dist) { + min_dist = dist; + } + } + + auto sq_length = CGAL::squared_distance(pi, pj); + + auto score = sq_length * min_dist; + if (score > max_score && visited_points.count(center_point) == 0) { + max_score = score; + best_point = center_point; + } + } + } + + auto res = walk_pl.locate(best_point); + if (auto* v = variant_get(&res)) { + if (visited_faces_on_right.count(*v) > 0) { + return_values.push_back(0); + } else { + // convert arr facet to polygon with holes + auto polygon_exterior = circ_to_poly((*v)->outer_ccb()); + Polygon_with_holes_2 pwh_right(polygon_exterior); + for (auto hit = (*v)->inner_ccbs_begin(); hit != (*v)->inner_ccbs_end(); ++hit) { + pwh_right.add_hole(circ_to_poly(*hit)); + } + + // compute intersection over union of pwh and the original polygon + if (CGAL::do_intersect(pwh, pwh_right)) { + std::vector result; + CGAL::intersection(pwh, pwh_right, std::back_inserter(result)); + typename K::FT intersection_area = 0; + for (auto& r : result) { + auto poly_area = r.outer_boundary().area(); + for (auto& h : r.holes()) { + poly_area -= h.area(); + } + intersection_area += poly_area; + } + CGAL::Polygon_with_holes_2 poly12; + CGAL::join(pwh, pwh_right, poly12); + typename K::FT union_area = poly12.outer_boundary().area(); + for (auto& h : poly12.holes()) { + union_area -= h.area(); + } + return_values.push_back(intersection_area / union_area); + } else { + return_values.push_back(0); + } + } + visited_faces_on_right.insert(*v); + break; + } else { + // Not in facet on right, retry another point + continue; + } + } + } + } + + return return_values; +} + +void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) { using SK = CGAL::Simple_cartesian; CGAL::Cartesian_converter C{}; @@ -1418,7 +2317,7 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { auto [dv, dl] = get_dir(s); best = std::min(best, angle(dv)); } - return (best + 0.1) / own_length; + return (best + 0.01) / own_length; }; std::map badnesses; @@ -1426,7 +2325,6 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { badnesses[e] = edge_badness(e); } - double thr; { std::vector tmp; tmp.reserve(badnesses.size()); @@ -1435,12 +2333,12 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { } std::nth_element(tmp.begin(), tmp.begin() + tmp.size() / 2, tmp.end()); double med = tmp[tmp.size() / 2]; - thr = 10.0 * med; + threshold = 4.0 * med; } std::set bad_edges; for (auto& p : badnesses) { - if (p.second > thr) { + if (p.second > threshold) { bad_edges.insert(p.first); } } @@ -1568,7 +2466,46 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { return best_x; }; + auto process_modifications = [&]( + Arrangement_2& arr_, + const std::set>& to_remove_, + const std::vector>& to_insert_) { + for (auto& e : to_remove_) { + bool removed = false; + for (auto he = arr_.edges_begin(); he != arr_.edges_end(); ++he) { + auto a = he->source()->point(); + auto b = he->target()->point(); + if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { + CGAL::remove_edge(arr_, he); + removed = true; + break; + } + } + if (!removed) { + std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; + } + } + + for (auto& pq : to_insert_) { + if (pq.first == pq.second) { + continue; + } + CGAL::insert(arr_, Segment_2(pq.first, pq.second)); + } + }; + + size_t path_index = 0; for (auto& path : bad_paths) { + decltype(to_remove) to_remove_this_path; + decltype(to_insert) to_insert_this_path; + + for (size_t i = 0; i < path.size() - 1; ++i) { + auto& a = path[i]; + auto& b = path[i + 1]; + + debug_output.write_segment(a, b, "arr_bad_path path_nr_" + std::to_string(path_index)); + } + auto x = collapse_path(path); if (!x) { // std::cerr << "Unable to collapse path, skipping" << std::endl; @@ -1589,12 +2526,10 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { continue; } - std::cerr << "new_length: " << new_length << " orig_length: " << orig_length << std::endl; - for (size_t i = 0; i < path.size(); ++i) { auto& v = path[i]; if (CGAL::squared_distance(v, *x) < 1.e-5) { - std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl; + // std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl; continue; } } @@ -1604,75 +2539,314 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { auto& b = path[i + 1]; if (a < b) { to_remove.insert({a, b}); + to_remove_this_path.insert({a, b}); } else { to_remove.insert({b, a}); + to_remove_this_path.insert({b, a}); } } auto s = path.front(); auto t = path.back(); if (s != *x) { to_insert.push_back({s, *x}); + to_insert_this_path.push_back({s, *x}); + + debug_output.write_segment(s, *x, "corrected_path path_nr_" + std::to_string(path_index)); } if (t != *x) { to_insert.push_back({t, *x}); + to_insert_this_path.push_back({t, *x}); + + debug_output.write_segment(t, *x, "corrected_path path_nr_" + std::to_string(path_index)); } + + path_index += 1; + +#if 1 + process_modifications(arr, to_remove_this_path, to_insert_this_path); +#else + auto arr_copy = arr; + process_modifications(arr_copy, to_remove_this_path, to_insert_this_path); + auto ious = arrangement_cell_iou(arr, arr_copy); + for (auto& iou : ious) { + std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl; + } + std::swap(arr_copy, arr); +#endif } - /* - using Walk_pl = CGAL::Arr_walk_along_line_point_location; - Walk_pl walk_pl(arr); + process_modifications(arr, to_remove, to_insert); +} - for (auto& e : to_remove) { - // debug_output.write_segment(e->source()->point(), e->target()->point(), "arr_bad_remove"); - auto res = walk_pl.locate(e.first); - if (auto* v = boost::get(&res)) { - Arrangement_2::Halfedge_around_vertex_circulator first, curr; - first = curr = (*v)->incident_halfedges(); - size_t i = 0; - std::array pts; - std::array hes; - do { - Arrangement_2::Vertex_const_handle u = curr->source(); - hes[i] = curr; - pts[i++] = u->point(); - } while (++curr != first); +template +void next_circular(typename Vec::const_iterator& it, const Vec& vec) { + std::advance(it, 1); + if (it == vec.end()) { + it = vec.begin(); + } +} +template +void previous_circular(typename Vec::const_iterator& it, const Vec& vec) { + if (it == vec.begin()) { + it = vec.end(); + } + std::advance(it, -1); +} - if ((*v)->point() != e.first) { - std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; - continue; +template +std::size_t circular_distance(typename Vec::const_iterator first, + typename Vec::const_iterator last, + const Vec& vec) { + if (first <= last) { + return static_cast(last - first); + } + return static_cast(vec.end() - first) + static_cast(last - vec.begin()); +} + +template +std::pair +longest_wrapping_true_run(const Vec& v, Pred pred) { + using It = typename Vec::const_iterator; + + const auto n = v.size(); + if (n == 0) { + return {v.end(), v.end()}; + } + + // Find best non-wrapping run + std::size_t best_len = 0; + std::size_t best_start = 0; + + std::size_t curr_len = 0; + std::size_t curr_start = 0; + + for (std::size_t i = 0; i < n; ++i) { + if (pred(v[i])) { + if (curr_len == 0) { + curr_start = i; + } + ++curr_len; + if (curr_len > best_len) { + best_len = curr_len; + best_start = curr_start; } } else { - std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; - continue; + curr_len = 0; } } - */ - for (auto& e : to_remove) { - bool removed = false; - for (auto he = arr.edges_begin(); he != arr.edges_end(); ++he) { - auto a = he->source()->point(); - auto b = he->target()->point(); - if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { - CGAL::remove_edge(arr, he); - removed = true; - break; + // Count leading true + std::size_t leading = 0; + while (leading < n && pred(v[leading])) { + ++leading; + } + + // All true + if (leading == n) { + return {v.begin(), v.end()}; + } + + // Count trailing true + std::size_t trailing = 0; + while (trailing < n && pred(v[n - 1 - trailing])) { + ++trailing; + } + + // Wrapped run = [n - trailing, n) + [0, leading) + const std::size_t wrapped_len = leading + trailing; + + if (wrapped_len > best_len) { + It first = v.begin() + static_cast(n - trailing); + It last = v.begin() + static_cast(leading); + return {first, last}; + } + + It first = v.begin() + static_cast(best_start); + It last = first + static_cast(best_len); + return {first, last}; +} + +void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double threshold) { + using SK = CGAL::Simple_cartesian; + CGAL::Cartesian_converter C{}; + + auto other = [](const Segment_2& e, const Point_2& v) { + return (e.source() == v) ? e.target() : e.source(); + }; + + auto edge_badness = [&](const Segment_2& e) -> double { + auto closest = segment_lookup.n_closest_input_segments(e, 2); + if (closest.size() != 2) { + throw std::runtime_error("Unable to locate two nearby edges"); + } + + auto get_dir = [&](const Segment_2& s) { + auto a = C(s.source()); + auto b = C(s.target()); + SK::Vector_2 v = b - a; + double l = std::sqrt(v.squared_length()); + if (l <= 1e-12) { + return std::make_pair(SK::Vector_2(0, 0), 0.); + } + return std::make_pair(v / l, l); + }; + + auto [own_dir, own_length] = get_dir(e); + + auto angle = [&](const SK::Vector_2& ov) { + double d = std::abs(own_dir * ov); + if (d > 1.0) { + d = 1.0; + } + return std::acos(d); + }; + + double best = std::numeric_limits::infinity(); + for (auto& s : closest) { + auto [dv, dl] = get_dir(s); + best = std::min(best, angle(dv)); + } + return (best + 0.01) / own_length; + }; + + size_t facet_index = 0; + for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it, ++facet_index) { + if (!it->is_unbounded()) { + std::set> to_remove; + std::vector> to_insert; + + std::vector segs; + std::vector vertices; + std::vector halfedges; + + auto circ = it->outer_ccb(); + do { + auto a = circ->source()->point(); + auto b = circ->target()->point(); + segs.emplace_back(a, b); + vertices.push_back(circ->source()); + halfedges.push_back(circ); + ++circ; + } while (circ != it->outer_ccb()); + + std::vector badnesses; + for (auto& e : segs) { + badnesses.push_back(edge_badness(e)); + } + + auto bit = std::min_element(badnesses.begin(), badnesses.end()); + if (*bit > threshold) { + // std::cerr << "All edges are good, skipping" << std::endl; + continue; + } + + auto it_pair = longest_wrapping_true_run(badnesses, [&](double d) { return d > threshold; }); + auto N = circular_distance(it_pair.first, it_pair.second, badnesses); + + if (N == 0) { + // std::cerr << "Unable to find run of bad edges, skipping" << std::endl; + continue; + } + + std::vector> incoming_paths; + + auto jt = it_pair.first; + for (std::size_t k = 0; k < N; ++k, next_circular(jt, badnesses)) { + + auto he = halfedges[std::distance(badnesses.cbegin(), jt)]; + to_remove.insert({he->source()->point(), he->target()->point()}); + debug_output.write_segment(he->source()->point(), he->target()->point(), "arr_bad_bound facet_" + std::to_string(facet_index)); + + Arrangement_2::Vertex_handle v = he->source(); + + // circle around other edges onto v + Arrangement_2::Halfedge_around_vertex_circulator first, curr; + first = curr = v->incident_halfedges(); + do { + Arrangement_2::Vertex_handle u = curr->source(); + if (curr->face() != it && curr->twin()->face() != it) { + + // loop until we find a 3-degree vertex, or we come back to the start + std::vector path{v->point(), u->point()}; + auto he = curr; + + while (u->degree() == 2 && u != v && path.size() < 10) { + std::vector hes; + + { + Arrangement_2::Halfedge_around_vertex_circulator first, curr; + first = curr = u->incident_halfedges(); + do { + hes.push_back(curr); + curr++; + } while (curr != first); + } + + auto next_he = hes.front() != he && hes.front() != he->twin() ? hes.front() : hes.back(); + auto next_v = next_he->target() != u ? next_he->target() : next_he->source(); + + path.push_back(next_v->point()); + u = next_v; + } + incoming_paths.push_back(std::move(path)); + } + } while (++curr != first); + } + + const std::size_t start = + static_cast(std::distance(badnesses.cbegin(), it_pair.first)); + + auto n = badnesses.size(); + + auto wrap = [n](std::ptrdiff_t i) -> std::size_t { + i %= static_cast(n); + if (i < 0) { + i += static_cast(n); + } + return static_cast(i); + }; + + const std::size_t ib = start; + const std::size_t ia = wrap(static_cast(start) - 1); + const std::size_t ic = wrap(static_cast(start + N)); + const std::size_t id = wrap(static_cast(start + N + 1)); + + auto a = vertices.begin() + static_cast(ia); + auto b = vertices.begin() + static_cast(ib); + auto c = vertices.begin() + static_cast(ic); + auto d = vertices.begin() + static_cast(id); + + CGAL::Ray_2 r1((*a)->point(), (*b)->point()); + CGAL::Ray_2 r2((*d)->point(), (*c)->point()); + + auto x = CGAL::intersection(r1, r2); + if (x) { + if (auto* xp = variant_get>(&*x)) { + to_insert.emplace_back((*b)->point(), *xp); + to_insert.emplace_back((*c)->point(), *xp); + + debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + } + } else { + CGAL::Line_2 r1((*a)->point(), (*b)->point()); + CGAL::Line_2 r2((*d)->point(), (*c)->point()); + + auto x = CGAL::intersection(r1, r2); + if (x) { + if (auto* xp = variant_get>(&*x)) { + to_insert.emplace_back((*b)->point(), *xp); + to_insert.emplace_back((*c)->point(), *xp); + + debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + } + } } } - if (!removed) { - std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; - } } - - for (auto& pq : to_insert) { - if (pq.first == pq.second) { - continue; - } - CGAL::insert(arr, Segment_2(pq.first, pq.second)); - // debug_output.write_segment(pq.first, pq.second, "arr_bad_insert"); - } - } void remove_colinear_vertices(Arrangement_2& arr) { @@ -1725,20 +2899,31 @@ class timer { public: class entry { public: + entry() {} + entry(std::map::const_iterator start_it) : start_it(start_it) {} + void stop() { - auto end = std::chrono::high_resolution_clock::now(); - auto duration = std::chrono::duration(end - start_it->second).count(); - std::cerr << "Timing for " << start_it->first << ": " << duration << " ms" << std::endl; + if (start_it) { + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration(end - start_it.value()->second).count(); + std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl; + } } private: - std::map::const_iterator start_it; + std::optional::const_iterator> start_it; }; + timer(bool enabled = true) : enabled_(enabled) {} + entry start(const std::string& name) { - return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + if (enabled_) { + return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + } else { + return entry(); + } } private: @@ -1746,27 +2931,30 @@ class timer { std::string, std::chrono::high_resolution_clock::time_point> timings_; + + bool enabled_; }; -void arrange_cgal_polygons(const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { +void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1; // even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied // no, `1.e-2 + 1.e-5` creates issues with the outer perimeter, are there other tolerances in play? static const double OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT = 1.e-5; -#ifdef SVGFILL_DEBUG - auto t = std::time(nullptr); - auto tm = *std::localtime(&t); + DebugWriter debug_output; + if (settings.debug_output) { + auto t = std::time(nullptr); + auto tm = *std::localtime(&t); - std::ostringstream oss; - oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); - auto now = oss.str(); - DebugWriter debug_output(true, now); -#else - DebugWriter debug_output(false, ""); -#endif + std::ostringstream oss; + oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); + auto now = oss.str(); + debug_output = DebugWriter(true, now); + } else { + debug_output = DebugWriter(false, ""); + } - timer timer; + timer timer(settings.debug_output); auto t0 = timer.start("input"); @@ -1794,7 +2982,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v t0.stop(); t0 = timer.start("overlap elimination"); - eliminate_overlaps(OVERLAP_RESOLUTION_DISTANCE, input_polygons); + eliminate_overlaps(debug_output, OVERLAP_RESOLUTION_DISTANCE, input_polygons); t0.stop(); @@ -1813,79 +3001,79 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v debug_output.write_polygons(input_polygons, "processed_input"); -#if 1 - t0 = timer.start("outer perimeter"); - - // Find the outer perimeter using offset - union - negative offset - std::vector offset_polygons; - for (auto& r : input_polygons) { - auto R = r; - if (!R.is_counterclockwise_oriented()) { - R.reverse_orientation(); - } - - // Overlap removal can also result in close points causing problems when converted into non-exact nt - remove_close_points(R); - - auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); - for (auto& p : ps) { - if (!p.is_simple()) { - throw std::runtime_error("Complex polygon originated from offset"); - } - } - offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); - } - - debug_output.write_polygons(offset_polygons, "offset_input"); - - // Perform Boolean union on the offset polygons - std::vector unioned_polygons; - CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); - - if (unioned_polygons.size() > 1) { - // @todo this is currently one of the major limitations in the code that still can be eliminated - // by grouping the input polygons by their perimiter polygon in unioned_polygons - std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); - } - - debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); - - Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); - remove_close_points(fused_removed_close_points, 1.e-4); - - // Apply negative offset to get the outer perimeter polygon - auto outer_perimiter = create_and_convert_offset_polygon( - // Because polygon_offset is inexact, make sure our inset distance is slightly larger - // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), - - // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter - -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, - fused_removed_close_points); - - debug_output.write_polygons(outer_perimiter, "outer_perimiter"); -#else - std::map> neighbour_map; - build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); - - auto start_vertex = neighbour_map.rbegin()->first; - auto next_vertex = neighbour_map.rbegin()->second.front(); - - std::vector cycle = {start_vertex, next_vertex}; - while (cycle.back() != cycle.front()) { - const auto& incoming_from = *(cycle.rbegin() + 1); - const auto& nb = neighbour_map[cycle.back()]; - auto it = std::find(nb.begin(), nb.end(), incoming_from); - // cycle it -1 around nb - if (it == nb.begin()) { - it == nb.end() - 1; - } else { - --it; - } - cycle.push_back(*it); - } std::vector outer_perimiter; - outer_perimiter.emplace_back(cycle.begin(), cycle.end()); -#endif + if (settings.outer_perimiter_algo == 0) { + t0 = timer.start("outer perimeter"); + + // Find the outer perimeter using offset - union - negative offset + std::vector offset_polygons; + for (auto& r : input_polygons) { + auto R = r; + if (!R.is_counterclockwise_oriented()) { + R.reverse_orientation(); + } + + // Overlap removal can also result in close points causing problems when converted into non-exact nt + remove_close_points(R); + + auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); + for (auto& p : ps) { + if (!p.is_simple()) { + throw std::runtime_error("Complex polygon originated from offset"); + } + } + offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); + } + + debug_output.write_polygons(offset_polygons, "offset_input"); + + // Perform Boolean union on the offset polygons + std::vector unioned_polygons; + CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); + + if (unioned_polygons.size() > 1) { + // @todo this is currently one of the major limitations in the code that still can be eliminated + // by grouping the input polygons by their perimiter polygon in unioned_polygons + std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); + } + + debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); + + Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); + remove_close_points(fused_removed_close_points, 1.e-4); + + // Apply negative offset to get the outer perimeter polygon + outer_perimiter = create_and_convert_offset_polygon( + // Because polygon_offset is inexact, make sure our inset distance is slightly larger + // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), + + // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter + -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, + fused_removed_close_points); + + debug_output.write_polygons(outer_perimiter, "outer_perimiter"); + } else { + std::map> neighbour_map; + build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); + + auto start_vertex = neighbour_map.rbegin()->first; + auto next_vertex = neighbour_map.rbegin()->second.front(); + + std::vector cycle = {start_vertex, next_vertex}; + while (cycle.back() != cycle.front()) { + const auto& incoming_from = *(cycle.rbegin() + 1); + const auto& nb = neighbour_map[cycle.back()]; + auto it = std::find(nb.begin(), nb.end(), incoming_from); + // cycle it -1 around nb + if (it == nb.begin()) { + it = nb.end() - 1; + } else { + --it; + } + cycle.push_back(*it); + } + outer_perimiter.emplace_back(cycle.begin(), cycle.end()); + } t0.stop(); t0 = timer.start("corridor creation"); @@ -1911,8 +3099,10 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v // subdivide difference_result to have better more detailed triangulation and therefore less-pronounced artefacts in midpoint network + auto subdivision_length = polygon_offset_distance / settings.subdivision_factor; + for (auto& pwh : difference_result) { - difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 8., pwh)); + difference_result_subdivided.push_back(subdivide_polygon(subdivision_length, pwh)); // difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 64., pwh)); } @@ -1940,7 +3130,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v SegmentLookup segment_lookup(input_polygons); - auto [line_graph, midpoint_to_segment, segment_to_input_facet] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); + auto [line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); for (auto& p : line_graph) { for (auto& q : p.second) { debug_output.write_segment(p.first, q, "network_1"); @@ -1950,38 +3140,48 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v t0.stop(); t0 = timer.start("center line cleaning"); - - auto triangles = find_triangles(line_graph); - // For every triangle found in the network we eliminate one edge to break the cycle - // The edge we eliminate is the edge with the greatest angle with any of it's neighbours - auto eliminated_segments = eliminate_triangles(line_graph); + Graph2D G; + if (settings.line_cleaning_algo == 0) { + G = join_segment_runs(debug_output, line_graph, midpoint_to_edge_length); + Arrangement_2 arr; + G.to_arrangement(arr); + Graph2D G2; + G2.from_arrangement(arr); + eliminate_colinear_vertices(G2); + G = G2; + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_2"); + } + } else { + auto eliminated_segments = eliminate_triangles(line_graph); - Graph2D G2(line_graph); - for (auto& e : eliminated_segments) { - debug_output.write_segment(e.first, e.second, "eliminated"); - G2.remove_edge(e.first, e.second); - } + Graph2D G2(line_graph); + for (auto& e : eliminated_segments) { + debug_output.write_segment(e.first, e.second, "eliminated"); + G2.remove_edge(e.first, e.second); + } - auto G = G2.weld_vertices(); + G = G2.weld_vertices(); - for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { - debug_output.write_segment(it->first, it->second, "network_2"); - } + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_2"); + } - eliminate_colinear_vertices(G); + eliminate_colinear_vertices(G); - edge_slide(G); + edge_slide(G); - for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { - debug_output.write_segment(it->first, it->second, "network_3"); + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_3"); + } } t0.stop(); t0 = timer.start("topology"); - auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup); + auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4); // Now plot the edges on an arrangement in order to find planar cycles // and merge the corridor-halves with their neighbouring input polygon @@ -1997,31 +3197,31 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v debug_output.write_segment(pq.first, pq.second, "extended_segments"); } -#if 0 - // Write input polygons to arrangement_2 - // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter - for (auto& poly : input_polygons) { - for (size_t i = 0; i != poly.size(); ++i) { - auto j = (i + 1) % poly.size(); - if (poly.vertex(i) == poly.vertex(j)) { - continue; + if (settings.topology_reconstruction_algo != 0) { + // Write input polygons to arrangement_2 + // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter + for (auto& poly : input_polygons) { + for (size_t i = 0; i != poly.size(); ++i) { + auto j = (i + 1) % poly.size(); + if (poly.vertex(i) == poly.vertex(j)) { + continue; + } + CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); + } + } + } else { + // Write outer perimeter to arrangement_2 + for (auto& p : outer_perimiter) { + for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source = it->source(); + auto target = it->target(); + if (source == target) { + continue; + } + CGAL::insert(arr, Segment_2(source, target)); } - CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); } } -#else - // Write outer perimeter to arrangement_2 - for (auto& p : outer_perimiter) { - for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { - auto source = it->source(); - auto target = it->target(); - if (source == target) { - continue; - } - CGAL::insert(arr, Segment_2(source, target)); - } - } -#endif // Just for the automatic numbering, create a full vector std::vector temp; @@ -2047,12 +3247,17 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v // corridor network we know it needs to be joined with an input polygon. In that // case the edges need to be eliminated that correspond to original geometry. -#if 0 - fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); -#else - remove_colinear_vertices(arr); - clean_noisy_paths(arr, segment_lookup); -#endif + if (settings.topology_reconstruction_algo != 0) { + fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); + } + + if (settings.perform_cleanup && settings.line_cleaning_algo != 0) { + remove_colinear_vertices(arr); + double threshold; + clean_noisy_paths(debug_output, arr, segment_lookup, threshold); + remove_colinear_vertices(arr); + clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); + } t0.stop(); @@ -2068,8 +3273,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v #ifndef SVGFILL_MAIN -bool svgfill::arrange_polygons(const std::vector& polygons, std::vector& arranged) -{ +bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged) { std::vector cgal_polygons, cgal_polygons_out; std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) { Polygon_2 result; @@ -2078,7 +3282,7 @@ bool svgfill::arrange_polygons(const std::vector& polygons, }); return result; }); - arrange_cgal_polygons(cgal_polygons, cgal_polygons_out); + arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out); std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) { svgfill::polygon_2 result; std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) { @@ -2128,7 +3332,7 @@ int main(int argc, char** argv) { input_polygons.back().push_back(CGAL::Point_2(x, y)); } } - arrange_cgal_polygons(input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); break; } return 0; @@ -2141,7 +3345,7 @@ int main(int argc, char** argv) { input_polygons = { rect1, rect2, rect3, rect4, rect5 }; } - arrange_cgal_polygons(input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); return 0; } diff --git a/src/svgfill/src/graph_2d.h b/src/svgfill/src/graph_2d.h index da2b5014ec..d19418ff62 100644 --- a/src/svgfill/src/graph_2d.h +++ b/src/svgfill/src/graph_2d.h @@ -346,6 +346,13 @@ public: } } + template + void from_arrangement(T& arr) { + for (auto it = arr.edges_begin(); it != arr.edges_end(); ++it) { + insert(it->source()->point(), it->target()->point()); + } + } + void assert_symmetric() { #ifdef SVGFILL_DEBUG #if 0 diff --git a/src/svgfill/src/svgfill.cpp b/src/svgfill/src/svgfill.cpp index 8a2a1bb008..cf45881c93 100644 --- a/src/svgfill/src/svgfill.cpp +++ b/src/svgfill/src/svgfill.cpp @@ -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& edge_indices) { if (edge_indices.empty()) { return; diff --git a/src/svgfill/src/svgfill.h b/src/svgfill/src/svgfill.h index 396fc9c924..2588636a8d 100644 --- a/src/svgfill/src/svgfill.h +++ b/src/svgfill/src/svgfill.h @@ -67,6 +67,7 @@ namespace svgfill { virtual std::vector 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>&); 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,25 @@ namespace svgfill { SVGFILL_API std::string polygons_to_svg(const std::vector>& polygons, bool random_color=false); SVGFILL_API std::string polygons_to_svg(const std::vector& polygons, bool random_color = false); SVGFILL_API bool svg_to_polygons(const std::string& data, const boost::optional& class_name, std::vector& polygons); - SVGFILL_API bool arrange_polygons(const std::vector& polygons, std::vector& 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; + // 0: join segment runs + // 1: local badness reduction + int line_cleaning_algo = 0; + bool perform_cleanup = true; + double subdivision_factor = 16.; + }; + + SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged); + } #endif From e7db239647d98580dbb30c3af1bf68a66158f64c Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Fri, 10 Apr 2026 21:45:21 +0200 Subject: [PATCH 66/76] inverse access in schema --- src/ifcparse/IfcSchema.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ifcparse/IfcSchema.h b/src/ifcparse/IfcSchema.h index 3dedd47a8e..349a81532d 100644 --- a/src/ifcparse/IfcSchema.h +++ b/src/ifcparse/IfcSchema.h @@ -358,6 +358,7 @@ class IFC_PARSE_API entity : public declaration { const std::vector& subtypes() const { return subtypes_; } const std::vector& attributes() const { return attributes_; } + const std::vector& inverse_attributes() const { return inverse_attributes_; } const std::vector& derived() const { return derived_; } const std::vector all_attributes() const { From 002b7c5d6ee97075eff7e46106c27c8ae0551a4f Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Fri, 10 Apr 2026 22:09:56 +0100 Subject: [PATCH 67/76] ifcquery, ifcmcp: better bot selector syntax hints --- src/ifcmcp/ifcmcp/core.py | 14 ++++++++++++-- src/ifcquery/ifcquery/select.py | 11 ++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py index ed9f91c3c9..137cdbdce0 100644 --- a/src/ifcmcp/ifcmcp/core.py +++ b/src/ifcmcp/ifcmcp/core.py @@ -278,7 +278,12 @@ class IfcSession: return info.info(model, element) def ifc_select(self, query: str) -> list[dict[str, Any]]: - """Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow').""" + """Filter elements using ifcopenshell selector syntax. + + Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``, + ``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``, + ``material = "Concrete"``. + """ return select.select(self._require_model(), query) def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: @@ -536,7 +541,12 @@ class IfcSession: { "type": "function", "name": "ifc_select", - "description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').", + "description": ( + "Select elements using ifcopenshell selector syntax. " + "Examples: 'IfcWall', 'IfcWall, IfcColumn', '! IfcWall', " + "'IfcWall, Name = \"My Wall\"', 'type = \"Concrete Wall\"', " + "'material = \"Concrete\"'." + ), "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, diff --git a/src/ifcquery/ifcquery/select.py b/src/ifcquery/ifcquery/select.py index c28a159b90..f3ee1dceaa 100644 --- a/src/ifcquery/ifcquery/select.py +++ b/src/ifcquery/ifcquery/select.py @@ -26,7 +26,16 @@ import ifcopenshell.util.selector def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]: - """Filter elements using selector syntax and return matching element summaries.""" + """Filter elements using ifcopenshell selector syntax and return matching element summaries. + + Examples: + - ``IfcWall`` — all walls + - ``IfcWall, IfcColumn`` — walls and columns + - ``! IfcWall`` — everything except walls + - ``IfcWall, Name = "My Wall"`` — walls with a specific name attribute + - ``type = "Concrete Wall"`` — elements assigned that type product + - ``material = "Concrete"`` — elements with that material + """ elements = ifcopenshell.util.selector.filter_elements(model, query) results = [] for element in sorted(elements, key=lambda e: e.id()): From 7788ae86c99fae69b8e0f8a3ba3d70e86954916c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 16:23:41 +0500 Subject: [PATCH 68/76] build-all.py: descriptive error for missing SSL support --- nix/build-all.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/nix/build-all.py b/nix/build-all.py index f5436262d2..a4e261fc70 100644 --- a/nix/build-all.py +++ b/nix/build-all.py @@ -1094,10 +1094,19 @@ if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flag f"http://www.python.org/ftp/python/{PYTHON_VERSION}/", f"Python-{PYTHON_VERSION}.tgz", ) - python_bin = INSTALL_DIR / f"python-{PYTHON_VERSION}" / "bin" / "python3" + python_install = INSTALL_DIR / f"python-{PYTHON_VERSION}" + python_bin = python_install / "bin" / "python3" # `_ssl` module is present -> we will be able to install `numpy` later # to verify IfcOpenShell installation - run([str(python_bin), "-c", "import _ssl"]) + try: + run([str(python_bin), "-c", "import _ssl"]) + except RuntimeError: + print( + "ERROR: Python was built without SSL support (_ssl module is missing). " + f"To fix this: remove the installed Python at {python_install}; " + "install OpenSSL development libraries and re-run." + ) + raise if MAC_CROSS_COMPILE_INTEL: assert original_path From 20229aa88c4efcd5a8ac8d2a76dedecdde1fe4d3 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 16:43:20 +0500 Subject: [PATCH 69/76] README.md: add pyodide-wasm-wheels tag badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 74c2ebe115..a87e023d43 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Contents | [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcfm?label=PyPI&color=006dad)](https://pypi.org/project/ifcfm/) | | [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcmax.html) | [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcmcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcmcp/) | -| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) | +| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [![Pyodide WASM Wheels tag](https://img.shields.io/github/v/tag/ifcopenshell/wasm-wheels?sort=semver&label=pyodide-wasm-wheels)](https://github.com/IfcOpenShell/wasm-wheels) | | [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcpatch?label=PyPI&color=006dad)](https://pypi.org/project/ifcpatch/) | | [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcquery?label=PyPI&color=006dad)](https://pypi.org/project/ifcquery/) | | [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [![GitHub Unstable](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*.*&label=GitHub-Unstable&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true) From 67238c4ac1e580180e2b0f85389c304676b6c2a5 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 14:49:51 +0500 Subject: [PATCH 70/76] ci-pyodide-wasm-release - use BUILD_REPO_TOKEN --- .github/workflows/ci-pyodide-wasm-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-pyodide-wasm-release.yml b/.github/workflows/ci-pyodide-wasm-release.yml index eff7bc30d9..4f04644de3 100644 --- a/.github/workflows/ci-pyodide-wasm-release.yml +++ b/.github/workflows/ci-pyodide-wasm-release.yml @@ -29,7 +29,7 @@ jobs: with: repository: IfcOpenShell/wasm-wheels path: wasm-wheels - token: ${{ secrets.WASM_WHEELS_TOKEN }} + token: ${{ secrets.BUILD_REPO_TOKEN }} - name: Commit and push wheel to wasm-wheels run: | From 16723d11cab9bc8a13b4e025a00d39445ccc462e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 14:49:26 +0500 Subject: [PATCH 71/76] ci-pyodide-wasm-release - add tag when pushing release --- .github/workflows/ci-pyodide-wasm-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-pyodide-wasm-release.yml b/.github/workflows/ci-pyodide-wasm-release.yml index 4f04644de3..0e3017f819 100644 --- a/.github/workflows/ci-pyodide-wasm-release.yml +++ b/.github/workflows/ci-pyodide-wasm-release.yml @@ -40,4 +40,7 @@ jobs: git config user.email "ifcopenbot@ifcopenshell.org" git add "$WHEEL_NAME" git commit -m "Add $WHEEL_NAME" + VERSION=$(cat ../VERSION) + git tag "v${VERSION}" git push origin main + git push origin "v${VERSION}" From 4b8c6126479de28fd4ea54f9476588a6808a6a33 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 18:34:16 +0500 Subject: [PATCH 72/76] fix ifcmcp package name inconsistency --- README.md | 2 +- src/ifcmcp/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a87e023d43..fb87f3f4ec 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Contents | [ifcedit](https://docs.ifcopenshell.org/ifcedit.html) | CLI wrapper for ifcopenshell.api IFC model mutation functions | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcedit?label=PyPI&color=006dad)](https://pypi.org/project/ifcedit/) | | [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcfm?label=PyPI&color=006dad)](https://pypi.org/project/ifcfm/) | | [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcmax.html) -| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcmcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcmcp/) | +| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcopenshell-mcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell-mcp/) | | [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [![Pyodide WASM Wheels tag](https://img.shields.io/github/v/tag/ifcopenshell/wasm-wheels?sort=semver&label=pyodide-wasm-wheels)](https://github.com/IfcOpenShell/wasm-wheels) | | [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcpatch?label=PyPI&color=006dad)](https://pypi.org/project/ifcpatch/) | | [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcquery?label=PyPI&color=006dad)](https://pypi.org/project/ifcquery/) | diff --git a/src/ifcmcp/README.md b/src/ifcmcp/README.md index a0fe01c8ea..6d513bfd07 100644 --- a/src/ifcmcp/README.md +++ b/src/ifcmcp/README.md @@ -8,10 +8,10 @@ sessions. ## Installation ```bash -pip install ifcmcp +pip install ifcopenshell-mcp ``` -Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately. +Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcopenshell-mcp[mcp]` or add `mcp` separately. ## Running the server From 763a31a31dbd10269949c89955e72dbe3d825a2e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 18:57:42 +0500 Subject: [PATCH 73/76] readme: fix ifcsverchok badge filter --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fb87f3f4ec..a44a6ff78c 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Contents | [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [![Pyodide WASM Wheels tag](https://img.shields.io/github/v/tag/ifcopenshell/wasm-wheels?sort=semver&label=pyodide-wasm-wheels)](https://github.com/IfcOpenShell/wasm-wheels) | | [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcpatch?label=PyPI&color=006dad)](https://pypi.org/project/ifcpatch/) | | [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcquery?label=PyPI&color=006dad)](https://pypi.org/project/ifcquery/) | -| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [![GitHub Unstable](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*.*&label=GitHub-Unstable&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true) +| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true) | [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifctester?label=PyPI&color=006dad)](https://pypi.org/project/ifctester/) | The IfcOpenShell C++ codebase is split into multiple interal libraries: From 89ce32fdfdb95028022e10ee9564d1b9cadeca9a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 19:13:57 +0500 Subject: [PATCH 74/76] Remove redundant docs-deployment.yml workflow The https://github.com/IfcOpenShell/website repo already has bonsai-docs.yml workflow that does the same thing - builds Bonsai docs from the main repo and deploys to bonsaibim_org_docs, so this workflow is redundant and confusing. --- .github/workflows/docs-deployment.yml | 36 --------------------------- 1 file changed, 36 deletions(-) delete mode 100644 .github/workflows/docs-deployment.yml diff --git a/.github/workflows/docs-deployment.yml b/.github/workflows/docs-deployment.yml deleted file mode 100644 index 3ff50b575e..0000000000 --- a/.github/workflows/docs-deployment.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Build and Deploy Stable Documentation - -on: - workflow_dispatch: # Manual trigger - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - cd src/bonsai/docs - pip install -r requirements.txt # Run pip install from the docs directory - - - name: Build documentation - run: | - cd src/bonsai/docs - make html - - - name: Deploy to GitHub Pages (Stable) - uses: peaceiris/actions-gh-pages@v4 - with: - deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} - external_repository: IfcOpenShell/bonsaibim_org_docs - publish_branch: main - cname: docs.bonsaibim.org - publish_dir: src/bonsai/docs/_build/html \ No newline at end of file From 5db65f404151dd28e507ff571e0ea30f370ca5be Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 18:29:00 +0500 Subject: [PATCH 75/76] maintenance - list all things we do on release --- .../docs/guides/development/maintenance.rst | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/bonsai/docs/guides/development/maintenance.rst b/src/bonsai/docs/guides/development/maintenance.rst index d0de81c757..5550f7ba6c 100644 --- a/src/bonsai/docs/guides/development/maintenance.rst +++ b/src/bonsai/docs/guides/development/maintenance.rst @@ -65,3 +65,41 @@ When Blender ships with a new Python version: - ``SUPPORTED_PYVERSIONS`` * - ``src/bonsai/scripts/dev_environment.py`` - ``PYTHON_VERSION`` mapping (Blender version, bundled Python version) + +Release +------- + +Notes: + +- Typically all packages are released at once using the same version schema +- The ``README.md`` badges can serve as a visual reference for what versions have been released + +Things to update: + +- ``.github/workflows/ci-bcf-pypi.yml`` - release `bcf-client `_ to PyPI +- ``.github/workflows/ci-bonsai.yml`` - release bonsai in GitHub releases +- ``.github/workflows/ci-bsdd-pypi.yaml`` - release `bsdd `_ to PyPI +- ``.github/workflows/ci-ifc4d-pypi.yaml`` - release `ifc4d `_ to PyPI +- ``.github/workflows/ci-ifc5d-pypi.yaml`` - release `ifc5d `_ to PyPI +- ``.github/workflows/ci-ifcclash-pypi.yaml`` - release `ifcclash `_ to PyPI +- ``.github/workflows/ci-ifcconvert.yml`` - release ifcconvert binaries in GitHub releases +- ``.github/workflows/ci-ifccsv-pypi.yaml`` - release `ifccsv `_ to PyPI +- ``.github/workflows/ci-ifcdiff-pypi.yaml`` - release `ifcdiff `_ to PyPI +- ``.github/workflows/ci-ifcedit-pypi.yaml`` - release `ifcedit `_ to PyPI +- ``.github/workflows/ci-ifcfm-pypi.yaml`` - release `ifcfm `_ to PyPI +- ``.github/workflows/ci-ifccityjson-pypi.yaml`` - release `ifccityjson `_ to PyPI +- ``.github/workflows/ci-ifcmcp-pypi.yaml`` - release `ifcopenshell-mcp `_ to PyPI +- ``.github/workflows/ci-ifcopenshell-python.yml`` - release ifcopenshell-python binaries in GitHub releases +- ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` - release `ifcopenshell `_ wheels to PyPI +- ``.github/workflows/ci-ifcpatch-pypi.yaml`` - release `ifcpatch `_ to PyPI +- ``.github/workflows/ci-ifcquery-pypi.yaml`` - release `ifcquery `_ to PyPI +- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases +- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester `_ to PyPI +- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels `_ +- Release Bonsai Blender extension - zip files from ci-bonsai.yml releases should be uploaded manually to `Blender extensions platform `_ +- Publishing documentation and websites (see `website `_ repository): + + - `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org `_ (`ifcopenshell_org_docs `_ repo) + - `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org `_ (`bonsaibim_org_docs `_ repo) + - `main.yml` - publishes `bonsaibim.org `_ (`bonsaibim_org_static_html `_ repo) and `ifcopenshell.org `_ (`ifcopenshell_org_static_html `_ repo) +- ``VERSION`` to the release version - **UPDATE THIS LAST** as all workflows above typically depend on it to set the version correctly From e6258ab4a8e8c76d18b743ea80a6f7699809319b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 13 Apr 2026 19:40:25 +0500 Subject: [PATCH 76/76] Bump VERSION to 0.8.6 Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7ada0d303f..7fc2521fd7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.8.6