mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-07 00:11:36 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f59942c453 | |||
| ab7d9fdf4a | |||
| 5436467fc5 | |||
| 1c7e134d78 | |||
| 97ee4eaef0 | |||
| 30517770e0 | |||
| 5b1ec85f75 | |||
| 12123baafe | |||
| 0a1c54cedc | |||
| 12144f76d3 | |||
| c478da5257 | |||
| c28251a1b1 | |||
| 9bc0588d21 | |||
| 918cc65a0d | |||
| 7350ccd25e | |||
| c1f146966c | |||
| 24acfeaf45 | |||
| 5d748c5b04 | |||
| 5bcf8685ab | |||
| 8f64f75abb | |||
| 434ea74f22 | |||
| fbf2946b69 | |||
| 7af0ec13e5 | |||
| 29e5e0fd1a | |||
| d7de4f8df0 | |||
| 252bd6f4f6 | |||
| 3b28c92414 | |||
| fcbec74521 | |||
| 0f7f960b29 | |||
| b7fc5daf82 | |||
| 1d7a8ca249 | |||
| eaf7950677 | |||
| 95851ff94c |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -69,6 +69,7 @@ classes = (
|
||||
operator.RemoveRepresentation,
|
||||
operator.RemoveRepresentationItem,
|
||||
operator.RemoveRepresentationItemFromShapeAspect,
|
||||
operator.SelectByRepresentationType,
|
||||
operator.SelectConnection,
|
||||
operator.SelectRepresentationItem,
|
||||
operator.SwitchRepresentation,
|
||||
|
||||
@@ -417,6 +417,55 @@ class SelectConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.select_connection(tool.Geometry, connection=tool.Ifc.get().by_id(self.connection))
|
||||
|
||||
|
||||
class SelectByRepresentationType(bpy.types.Operator):
|
||||
bl_idname = "bim.select_by_representation_type"
|
||||
bl_label = "Select By Representation Type"
|
||||
bl_description = (
|
||||
"Select objects whose active representation matches this type. "
|
||||
"Ctrl+Click to also include objects that have this type in any representation (active or not)"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
representation_type: bpy.props.StringProperty()
|
||||
select_inactive: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.select_inactive = event.ctrl
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
ifc = tool.Ifc.get()
|
||||
if not ifc:
|
||||
return {"CANCELLED"}
|
||||
# Strip the "*" suffix used for mapped/resolved representations.
|
||||
target_type = self.representation_type.rstrip("*")
|
||||
matched = 0
|
||||
for obj in context.visible_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
if self.select_inactive:
|
||||
# Ctrl: match any representation on the element, active or not.
|
||||
has_type = any(
|
||||
(ifcopenshell.util.representation.resolve_representation(rep).RepresentationType or "") == target_type
|
||||
for rep in ifcopenshell.util.representation.get_representations_iter(element)
|
||||
)
|
||||
else:
|
||||
# Default: match only the currently active (displayed) representation.
|
||||
active_rep = tool.Geometry.get_active_representation(obj)
|
||||
if active_rep is None:
|
||||
obj.select_set(False)
|
||||
continue
|
||||
resolved = ifcopenshell.util.representation.resolve_representation(active_rep)
|
||||
has_type = (resolved.RepresentationType or "") == target_type
|
||||
obj.select_set(has_type)
|
||||
if has_type:
|
||||
matched += 1
|
||||
mode = "any representation" if self.select_inactive else "active representation"
|
||||
self.report({"INFO"}, f"Selected {matched} object(s) with RepresentationType '{target_type}' ({mode})")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class RemoveConnection(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_connection"
|
||||
bl_label = "Remove Connection"
|
||||
|
||||
@@ -152,7 +152,12 @@ class BIM_PT_representations(Panel):
|
||||
row.label(text=representation["ContextType"])
|
||||
row.label(text=representation["ContextIdentifier"])
|
||||
row.label(text=representation["TargetView"])
|
||||
row.label(text=representation["RepresentationType"])
|
||||
op = row.operator(
|
||||
"bim.select_by_representation_type",
|
||||
text=representation["RepresentationType"],
|
||||
emboss=False,
|
||||
)
|
||||
op.representation_type = representation["RepresentationType"]
|
||||
op = row.operator(
|
||||
"bim.switch_representation",
|
||||
icon="FILE_REFRESH" if representation["is_active"] else "OUTLINER_DATA_MESH",
|
||||
|
||||
@@ -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
|
||||
@@ -892,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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ai-chat.ifcopenshell.org
|
||||
@@ -1,7 +1,7 @@
|
||||
IfcOpenShell AI Assistant
|
||||
=========================
|
||||
|
||||
A web-based client-side (pyodide + OpenAI API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application.
|
||||
A web-based client-side (pyodide + OpenAI, Anthropic, Gemini, or OpenRouter API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application.
|
||||
|
||||
### Setup instructions
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
function parseArguments(argumentsText) {
|
||||
if (!argumentsText) return {};
|
||||
try {
|
||||
return JSON.parse(argumentsText);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function toAnthropicTools(tools = []) {
|
||||
return tools.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
function toAnthropicAssistantContent(message) {
|
||||
const content = [];
|
||||
|
||||
if (message.content) {
|
||||
content.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
input: parseArguments(toolCall.function.arguments),
|
||||
});
|
||||
}
|
||||
|
||||
if (content.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content.length === 1 && content[0].type === "text" ? content[0].text : content;
|
||||
}
|
||||
|
||||
function toAnthropicUserContent(message) {
|
||||
return typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "");
|
||||
}
|
||||
|
||||
function toAnthropicToolResult(message) {
|
||||
return {
|
||||
type: "tool_result",
|
||||
tool_use_id: message.tool_call_id,
|
||||
content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
function splitSystemAndMessages(messages = []) {
|
||||
const system = [];
|
||||
const anthropicMessages = [];
|
||||
let pendingToolResults = [];
|
||||
|
||||
const flushToolResults = () => {
|
||||
if (pendingToolResults.length === 0) return;
|
||||
anthropicMessages.push({ role: "user", content: pendingToolResults });
|
||||
pendingToolResults = [];
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "system") {
|
||||
if (message.content) {
|
||||
system.push(message.content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
pendingToolResults.push(toAnthropicToolResult(message));
|
||||
continue;
|
||||
}
|
||||
|
||||
flushToolResults();
|
||||
|
||||
if (message.role === "user") {
|
||||
anthropicMessages.push({
|
||||
role: "user",
|
||||
content: toAnthropicUserContent(message),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
anthropicMessages.push({
|
||||
role: "assistant",
|
||||
content: toAnthropicAssistantContent(message),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
flushToolResults();
|
||||
|
||||
return {
|
||||
system: system.join("\n\n"),
|
||||
messages: anthropicMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function toChatCompletionResponse(response) {
|
||||
const text = [];
|
||||
const toolCalls = [];
|
||||
|
||||
for (const block of response.content ?? []) {
|
||||
if (block.type === "text") {
|
||||
text.push(block.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const message = { role: "assistant" };
|
||||
const content = text.join("\n").trim();
|
||||
|
||||
if (content) {
|
||||
message.content = content;
|
||||
}
|
||||
|
||||
if (toolCalls.length) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function chat({ apiKey, model, messages, tools }) {
|
||||
const request = splitSystemAndMessages(messages);
|
||||
const 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: anthropicTools,
|
||||
};
|
||||
|
||||
if (request.system) {
|
||||
body.system = [{ type: "text", text: request.system, cache_control: { type: "ephemeral" } }];
|
||||
}
|
||||
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-dangerous-direct-browser-access": "true",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Anthropic error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return toChatCompletionResponse(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
function getChatCompletionsUrl(baseURL) {
|
||||
const root = (baseURL || "https://api.openai.com/v1").replace(/\/+$/, "");
|
||||
return `${root}/chat/completions`;
|
||||
}
|
||||
|
||||
export async function chat({ apiKey, baseURL, model, messages, tools }) {
|
||||
const res = await fetch(getChatCompletionsUrl(baseURL), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model, messages, tools }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenAI error ${res.status}: ${text}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
function getChatCompletionsUrl(baseURL) {
|
||||
const root = (baseURL || "https://openrouter.ai/api/v1").replace(/\/+$/, "");
|
||||
return `${root}/chat/completions`;
|
||||
}
|
||||
|
||||
export async function chat({ apiKey, baseURL, model, messages, tools }) {
|
||||
const res = await fetch(getChatCompletionsUrl(baseURL), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model, messages, tools }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenRouter error ${res.status}: ${text}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
+421
-89
@@ -1,4 +1,132 @@
|
||||
// app.js
|
||||
import * as openaiApi from "./api_openai.js";
|
||||
import * as anthropicApi from "./api_anthropic.js";
|
||||
import * as openrouterApi from "./api_openrouter.js";
|
||||
|
||||
const PROVIDERS = {
|
||||
openai: {
|
||||
api: openaiApi,
|
||||
apiKeyLabel: "OpenAI API key",
|
||||
apiKeyPlaceholder: "sk-...",
|
||||
baseUrlLabel: "Base URL",
|
||||
baseUrlPlaceholder: "https://api.openai.com/v1",
|
||||
baseUrlDefault: "https://api.openai.com/v1",
|
||||
models: [
|
||||
{
|
||||
value: "gpt-5.2",
|
||||
label: "gpt-5.2"
|
||||
},
|
||||
{
|
||||
value: "gpt-5.2-chat-latest",
|
||||
label: "gpt-5.2-chat-latest"
|
||||
},
|
||||
{
|
||||
value: "gpt-5",
|
||||
label: "gpt-5"
|
||||
},
|
||||
{
|
||||
value: "gpt-5-chat-latest",
|
||||
label: "gpt-5-chat-latest"
|
||||
},
|
||||
{
|
||||
value: "gpt-5-mini",
|
||||
label: "gpt-5-mini"
|
||||
},
|
||||
{
|
||||
value: "gpt-5-nano",
|
||||
label: "gpt-5-nano"
|
||||
},
|
||||
{
|
||||
value: "gpt-4.1",
|
||||
label: "gpt-4.1"
|
||||
},
|
||||
{
|
||||
value: "gpt-4.1-mini",
|
||||
label: "gpt-4.1-mini"
|
||||
},
|
||||
{
|
||||
value: "gpt-4.1-nano",
|
||||
label: "gpt-4.1-nano"
|
||||
},
|
||||
],
|
||||
},
|
||||
anthropic: {
|
||||
api: anthropicApi,
|
||||
apiKeyLabel: "Anthropic API key",
|
||||
apiKeyPlaceholder: "sk-ant-...",
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
label: "claude-sonnet-4-6"
|
||||
},
|
||||
{
|
||||
value: "claude-opus-4-6",
|
||||
label: "claude-opus-4-6"
|
||||
},
|
||||
{
|
||||
value: "claude-haiku-4-5-20251001",
|
||||
label: "claude-haiku-4-5"
|
||||
},
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
api: openaiApi,
|
||||
apiKeyLabel: "Gemini API key",
|
||||
apiKeyPlaceholder: "AIza...",
|
||||
baseUrlLabel: "Base URL",
|
||||
baseUrlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
baseUrlDefault: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
models: [
|
||||
{
|
||||
value: "gemini-3-flash-preview",
|
||||
label: "gemini-3-flash-preview"
|
||||
},
|
||||
{
|
||||
value: "gemini-2.5-flash",
|
||||
label: "gemini-2.5-flash"
|
||||
},
|
||||
{
|
||||
value: "gemini-2.5-pro",
|
||||
label: "gemini-2.5-pro"
|
||||
},
|
||||
],
|
||||
},
|
||||
openrouter: {
|
||||
api: openrouterApi,
|
||||
apiKeyLabel: "OpenRouter API key",
|
||||
apiKeyPlaceholder: "sk-or-v1-...",
|
||||
baseUrlLabel: "Base URL",
|
||||
baseUrlPlaceholder: "https://openrouter.ai/api/v1",
|
||||
baseUrlDefault: "https://openrouter.ai/api/v1",
|
||||
models: [
|
||||
{
|
||||
value: "openai/gpt-oss-20b",
|
||||
label: "gpt-oss-20b"
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-oss-120b",
|
||||
label: "gpt-oss-120b"
|
||||
},
|
||||
{
|
||||
value: "mistralai/mistral-small-3.2-24b-instruct",
|
||||
label: "mistral-small-3.2"
|
||||
},
|
||||
{
|
||||
value: "openai/gpt-4.1",
|
||||
label: "gpt-4.1"
|
||||
},
|
||||
{
|
||||
value: "anthropic/claude-sonnet-4-5",
|
||||
label: "claude-sonnet-4-5"
|
||||
},
|
||||
{
|
||||
value: "google/gemini-2.5-pro-preview",
|
||||
label: "gemini-2.5-pro"
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const statusEl = $("status");
|
||||
@@ -6,11 +134,42 @@ const msgsEl = $("msgs");
|
||||
const sendBtn = $("send");
|
||||
const inputEl = $("input");
|
||||
const apiKeyEl = $("apiKey");
|
||||
const apiKeyLabelEl = $("apiKeyLabel");
|
||||
const baseUrlRowEl = $("baseUrlRow");
|
||||
const baseUrlLabelEl = $("baseUrlLabel");
|
||||
const baseUrlEl = $("baseUrl");
|
||||
const thinkingIndicatorEl = $("thinkingIndicator");
|
||||
const modelEl = $("model");
|
||||
const providerEls = document.querySelectorAll('input[name="provider"]');
|
||||
const ifcFileEl = $("ifcFile");
|
||||
const newBtn = $("newModel");
|
||||
const downloadBtn = $("downloadIfc");
|
||||
|
||||
function getProviderValue() {
|
||||
return document.querySelector('input[name="provider"]:checked')?.value || "openai";
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
const provider = PROVIDERS[getProviderValue()];
|
||||
apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}<span class="small">stored in browser memory; only sent to provider servers</span>`;
|
||||
apiKeyEl.placeholder = provider.apiKeyPlaceholder;
|
||||
baseUrlRowEl.hidden = !provider.baseUrlDefault;
|
||||
if (provider.baseUrlDefault) {
|
||||
baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}<span class="small">override the API endpoint for OpenAI-compatible providers</span>`;
|
||||
baseUrlEl.placeholder = provider.baseUrlPlaceholder;
|
||||
baseUrlEl.value = provider.baseUrlDefault;
|
||||
} else {
|
||||
baseUrlEl.value = "";
|
||||
baseUrlEl.placeholder = "";
|
||||
}
|
||||
modelEl.innerHTML = provider.models.map(m => `<option value="${m.value}">${m.label}</option>`).join("");
|
||||
}
|
||||
|
||||
for (const providerEl of providerEls) {
|
||||
providerEl.addEventListener("change", onProviderChange);
|
||||
}
|
||||
onProviderChange();
|
||||
|
||||
function setBusy(isBusy, reason = "") {
|
||||
const controls = [
|
||||
$("send"),
|
||||
@@ -30,32 +189,213 @@ function setBusy(isBusy, reason = "") {
|
||||
browseBtn.tabIndex = isBusy ? -1 : 0;
|
||||
}
|
||||
|
||||
sendBtn.innerHTML = isBusy
|
||||
? `<span class="spinner"></span>`
|
||||
: `Send <span class="material-icons">send</span>`;
|
||||
|
||||
setStatus(isBusy ? (reason || "Working…") : "Ready");
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function sanitizeUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
if (["http:", "https:", "mailto:"].includes(parsed.protocol)) {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text) {
|
||||
const placeholders = [];
|
||||
const addPlaceholder = (html) => {
|
||||
const token = `@@MD${placeholders.length}@@`;
|
||||
placeholders.push({ token, html });
|
||||
return token;
|
||||
};
|
||||
|
||||
let rendered = text;
|
||||
|
||||
rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`<code>${escapeHtml(code)}</code>`));
|
||||
rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
||||
const href = sanitizeUrl(url);
|
||||
if (!href) {
|
||||
return `${label} (${url})`;
|
||||
}
|
||||
return addPlaceholder(
|
||||
`<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">${escapeHtml(label)}</a>`
|
||||
);
|
||||
});
|
||||
|
||||
rendered = escapeHtml(rendered);
|
||||
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
rendered = rendered.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
rendered = rendered.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||||
|
||||
for (const placeholder of placeholders) {
|
||||
rendered = rendered.replaceAll(placeholder.token, placeholder.html);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
|
||||
const html = [];
|
||||
let paragraphLines = [];
|
||||
let quoteLines = [];
|
||||
let listType = null;
|
||||
let listItems = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraphLines.length) return;
|
||||
html.push(`<p>${renderInlineMarkdown(paragraphLines.join(" "))}</p>`);
|
||||
paragraphLines = [];
|
||||
};
|
||||
|
||||
const flushQuote = () => {
|
||||
if (!quoteLines.length) return;
|
||||
const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("<br />");
|
||||
html.push(`<blockquote><p>${quoteBody}</p></blockquote>`);
|
||||
quoteLines = [];
|
||||
};
|
||||
|
||||
const flushList = () => {
|
||||
if (!listItems.length || !listType) return;
|
||||
const items = listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("");
|
||||
html.push(`<${listType}>${items}</${listType}>`);
|
||||
listType = null;
|
||||
listItems = [];
|
||||
};
|
||||
|
||||
const flushAll = () => {
|
||||
flushParagraph();
|
||||
flushQuote();
|
||||
flushList();
|
||||
};
|
||||
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
flushAll();
|
||||
const language = trimmed.slice(3).trim();
|
||||
const codeLines = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !lines[index].trim().startsWith("```")) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
const languageClass = language ? ` class="language-${escapeHtml(language)}"` : "";
|
||||
html.push(`<pre><code${languageClass}>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
flushAll();
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
flushAll();
|
||||
const level = headingMatch[1].length;
|
||||
html.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const quoteMatch = trimmed.match(/^>\s?(.*)$/);
|
||||
if (quoteMatch) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
quoteLines.push(quoteMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quoteLines.length) {
|
||||
flushQuote();
|
||||
}
|
||||
|
||||
const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/);
|
||||
if (unorderedListMatch) {
|
||||
flushParagraph();
|
||||
if (listType && listType !== "ul") {
|
||||
flushList();
|
||||
}
|
||||
listType = "ul";
|
||||
listItems.push(unorderedListMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/);
|
||||
if (orderedListMatch) {
|
||||
flushParagraph();
|
||||
if (listType && listType !== "ol") {
|
||||
flushList();
|
||||
}
|
||||
listType = "ol";
|
||||
listItems.push(orderedListMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listItems.length) {
|
||||
flushList();
|
||||
}
|
||||
|
||||
paragraphLines.push(trimmed);
|
||||
}
|
||||
|
||||
flushAll();
|
||||
|
||||
return html.join("");
|
||||
}
|
||||
|
||||
function addMessage(role, text) {
|
||||
if (text.ok) {
|
||||
text = text.data;
|
||||
}
|
||||
if (typeof text !== "string") {
|
||||
text = JSON.stringify(text, null, 2);
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${role}`;
|
||||
wrap.innerHTML = `
|
||||
<div class="role ${role}">${role}</div>
|
||||
<div class="role ${role}">${role}${role === "tool" ? '<span class="chevron">▶</span>' : ''}</div>
|
||||
<div class="bubble"></div>`;
|
||||
const bubble = wrap.querySelector(".bubble");
|
||||
bubble.textContent = text;
|
||||
if (role === "assistant") {
|
||||
bubble.classList.add("markdown-content");
|
||||
bubble.innerHTML = renderMarkdown(text);
|
||||
} else {
|
||||
bubble.textContent = text;
|
||||
}
|
||||
bubble.onclick = function () {
|
||||
if (bubble.scrollHeight > 100 && role === "tool") {
|
||||
bubble.style.maxHeight = bubble.style.maxHeight == 'none' ? '' : 'none';
|
||||
bubble.style.borderBottom = bubble.style.borderBottom == '' ? 'dotted 2px gray' : '';
|
||||
const expanded = bubble.style.maxHeight === 'none';
|
||||
bubble.style.maxHeight = expanded ? '' : 'none';
|
||||
bubble.style.borderBottom = expanded ? '' : 'dotted 2px gray';
|
||||
wrap.querySelector(".chevron").style.transform = expanded ? '' : 'rotate(90deg)';
|
||||
}
|
||||
}
|
||||
msgsEl.appendChild(wrap);
|
||||
msgsEl.insertBefore(wrap, thinkingIndicatorEl);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
thinkingIndicatorEl.hidden = text !== "Thinking…";
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
}
|
||||
|
||||
const worker = new Worker("./ifc_worker.js", { type: "module" });
|
||||
@@ -75,74 +415,73 @@ function callWorker(type, payload = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
|
||||
// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
|
||||
// ---- Tool schemas (should match ifcmcp.core openai_tools()) ----
|
||||
const tools = [
|
||||
{
|
||||
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
|
||||
parameters: { type: "object", properties: { schema: { type: "string" } }, required: [], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory. 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", name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
|
||||
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.",
|
||||
parameters: { type: "object", properties: {}, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
|
||||
parameters: { type: "object", properties: {}, required: [], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_tree", description: "Get the full spatial hierarchy tree.",
|
||||
parameters: { type: "object", properties: {}, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
|
||||
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
|
||||
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_info", description: "Inspect an entity by STEP id.",
|
||||
parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_info", description: "Inspect an entity by STEP id.",
|
||||
parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.",
|
||||
type: "function", function: { name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.",
|
||||
parameters: {
|
||||
type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } },
|
||||
required: ["element_id"], additionalProperties: false
|
||||
}
|
||||
} }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_clash", description: "Run clash/clearance checks for an element.",
|
||||
type: "function", function: { name: "ifc_clash", description: "Run clash/clearance checks for an element.",
|
||||
parameters: {
|
||||
type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } },
|
||||
required: ["element_id"], additionalProperties: false
|
||||
}
|
||||
} }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.",
|
||||
parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.",
|
||||
parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.",
|
||||
parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.",
|
||||
parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
|
||||
parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.",
|
||||
parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.",
|
||||
parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.",
|
||||
parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
|
||||
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.",
|
||||
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
|
||||
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.",
|
||||
parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_schema", description: "Return IFC class documentation for an entity type.",
|
||||
parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_schema", description: "Return IFC class documentation for an entity type.",
|
||||
parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false } }
|
||||
},
|
||||
{
|
||||
type: "function", name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
|
||||
parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false }
|
||||
type: "function", function: { name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.",
|
||||
parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false } }
|
||||
},
|
||||
];
|
||||
|
||||
@@ -156,85 +495,78 @@ Rules:
|
||||
Be concise. Avoid dumping huge trees unless asked.
|
||||
`;
|
||||
|
||||
let inputItems = []; // running conversation state (Responses API style)
|
||||
let messages = []; // running conversation state (Chat Completions 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,
|
||||
}),
|
||||
});
|
||||
const MAX_TOOL_RESULT_CHARS = 0;
|
||||
const MAX_HISTORY_MESSAGES = 40;
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`OpenAI error ${res.status}: ${text}`);
|
||||
}
|
||||
return await res.json();
|
||||
function truncateToolResult(text) {
|
||||
if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text;
|
||||
return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)";
|
||||
}
|
||||
|
||||
function 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
async function runAgentTurn(userText) {
|
||||
const apiKey = apiKeyEl.value.trim();
|
||||
if (!apiKey) throw new Error("Missing API key");
|
||||
|
||||
// Add user message
|
||||
inputItems.push({ role: "user", content: userText });
|
||||
const provider = PROVIDERS[getProviderValue()];
|
||||
const { chat } = provider.api;
|
||||
const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined;
|
||||
|
||||
messages.push({ role: "user", content: userText });
|
||||
trimHistory();
|
||||
|
||||
// Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const response = await openAIResponsesCreate({
|
||||
const response = await chat({
|
||||
apiKey,
|
||||
baseURL,
|
||||
model: modelEl.value,
|
||||
input: inputItems,
|
||||
messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages],
|
||||
tools,
|
||||
});
|
||||
|
||||
// Keep ALL output items (incl reasoning/tool calls) in the running state.
|
||||
inputItems.push(...(response.output ?? []));
|
||||
const message = response.choices?.[0]?.message;
|
||||
if (!message) throw new Error("No message in response");
|
||||
|
||||
// Show any assistant text immediately
|
||||
const text = extractAssistantText(response);
|
||||
if (text) addMessage("assistant", text);
|
||||
messages.push(message);
|
||||
|
||||
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
|
||||
if (message.content) addMessage("assistant", message.content);
|
||||
|
||||
const calls = message.tool_calls ?? [];
|
||||
if (calls.length === 0) return;
|
||||
|
||||
for (const call of calls) {
|
||||
let args = {};
|
||||
try { args = call.arguments ? JSON.parse(call.arguments) : {}; }
|
||||
try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; }
|
||||
catch { args = {}; }
|
||||
|
||||
addMessage("tool", `→ ${call.name}(${JSON.stringify(args)})`);
|
||||
addMessage("tool", `→ ${call.function.name}(${JSON.stringify(args)})`);
|
||||
|
||||
const toolRes = await callWorker("toolCall", { name: call.name, args });
|
||||
const toolRes = await callWorker("toolCall", { name: call.function.name, args });
|
||||
|
||||
// Feed tool result back to the model
|
||||
inputItems.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.call_id,
|
||||
output: JSON.stringify(toolRes.result),
|
||||
const fullResult = JSON.stringify(toolRes.result);
|
||||
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: truncateToolResult(fullResult),
|
||||
});
|
||||
|
||||
addMessage("tool", `← ${call.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
|
||||
// Show full result in UI, but only truncated version goes to the LLM
|
||||
addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +583,7 @@ sendBtn.onclick = async () => {
|
||||
await runAgentTurn(text);
|
||||
setBusy(false, "Ready");
|
||||
} catch (e) {
|
||||
setBusy(true, "Error");
|
||||
setBusy(false, "Error");
|
||||
addMessage("assistant", `Error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
@@ -281,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) {
|
||||
@@ -317,4 +649,4 @@ downloadBtn.onclick = async () => {
|
||||
setBusy(true, "Error");
|
||||
addMessage("assistant", `Worker init failed: ${e.message}`);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
+43
-238
@@ -5,233 +5,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>IfcOpenShell AI Assistant</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
header input,
|
||||
header select {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.side {
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 100px 12px 12px 12px;
|
||||
overflow: auto;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.msgs {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.msg .role {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
|
||||
.role.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.msg .bubble {
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.user .bubble {
|
||||
padding: 10px 20px;
|
||||
background: #eee;
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
border: solid 1px #ddd;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 60%;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 88px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.composer .inner {
|
||||
border: solid 1px #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
section>.row>label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
section>.row>label .small {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.side .row {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
#model {
|
||||
background: white;
|
||||
color: gray;
|
||||
border: solid 1px #eee;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: dashed 1px #ddd;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-row>.btn,
|
||||
.btn-row>button.btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #eee;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Disabled state for label-button + normal buttons */
|
||||
.btn.disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#input {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#input:focus,
|
||||
#input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 90%;
|
||||
background-color: #eee;
|
||||
border: solid 1px #ddd;
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
@@ -239,14 +13,46 @@
|
||||
<main>
|
||||
<section class="side">
|
||||
<div class="row">
|
||||
<label>OpenAI API key<span class="small">stored in browser memory; only sent to OpenAI servers</span></label>
|
||||
<label>Provider</label>
|
||||
<div class="provider-tabs" role="radiogroup" aria-label="Provider">
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="openai" checked />
|
||||
<span>OpenAI</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="anthropic" />
|
||||
<span>Claude</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="gemini" />
|
||||
<span>Gemini</span>
|
||||
</label>
|
||||
<label class="provider-tab">
|
||||
<input type="radio" name="provider" value="openrouter" />
|
||||
<span>OpenRouter</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Model</label>
|
||||
<select id="model" style="width: 100%;"></select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label id="apiKeyLabel">API key<span class="small">stored in browser memory; only sent to provider servers</span></label>
|
||||
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="row" id="baseUrlRow">
|
||||
<label id="baseUrlLabel">Base URL<span class="small">override the API endpoint for OpenAI-compatible providers</span></label>
|
||||
<input id="baseUrl" type="text" placeholder="https://api.openai.com/v1" autocomplete="off" style="width: 100%;" />
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="row" style="margin-bottom: 10px;">
|
||||
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to OpenAI servers</span></label>
|
||||
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to provider servers</span></label>
|
||||
|
||||
<div class="btn-row">
|
||||
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
|
||||
@@ -290,15 +96,9 @@
|
||||
|
||||
<section class="chat">
|
||||
<header>
|
||||
<img src="https://ifcopenshell.org/assets/images/logo.png" style="float: left; padding: 10px 20px 10px 0; ">
|
||||
<div class="row">
|
||||
|
||||
<img src="https://ifcopenshell.org/assets/images/logo.png" style="float: left; padding: 10px 20px 10px 0; ">
|
||||
|
||||
<strong>IfcOpenShell AI Assistant</strong>
|
||||
<select id="model">
|
||||
<option value="gpt-5">gpt-5</option>
|
||||
<option value="gpt-4.1">gpt-4.1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -306,7 +106,12 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="msgs" id="msgs"></div>
|
||||
<div class="msgs" id="msgs">
|
||||
<div class="thinking-indicator" id="thinkingIndicator" hidden>
|
||||
<span class="spinner"></span>
|
||||
<span>thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="inner">
|
||||
<textarea id="input" placeholder="Ask or instruct about the IFC model…"></textarea>
|
||||
@@ -319,4 +124,4 @@
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
header input,
|
||||
header select {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.side {
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.msgs {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.thinking-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #555;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.thinking-indicator[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinking-indicator .spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.msg .role {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
margin: 12px 0 4px 0;
|
||||
}
|
||||
|
||||
.role.user {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.msg .bubble {
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg.assistant .bubble {
|
||||
padding: 10px 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-content p,
|
||||
.markdown-content ul,
|
||||
.markdown-content ol,
|
||||
.markdown-content blockquote,
|
||||
.markdown-content pre {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
margin-left: 0;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid #ddd;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: #f2f2f2;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
overflow-x: auto;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.msg.user .bubble {
|
||||
padding: 10px 20px;
|
||||
background: #eee;
|
||||
width: 50%;
|
||||
margin-left: auto;
|
||||
border: solid 1px #ddd;
|
||||
}
|
||||
|
||||
.msg.tool .bubble {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 60%;
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
transition: transform 0.2s;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
height: 88px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.composer button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.composer .inner {
|
||||
border: solid 1px #ddd;
|
||||
border-radius: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #aaa;
|
||||
border-top-color: #333;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
section > .row > label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
margin-bottom: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
section > .row > label .small {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.side .row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.provider-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
background: #00000010;
|
||||
padding: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.provider-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.provider-tab input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.provider-tab span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 8px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
background: #e8e8e8;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.provider-tab input:checked + span {
|
||||
background: #f9f9f9;
|
||||
border-color: #999;
|
||||
color: #111;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.provider-tab input:focus-visible + span {
|
||||
outline: 2px solid #666;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.row button {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
|
||||
hr {
|
||||
border: dashed 1px #ddd;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-row > .btn,
|
||||
.btn-row > button.btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #eee;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.btn-wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn.disabled,
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#input {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#input:focus,
|
||||
#input:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 90%;
|
||||
background-color: #eee;
|
||||
border: solid 1px #ddd;
|
||||
padding: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
header .row:nth-child(2) {
|
||||
padding: 15px 0 0 0;
|
||||
}
|
||||
@@ -52,6 +52,15 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i
|
||||
if (inst->OffsetVertical().has_value()) {
|
||||
auto offset_vertical = inst->OffsetVertical().get() * length_unit_;
|
||||
o += offset_vertical * z;
|
||||
|
||||
auto tmp1 = (z * offset_vertical).eval();
|
||||
auto tmp2 = (Eigen::Vector3d(0, 0, 1) * offset_vertical).eval();
|
||||
auto tmp3 = (tmp1 - tmp2).eval();
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << "local z: " << z.x() << "," << z.y() << "," << z.z() << "; delta: " << tmp3.x() << "," << tmp3.y() << "," << tmp3.z();
|
||||
auto osss = oss.str();
|
||||
std::wcout << osss.c_str() << std::endl;
|
||||
}
|
||||
|
||||
if (inst->OffsetLongitudinal().has_value()) {
|
||||
|
||||
@@ -42,6 +42,7 @@ WHITE = numpy.array((1.0, 1.0, 1.0))
|
||||
|
||||
DO_NOTHING = lambda *args: None
|
||||
|
||||
ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, 'arrange_polygon_settings') else None
|
||||
|
||||
@dataclass
|
||||
class draw_settings:
|
||||
@@ -536,7 +537,7 @@ def main(
|
||||
*(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i))
|
||||
)
|
||||
|
||||
arranged = W.arrange_polygons(polies)
|
||||
arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies)
|
||||
svg_data_3 = W.polygons_to_svg(arranged, False)
|
||||
dom3 = parseString(svg_data_3)
|
||||
svg3 = dom3.childNodes[0]
|
||||
|
||||
@@ -358,6 +358,7 @@ class IFC_PARSE_API entity : public declaration {
|
||||
|
||||
const std::vector<const entity*>& subtypes() const { return subtypes_; }
|
||||
const std::vector<const attribute*>& attributes() const { return attributes_; }
|
||||
const std::vector<const inverse_attribute*>& inverse_attributes() const { return inverse_attributes_; }
|
||||
const std::vector<bool>& derived() const { return derived_; }
|
||||
|
||||
const std::vector<const attribute*> all_attributes() const {
|
||||
|
||||
@@ -1166,6 +1166,7 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
|
||||
%ignore svgfill::line_segments_to_polygons;
|
||||
%ignore svgfill::svg_to_polygons;
|
||||
%ignore svgfill::arrange_polygons;
|
||||
%ignore svgfill::abstract_arrangement;
|
||||
|
||||
%template(svg_line_segments) std::vector<std::array<svgfill::point_2, 2>>;
|
||||
%template(svg_groups_of_line_segments) std::vector<std::vector<std::array<svgfill::point_2, 2>>>;
|
||||
@@ -1287,9 +1288,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<svgfill::polygon_2> arrange_polygons(const std::vector<svgfill::polygon_2>& polygons) {
|
||||
std::vector<svgfill::polygon_2> arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector<svgfill::polygon_2>& polygons) {
|
||||
std::vector<svgfill::polygon_2> r;
|
||||
if (svgfill::arrange_polygons(polygons, r)) {
|
||||
if (svgfill::arrange_polygons(settings, polygons, r)) {
|
||||
return r;
|
||||
} else {
|
||||
throw std::runtime_error("Failed to arrange polygons");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -483,6 +483,18 @@ public:
|
||||
return ps;
|
||||
}
|
||||
|
||||
size_t delete_same_facet_edge_pairs() {
|
||||
size_t n_deleted = 0;
|
||||
for (auto it = arr.edges_begin(); it != arr.edges_end();) {
|
||||
decltype(it) current = it++;
|
||||
if (current->face() == current->twin()->face()) {
|
||||
arr.remove_edge(current);
|
||||
n_deleted++;
|
||||
}
|
||||
}
|
||||
return n_deleted;
|
||||
}
|
||||
|
||||
void merge(const std::vector<int>& edge_indices) {
|
||||
if (edge_indices.empty()) {
|
||||
return;
|
||||
|
||||
@@ -67,6 +67,7 @@ namespace svgfill {
|
||||
virtual std::vector<int> get_face_pairs() = 0;
|
||||
virtual size_t num_edges() = 0;
|
||||
virtual size_t num_faces() = 0;
|
||||
virtual size_t delete_same_facet_edge_pairs() = 0;
|
||||
};
|
||||
|
||||
class SVGFILL_API context {
|
||||
@@ -101,6 +102,7 @@ namespace svgfill {
|
||||
void write(std::vector<std::vector<polygon_2>>&);
|
||||
size_t num_edges() { return arr_->num_edges(); }
|
||||
size_t num_faces() { return arr_->num_faces(); }
|
||||
size_t delete_same_facet_edge_pairs() { return arr_->delete_same_facet_edge_pairs(); }
|
||||
|
||||
~context() {
|
||||
delete arr_;
|
||||
@@ -113,7 +115,22 @@ namespace svgfill {
|
||||
SVGFILL_API std::string polygons_to_svg(const std::vector<std::vector<polygon_2>>& polygons, bool random_color=false);
|
||||
SVGFILL_API std::string polygons_to_svg(const std::vector<polygon_2>& polygons, bool random_color = false);
|
||||
SVGFILL_API bool svg_to_polygons(const std::string& data, const boost::optional<std::string>& class_name, std::vector<polygon_2>& polygons);
|
||||
SVGFILL_API bool arrange_polygons(const std::vector<polygon_2>& polygons, std::vector<polygon_2>& arranged);
|
||||
}
|
||||
|
||||
struct SVGFILL_API arrange_polygon_settings {
|
||||
bool debug_output = false;
|
||||
// -1: compute from average edge length
|
||||
double polygon_offset_distance = -1.;
|
||||
// 0: use offset - union - negative offset to find the outer perimeter
|
||||
// 1: radial walk along vertices; exact, but can only reuse vertices, not create new positions by means of intersections
|
||||
int outer_perimiter_algo = 0;
|
||||
// 0: outer perimiter and corridor center lines
|
||||
// 1: input polygons, corridor center lines and segments connecting corridor center lines to input polygons
|
||||
int topology_reconstruction_algo = 0;
|
||||
bool perform_cleanup = true;
|
||||
double subdivision_factor = 16.;
|
||||
};
|
||||
|
||||
SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector<polygon_2>& polygons, std::vector<polygon_2>& arranged);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user