mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 03:19:53 +00:00
Merge branch 'v0.8.0' into fix-7318-bcf-gp-strokes
This commit is contained in:
@@ -188,6 +188,8 @@ class BcfClient:
|
||||
response.raise_for_status()
|
||||
return response.status_code, response.text
|
||||
except requests.exceptions.HTTPError as errh:
|
||||
response = errh.response
|
||||
assert response is not None
|
||||
print(f"message: {response.reason}' '{response.status_code}, {errh}")
|
||||
return response.status_code, response.reason
|
||||
|
||||
@@ -206,6 +208,8 @@ class BcfClient:
|
||||
response.raise_for_status()
|
||||
return response.status_code, response.text
|
||||
except requests.exceptions.HTTPError as errh:
|
||||
response = errh.response
|
||||
assert response is not None
|
||||
print(f"message: {response.reason}' '{response.status_code}, {errh}")
|
||||
return response.status_code, response.reason
|
||||
|
||||
@@ -222,6 +226,8 @@ class BcfClient:
|
||||
response.raise_for_status()
|
||||
return response.status_code, response.text
|
||||
except requests.exceptions.HTTPError as errh:
|
||||
response = errh.response
|
||||
assert response is not None
|
||||
print(f"message: {response.reason}' '{response.status_code}, {errh}")
|
||||
return response.status_code, response.reason
|
||||
|
||||
|
||||
@@ -24,10 +24,28 @@ a text, a tspan { fill: blue !important; text-decoration: underline;}
|
||||
a:hover { cursor: pointer; }
|
||||
.cut { fill: black; stroke: black; stroke-linecap: 'round'; stroke-width: 0.35; fill-rule: evenodd; }
|
||||
.projection { fill: white; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
|
||||
.surface { stroke: none; fill: #fff; fill-rule: evenodd; }
|
||||
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
|
||||
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
|
||||
.IfcGeographicElement { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 1; }
|
||||
/* SVG edge classification (issue #3668): see edge-classification.md. These select directly on
|
||||
the <path> element (each classified projection edge carries its own class), so they win over
|
||||
the inherited .projection rule above regardless of specificity. */
|
||||
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
|
||||
path.boundary { stroke: black; stroke-width: 0.3; stroke-opacity: 0.9; }
|
||||
path.crease { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; }
|
||||
path.sharp { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; }
|
||||
path.flush { stroke: black; stroke-width: 0.1; stroke-opacity: 0.4; }
|
||||
|
||||
/* Debug CSS for troubleshooting edge classification */
|
||||
/*
|
||||
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
|
||||
path.boundary { stroke: orange; stroke-width: 0.3; stroke-opacity: 0.9; }
|
||||
path.crease { stroke: green; stroke-width: 0.25; stroke-opacity: 0.85; }
|
||||
path.sharp { stroke: red; stroke-width: 0.18; stroke-opacity: 0.7; }
|
||||
path.flush { stroke: blue; stroke-width: 0.1; stroke-opacity: 0.4; }
|
||||
*/
|
||||
|
||||
.surface {fill: white; stroke-width: 0.1;}
|
||||
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
|
||||
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
|
||||
/* .IfcGeographicElement { fill: none; stroke: rgb(150, 150, 150); stroke-linecap: 'round'; stroke-dasharray: 1, 2;} */
|
||||
.PredefinedType-LINEWORK { stroke: black; stroke-width: 0.25; }
|
||||
.PredefinedType-LINEWORK.dashed { stroke-dasharray: 3, 2; }
|
||||
.PredefinedType-LINEWORK.fine { stroke-width: 0.18; stroke: #777777; }
|
||||
|
||||
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
|
||||
FILE_SCHEMA(('IFC4'));
|
||||
ENDSEC;
|
||||
DATA;
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
|
||||
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36,#37));
|
||||
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -35,5 +35,12 @@ DATA;
|
||||
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#31=IFCSIMPLEPROPERTYTEMPLATE('1cFVJnqT13m8ItkMHaI1tp',$,'UseEdgeClassification','Enable the boundary/outline/sharp/crease/flush SVG edge classification scheme (issue #3668). When false, drawings use the original unclassified linework.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#32=IFCSIMPLEPROPERTYTEMPLATE('2kB$mxBgnBUvhjh0Ti0c4P',$,'RenderCreases','Whether to render ''crease'' (concave) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#33=IFCSIMPLEPROPERTYTEMPLATE('3MSIJNW$T8r9Hl12kk0BY$',$,'ValleyAngleMinDegrees','Minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as ''crease'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
|
||||
#36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#37=IFCSIMPLEPROPERTYTEMPLATE('1zM9sia2L8RQDnWZxgUwlZ',$,'JoinClasses','Comma separated list of IFC classes whose cut linework will be joined together when they meet (e.g. mitred at a corner).\X2\000A\X0\Defaults to ''IfcWall,IfcSlab'' if not set. Override to also join other classes, such as ''IfcWall,IfcSlab,IfcCovering''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -10,6 +10,7 @@ if bonsai_lib_path:
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pystache
|
||||
@@ -18,8 +19,53 @@ from aiohttp import web
|
||||
|
||||
sio_port = 8080 # default port
|
||||
|
||||
|
||||
def get_asset_version() -> str:
|
||||
"""A cache-busting token appended to locally served static asset URLs.
|
||||
|
||||
Browsers otherwise keep serving a stale cached copy of static/js and
|
||||
static/css after Bonsai ships a code change, until the user does a hard
|
||||
refresh. Using the Bonsai version (which includes the build's commit
|
||||
hash) means the token changes on every shipped update.
|
||||
"""
|
||||
if bonsai_version:
|
||||
return urllib.parse.quote(bonsai_version, safe="")
|
||||
# Fallback for standalone runs without BONSAI_VERSION set (e.g. running
|
||||
# sioserver.py directly outside of Blender): derive a token from the
|
||||
# newest mtime among the static assets, so it still changes whenever the
|
||||
# shipped files change.
|
||||
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
latest_mtime = 0
|
||||
for root, _dirs, files in os.walk(static_dir):
|
||||
for name in files:
|
||||
latest_mtime = max(latest_mtime, int(os.path.getmtime(os.path.join(root, name))))
|
||||
return f"dev-{latest_mtime}"
|
||||
|
||||
|
||||
asset_version = get_asset_version()
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def no_cache_static_middleware(request: web.Request, handler):
|
||||
"""Force revalidation of locally served static assets.
|
||||
|
||||
Query-string version stamping (see `asset_version`) busts the cache for
|
||||
the HTML-referenced entry points, but JS files that statically import
|
||||
other local modules (e.g. cost.js/gantt.js importing utilities/costui.js)
|
||||
reference those modules by an un-stamped relative path. Marking all
|
||||
/static/ and /jsgantt/ responses as no-cache makes browsers always
|
||||
revalidate with the server (a cheap conditional GET / 304 when nothing
|
||||
changed), so nested imports also pick up shipped changes without
|
||||
requiring a hard refresh.
|
||||
"""
|
||||
response = await handler(request)
|
||||
if request.path.startswith("/static/") or request.path.startswith("/jsgantt/"):
|
||||
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
||||
return response
|
||||
|
||||
|
||||
sio = socketio.AsyncServer(cors_allowed_origins="*", async_mode="aiohttp", max_http_buffer_size=10000000)
|
||||
app = web.Application()
|
||||
app = web.Application(middlewares=[no_cache_static_middleware])
|
||||
sio.attach(app)
|
||||
|
||||
|
||||
@@ -199,28 +245,28 @@ class BlenderNamespace(socketio.AsyncNamespace):
|
||||
async def schedules(request):
|
||||
with open("templates/index.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
async def costing(request):
|
||||
with open("templates/costing.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
async def sequencing(request):
|
||||
with open("templates/gantt.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
async def documentation(request):
|
||||
with open("templates/drawings.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
@@ -229,7 +275,7 @@ async def documentation(request):
|
||||
async def demo(request):
|
||||
with open("templates/demo.html", "r") as f:
|
||||
template = f.read()
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version})
|
||||
html_content = pystache.render(template, {"port": sio_port, "version": bonsai_version, "v": asset_version})
|
||||
return web.Response(text=html_content, content_type="text/html")
|
||||
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ function addGanttElement(blenderId, tasks, workSched, filename) {
|
||||
vShowTaskInfoLink: 1, // Show link in tool tip (0/1)
|
||||
vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily
|
||||
vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data.
|
||||
vFormatArr: ["Day", "Week", "Month", "Quarter"], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers,
|
||||
vFormatArr: ["Hour", "Day", "Week", "Month", "Quarter"], // vUseSingleCell keeps Hour usable on large charts.
|
||||
vShowRes: true, // Disable the resource column.
|
||||
vShowComp: false, // Disable the completion column.
|
||||
vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want.
|
||||
|
||||
@@ -250,6 +250,14 @@ export class CostUI {
|
||||
},
|
||||
});
|
||||
|
||||
CostUI.addRibbonButton({
|
||||
text: "Download CSV",
|
||||
icon: "fa-solid fa-file-csv",
|
||||
callback: () => {
|
||||
CostUI.downloadCsv();
|
||||
},
|
||||
});
|
||||
|
||||
CostUI.addRibbonButton({
|
||||
text: "Hide Schedules",
|
||||
icon: "fa-regular fa-eye-slash",
|
||||
@@ -523,6 +531,81 @@ export class CostUI {
|
||||
}
|
||||
}
|
||||
|
||||
static downloadCsv() {
|
||||
const tables = document.querySelectorAll("table[id^='cost-items-']");
|
||||
if (tables.length === 0) {
|
||||
alert("No cost schedule loaded to export!");
|
||||
return;
|
||||
}
|
||||
tables.forEach((table) => {
|
||||
const scheduleId = table.id.split("-").pop();
|
||||
const csv = CostUI.tableToCsv(table);
|
||||
if (csv === null) {
|
||||
return;
|
||||
}
|
||||
const nameEl = document.querySelector(
|
||||
"#cost-schedule-container-" + scheduleId + " .form-header span"
|
||||
);
|
||||
const scheduleName = nameEl
|
||||
? nameEl.textContent
|
||||
: "cost_schedule_" + scheduleId;
|
||||
CostUI.triggerCsvDownload(csv, scheduleName + ".csv");
|
||||
});
|
||||
}
|
||||
|
||||
static tableToCsv(table) {
|
||||
const escapeCsvCell = (value) => {
|
||||
const text = (value === null || value === undefined ? "" : value)
|
||||
.toString()
|
||||
.trim();
|
||||
if (/[",\n]/.test(text)) {
|
||||
return '"' + text.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const cellText = (cell) => {
|
||||
const input = cell.querySelector("input");
|
||||
return input ? input.value : cell.innerText;
|
||||
};
|
||||
|
||||
// The Actions column only holds buttons (edit/delete/etc), not data.
|
||||
const isDataColumn = (column) => column && column !== "Actions";
|
||||
|
||||
const headerCells = Array.from(table.querySelectorAll("thead th")).filter(
|
||||
(th) => isDataColumn(th.getAttribute("data-column"))
|
||||
);
|
||||
if (headerCells.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rows = [headerCells.map((th) => escapeCsvCell(th.textContent)).join(",")];
|
||||
|
||||
table.querySelectorAll("tbody tr").forEach((row) => {
|
||||
const cells = Array.from(row.children).filter((cell) =>
|
||||
isDataColumn(cell.getAttribute("data-column"))
|
||||
);
|
||||
if (cells.length === 0) {
|
||||
return; // e.g. the "No cost items found" placeholder row.
|
||||
}
|
||||
rows.push(cells.map((cell) => escapeCsvCell(cellText(cell))).join(","));
|
||||
});
|
||||
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
static triggerCsvDownload(csvContent, filename) {
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
static createCostTable({ costSchedule, currency, callbacks }) {
|
||||
const preferences = CostUI.getColumnPreferences();
|
||||
const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES";
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="/static/css/cost.css"
|
||||
href="/static/css/cost.css?v={{v}}"
|
||||
id="index-stylesheet"
|
||||
/>
|
||||
<link
|
||||
@@ -21,7 +21,7 @@
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -34,7 +34,7 @@
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script type="module" defer src="./static/js/cost.js"></script>
|
||||
<script type="module" defer src="./static/js/cost.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
/>
|
||||
<!-- here we request the CSS file from the server, -->
|
||||
<!-- using registered static path in the server -->
|
||||
<link rel="stylesheet" href="/static/css/demo.css" id="demo-stylesheet" />
|
||||
<link rel="stylesheet" href="/static/css/demo.css?v={{v}}" id="demo-stylesheet" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -33,7 +33,7 @@
|
||||
</script>
|
||||
<!-- here we request the JS file from the server-->
|
||||
<!-- using registered static path in the server -->
|
||||
<script defer src="./static/js/demo.js"></script>
|
||||
<script defer src="./static/js/demo.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- the navigation bar at the top of the page. -->
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/css/drawings.css"
|
||||
href="/static/css/drawings.css?v={{v}}"
|
||||
id="drawings-stylesheet"
|
||||
/>
|
||||
<link
|
||||
@@ -24,7 +24,7 @@
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -45,7 +45,7 @@
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script defer src="./static/js/drawings.js"></script>
|
||||
<script defer src="./static/js/drawings.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
||||
@@ -9,25 +9,25 @@
|
||||
type="image/x-icon"
|
||||
href="https://bonsaibim.org/assets/images/favicon-blender.png"
|
||||
/>
|
||||
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css" />
|
||||
<link rel="stylesheet" href="/static/css/gantt.css" id="gantt-stylesheet" />
|
||||
<link rel="stylesheet" type="text/css" href="/jsgantt/jsgantt.css?v={{v}}" />
|
||||
<link rel="stylesheet" href="/static/css/gantt.css?v={{v}}" id="gantt-stylesheet" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://cdn.socket.io/4.0.0/socket.io.min.js"
|
||||
></script>
|
||||
<script type="text/javascript" src="./jsgantt/jsgantt.js"></script>
|
||||
<script type="text/javascript" src="./jsgantt/jsgantt.js?v={{v}}"></script>
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script type="module" defer src="./static/js/gantt.js"></script>
|
||||
<script type="module" defer src="./static/js/gantt.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="no-print">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
type="image/x-icon"
|
||||
href="https://bonsaibim.org/assets/images/favicon-blender.png"
|
||||
/>
|
||||
<link rel="stylesheet" href="/static/css/index.css" id="index-stylesheet" />
|
||||
<link rel="stylesheet" href="/static/css/index.css?v={{v}}" id="index-stylesheet" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
id="tabulator-stylesheet"
|
||||
@@ -21,7 +21,7 @@
|
||||
/>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="/static/js/jquery.min.js"
|
||||
src="/static/js/jquery.min.js?v={{v}}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
@@ -34,7 +34,7 @@
|
||||
<script>
|
||||
var SOCKET_PORT = {{port}};
|
||||
</script>
|
||||
<script defer src="./static/js/index.js"></script>
|
||||
<script defer src="./static/js/index.js?v={{v}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
|
||||
@@ -418,9 +418,10 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None
|
||||
|
||||
|
||||
def viewport_shading_changed_callback(area: bpy.types.Area) -> None:
|
||||
shading = area.spaces.active.shading.type
|
||||
if shading == "RENDERED":
|
||||
tool.Style.get_style_props().active_style_type = "External"
|
||||
shading_type = area.spaces.active.shading.type
|
||||
tool.Style.restore_material_style_types(shading_type)
|
||||
if shading_type == "SOLID":
|
||||
area.spaces.active.shading.color_type = "MATERIAL"
|
||||
|
||||
|
||||
def subscribe_to_viewport_shading_changes():
|
||||
|
||||
@@ -133,10 +133,6 @@ class MaterialCreator:
|
||||
if shape_has_openings and coords.is_a("IfcIndexedTextureMap"):
|
||||
continue
|
||||
tool.Loader.load_indexed_map(coords, self.mesh)
|
||||
elif tool.Style.get_texture_style(material):
|
||||
# No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy,
|
||||
# and IFC4 COORD uses generated UVs). Bake XY→UV as fallback.
|
||||
tool.Loader.load_generated_uv_map(self.mesh)
|
||||
|
||||
def assign_material_slots_to_faces(self) -> None:
|
||||
if not self.mesh["ios_materials"]:
|
||||
@@ -1103,12 +1099,14 @@ class IfcImporter:
|
||||
vertices = [[v[i], v[i + 1], v[i + 2], 1] for i in range(0, len(v), 3)]
|
||||
edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)]
|
||||
v2 = None
|
||||
polyline = None
|
||||
for edge in edges:
|
||||
v1 = vertices[edge[0]]
|
||||
if v1 != v2:
|
||||
polyline = curve.splines.new("POLY")
|
||||
polyline.points[-1].co = mathutils.Vector(v1)
|
||||
v2 = vertices[edge[1]]
|
||||
assert polyline is not None
|
||||
polyline.points.add(1)
|
||||
polyline.points[-1].co = mathutils.Vector(v2)
|
||||
edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(geometry).tolist()
|
||||
|
||||
@@ -843,7 +843,6 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
|
||||
settings = ifcopenshell.geom.settings()
|
||||
shape = ifcopenshell.geom.create_shape(settings, opening)
|
||||
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
|
||||
mat.translation = (0, 0, 0)
|
||||
opening_bm = bmesh.new()
|
||||
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
|
||||
for vert in verts:
|
||||
@@ -1060,6 +1059,7 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
|
||||
return tool.Ifc.get().createIfcConnectionSurfaceGeometry(surface)
|
||||
|
||||
def export_surface(self, polygon, target_face_matrix):
|
||||
ifc_file = tool.Ifc.get()
|
||||
x_axis = target_face_matrix.col[0][:3]
|
||||
z_axis = target_face_matrix.col[2][:3]
|
||||
p1 = target_face_matrix.translation
|
||||
@@ -1072,18 +1072,20 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
|
||||
placement = builder.create_axis2_placement_3d([o / self.unit_scale for o in p1], z_axis, x_axis)
|
||||
surface.BasisSurface = tool.Ifc.get().create_entity("IfcPlane", placement)
|
||||
|
||||
if tool.Ifc.get().schema != "IFC2X3":
|
||||
schema = ifc_file.schema
|
||||
if schema != "IFC2X3":
|
||||
points = [tool.Model.convert_si_to_unit(list(co)) for co in polygon.exterior.coords]
|
||||
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
|
||||
outer_boundary = tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False)
|
||||
|
||||
inner_boundaries = []
|
||||
inner_boundaries: list[ifcopenshell.entity_instance] = []
|
||||
for interior in polygon.interiors:
|
||||
points = [tool.Model.convert_si_to_unit(list(co)) for co in interior.coords]
|
||||
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
|
||||
inner_boundaries.append(tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False))
|
||||
else:
|
||||
pass # TODO
|
||||
# TODO:
|
||||
raise NotImplementedError(schema)
|
||||
|
||||
surface.OuterBoundary = outer_boundary
|
||||
surface.InnerBoundaries = inner_boundaries
|
||||
|
||||
@@ -400,6 +400,7 @@ class CadOffset(bpy.types.Operator):
|
||||
[verts.update(e.verts) for e in edges]
|
||||
|
||||
# Use the viewport angle to determine the offset direction
|
||||
wp = None
|
||||
for area in bpy.context.screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
# Don't ask me, I don't know.
|
||||
@@ -409,6 +410,7 @@ class CadOffset(bpy.types.Operator):
|
||||
z = area.spaces.active.region_3d.view_rotation @ Vector((0, 0, 1))
|
||||
wp = Matrix([x, y, z, Vector((0, 0, 0))]).to_4x4().transposed()
|
||||
break
|
||||
assert wp is not None
|
||||
|
||||
rotation = Matrix.Rotation(pi / 2, 2, "Z")
|
||||
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
|
||||
|
||||
@@ -478,6 +478,7 @@ class ChangeClassificationLevel(bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
props = tool.Classification.get_classification_props()
|
||||
props.available_library_references.clear()
|
||||
reference = None
|
||||
for reference in IfcStore.classification_file.by_id(self.parent_id).HasReferences:
|
||||
new = props.available_library_references.add()
|
||||
new.identification = reference.Identification or ""
|
||||
@@ -485,6 +486,7 @@ class ChangeClassificationLevel(bpy.types.Operator):
|
||||
new.ifc_definition_id = reference.id()
|
||||
new.has_references = bool(reference.HasReferences)
|
||||
new.referenced_source
|
||||
assert reference
|
||||
if reference.ReferencedSource.is_a("IfcClassificationReference"):
|
||||
props.active_library_referenced_source = reference.ReferencedSource.ReferencedSource.id()
|
||||
else:
|
||||
|
||||
@@ -156,6 +156,8 @@ class CostSchedulesData:
|
||||
values = root_element.CostValues
|
||||
elif root_element.is_a("IfcConstructionResource"):
|
||||
values = root_element.BaseCosts
|
||||
else:
|
||||
assert False, root_element
|
||||
for cost_value in values or []:
|
||||
cls._load_cost_value(root_element, data, cost_value)
|
||||
# data["CostValues"].append(cost_value.id())
|
||||
|
||||
@@ -47,6 +47,7 @@ classes = (
|
||||
operator.CleanWireframes,
|
||||
operator.ContractSheet,
|
||||
operator.ConvertSVGToDXF,
|
||||
operator.CopyAnnotationToDrawing,
|
||||
operator.CopyTextToSelection,
|
||||
operator.CreateDrawing,
|
||||
operator.CreateSheets,
|
||||
@@ -107,6 +108,7 @@ classes = (
|
||||
operator.SelectAssignedProduct,
|
||||
operator.SelectSimilarTextLiteralValue,
|
||||
operator.ToggleTargetView,
|
||||
operator.ToggleDrawingCategorySelection,
|
||||
operator.OpenDocumentationWebUi,
|
||||
operator.FilterSelectedObjectsIfIntersectedByCamera,
|
||||
prop.Variable,
|
||||
|
||||
@@ -425,10 +425,12 @@ class BaseDecorator:
|
||||
|
||||
blf.size(font_id, font_size_px)
|
||||
|
||||
w, h = None, None
|
||||
if box_alignment or center or vcenter:
|
||||
w, h = blf.dimensions(font_id, text)
|
||||
|
||||
if box_alignment:
|
||||
assert w is not None and h is not None
|
||||
box_alignment_offset = Vector((0, 0))
|
||||
if "bottom" in box_alignment:
|
||||
pass
|
||||
@@ -450,10 +452,12 @@ class BaseDecorator:
|
||||
else:
|
||||
# horizontal centering
|
||||
if center:
|
||||
assert w is not None
|
||||
pos -= Vector((cos, sin)) * w * 0.5
|
||||
|
||||
# vertical centering
|
||||
if vcenter:
|
||||
assert h is not None
|
||||
pos -= Vector((-sin, cos)) * h * 0.5
|
||||
|
||||
# side-shifting
|
||||
@@ -1001,6 +1005,8 @@ class FallDecorator(BaseDecorator):
|
||||
O = A.copy()
|
||||
O.z = B.z
|
||||
run = (B - O).length
|
||||
|
||||
angle_tg = None
|
||||
if run != 0:
|
||||
angle_tg = rise / run
|
||||
angle = round(degrees(atan(angle_tg)))
|
||||
@@ -1018,6 +1024,7 @@ class FallDecorator(BaseDecorator):
|
||||
elif object_type == "SLOPE_PERCENT":
|
||||
if angle == 90:
|
||||
return "-"
|
||||
assert angle_tg is not None
|
||||
return f"{round(angle_tg * 100)} %"
|
||||
return "NO DATA"
|
||||
|
||||
@@ -1249,6 +1256,7 @@ class SectionLevelDecorator(BaseDecorator):
|
||||
}
|
||||
|
||||
# process edges
|
||||
text_position, text_dir = None, None
|
||||
for edge in edges_original:
|
||||
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
|
||||
start_i = len(output_verts)
|
||||
@@ -1554,32 +1562,39 @@ class SectionDecorator(BaseDecorator):
|
||||
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
|
||||
start_i = len(output_verts)
|
||||
|
||||
circle_head = None
|
||||
if display_start_circle or display_end_circle:
|
||||
circle_head = get_circle_head(circle_size)
|
||||
|
||||
if display_start_symbol or display_end_symbol or connect_markers:
|
||||
triangle_head, divider_offset, edge_dir_circle = None, None, None
|
||||
display_symbol = display_start_symbol or display_end_symbol
|
||||
if display_symbol or connect_markers:
|
||||
edge_dir = (v1 - v0).normalized()
|
||||
side = (edge_dir.yx * Vector((1, -1))).to_3d()
|
||||
edge_dir_circle = edge_dir * circle_size
|
||||
|
||||
if display_start_symbol or display_end_symbol:
|
||||
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
|
||||
divider_offset = []
|
||||
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
|
||||
divider_offset.append(edge_dir_circle)
|
||||
if display_symbol:
|
||||
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
|
||||
divider_offset = []
|
||||
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
|
||||
divider_offset.append(edge_dir_circle)
|
||||
|
||||
if display_start_circle:
|
||||
assert circle_head is not None
|
||||
start_i = add_verts_sequence([v + v0 for v in circle_head], start_i, **out_kwargs, closed=True)
|
||||
# circle middle divider
|
||||
if not display_start_symbol:
|
||||
assert divider_offset is not None
|
||||
start_i = add_verts_sequence(
|
||||
[v0 + divider_offset[0], v0 - divider_offset[1]], start_i, **out_kwargs
|
||||
)
|
||||
|
||||
if display_start_symbol:
|
||||
assert triangle_head is not None
|
||||
start_i = add_verts_sequence([v + v0 for v in triangle_head], start_i, **out_kwargs, closed=True)
|
||||
|
||||
if display_end_circle:
|
||||
assert circle_head is not None
|
||||
start_i = add_verts_sequence([v + v1 for v in circle_head], start_i, **out_kwargs, closed=True)
|
||||
# circle middle divider
|
||||
if not display_end_symbol:
|
||||
@@ -1588,9 +1603,11 @@ class SectionDecorator(BaseDecorator):
|
||||
)
|
||||
|
||||
if display_end_symbol:
|
||||
assert triangle_head is not None
|
||||
start_i = add_verts_sequence([v + v1 for v in triangle_head], start_i, **out_kwargs, closed=True)
|
||||
|
||||
if connect_markers:
|
||||
assert edge_dir_circle is not None
|
||||
gap = []
|
||||
gap.append(edge_dir_circle if display_start_symbol else Vector((0, 0, 0)))
|
||||
gap.append(edge_dir_circle if display_end_symbol else Vector((0, 0, 0)))
|
||||
@@ -1677,6 +1694,12 @@ class CutDecorator:
|
||||
selected_elements_color = self.addon_prefs.decorator_color_selected
|
||||
self.fallback_colour = (0.3, 0.3, 0.3, 1)
|
||||
|
||||
# Evaluate camera movement once per redraw rather than twice per object: is_camera_moved()
|
||||
# runs eval()/numpy on the camera matrix and, as a side effect, refreshes the stored
|
||||
# checksum on the first True result - so calling it per object also made the second call
|
||||
# (fill) see an already-updated checksum and skip recalculating when it shouldn't.
|
||||
self.camera_moved = self.is_camera_moved()
|
||||
|
||||
all_vertices = []
|
||||
all_edges = []
|
||||
selected_vertices = []
|
||||
@@ -1802,23 +1825,35 @@ class CutDecorator:
|
||||
|
||||
# Currently selected objects must be recalculated as they may be being moved / edited.
|
||||
# If the camera is selected, we also recalculate as the user may be moving the camera.
|
||||
is_selected = obj.select_get()
|
||||
recalc_cut = not has_cut_cache or is_selected or self.camera_moved
|
||||
recalc_fill = not has_fill_cache or is_selected or self.camera_moved
|
||||
if not (recalc_cut or recalc_fill):
|
||||
return
|
||||
|
||||
if not has_cut_cache or obj.select_get() or self.is_camera_moved():
|
||||
self.recalculate_cut(context, obj, element)
|
||||
if not has_fill_cache or obj.select_get() or self.is_camera_moved():
|
||||
self.recalculate_fill(context, obj, element)
|
||||
# The intersection test builds a bmesh and scans every vertex; both recalculations need
|
||||
# the same answer, so compute it once here rather than once in each.
|
||||
is_intersecting = tool.Drawing.is_intersecting_camera(obj, context.scene.camera)
|
||||
if recalc_cut:
|
||||
self.recalculate_cut(context, obj, element, is_intersecting)
|
||||
if recalc_fill:
|
||||
self.recalculate_fill(context, obj, element, is_intersecting)
|
||||
|
||||
def recalculate_cut(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
|
||||
if tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
|
||||
def recalculate_cut(
|
||||
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
|
||||
) -> None:
|
||||
if is_intersecting:
|
||||
verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera)
|
||||
DecoratorData.cut_cache[element.id()] = (verts, edges)
|
||||
else:
|
||||
DecoratorData.cut_cache[element.id()] = (False, False)
|
||||
|
||||
def recalculate_fill(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
|
||||
def recalculate_fill(
|
||||
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
|
||||
) -> None:
|
||||
element_id = element.id()
|
||||
|
||||
if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
|
||||
if not is_intersecting:
|
||||
DecoratorData.fill_cache[element_id] = {}
|
||||
return
|
||||
|
||||
@@ -1871,6 +1906,8 @@ class CutDecorator:
|
||||
layer_set = material
|
||||
offset = 0
|
||||
sense_factor = 1
|
||||
else:
|
||||
assert False, material
|
||||
|
||||
if len(layer_set.MaterialLayers) == 1:
|
||||
material = layer_set.MaterialLayers[0].Material
|
||||
@@ -1897,6 +1934,8 @@ class CutDecorator:
|
||||
co = Vector((0.0, 0.0, offset))
|
||||
no = tool.Drawing.get_extrusion_vector(element).normalized()
|
||||
no = Vector([1.0, 0.0, 0.0])
|
||||
else:
|
||||
assert False, usage
|
||||
no *= sense_factor
|
||||
last_i = len(layer_set.MaterialLayers) - 1
|
||||
|
||||
|
||||
@@ -225,9 +225,11 @@ def format_distance(
|
||||
unit_system, unit_length, unit_fraction = unit_mapping[custom_unit]
|
||||
|
||||
value *= unit_scale
|
||||
tx_dist = None
|
||||
|
||||
# Imperial Formatting
|
||||
if unit_system == "IMPERIAL":
|
||||
toInches = None
|
||||
if in_unit_length:
|
||||
if unit_length == "INCHES":
|
||||
toInches = 1
|
||||
@@ -241,6 +243,7 @@ def format_distance(
|
||||
toInches = 1550
|
||||
inPerFoot = 144
|
||||
|
||||
assert toInches is not None
|
||||
decInches = value * toInches
|
||||
decFeet = decInches / 12
|
||||
|
||||
@@ -383,6 +386,7 @@ def format_distance(
|
||||
if precision and isinstance(precision, float):
|
||||
value = precision * round(float(value) / precision)
|
||||
|
||||
fmt = None
|
||||
if decimal_places is not None:
|
||||
fmt = "%1." + str(decimal_places) + "f"
|
||||
|
||||
@@ -465,6 +469,7 @@ def format_distance(
|
||||
assert f"Unexpected unit_system - '{unit_system}'."
|
||||
# tx_dist = fmt % value
|
||||
|
||||
assert tx_dist is not None
|
||||
return tx_dist
|
||||
|
||||
|
||||
|
||||
@@ -228,6 +228,90 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
)
|
||||
|
||||
|
||||
def get_copy_annotation_target_drawings(self, context):
|
||||
global COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
|
||||
drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"]
|
||||
drawings.sort(key=lambda d: d.Name or "")
|
||||
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [(str(d.id()), d.Name or "Unnamed", "") for d in drawings]
|
||||
return COPY_ANNOTATION_TARGET_DRAWINGS_ENUM
|
||||
|
||||
|
||||
COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = []
|
||||
|
||||
|
||||
class CopyAnnotationToDrawing(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.copy_annotation_to_drawing"
|
||||
bl_label = "Copy Annotation To Drawing"
|
||||
bl_description = (
|
||||
"Copy the selected annotations to another drawing.\n\n"
|
||||
"The copies become independent annotations assigned to the chosen drawing, "
|
||||
"placed in its view plane. The originals stay in their current drawing"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
target_drawing: bpy.props.EnumProperty(name="Target Drawing", items=get_copy_annotation_target_drawings)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
target_drawing: str
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not tool.Ifc.get():
|
||||
cls.poll_message_set("No IFC project loaded.")
|
||||
return False
|
||||
if not cls.get_selected_annotations(context):
|
||||
cls.poll_message_set("No annotation selected.")
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_selected_annotations(cls, context) -> list[ifcopenshell.entity_instance]:
|
||||
return [
|
||||
element
|
||||
for obj in context.selected_objects
|
||||
if (element := tool.Ifc.get_entity(obj))
|
||||
and element.is_a("IfcAnnotation")
|
||||
and element.ObjectType != "DRAWING"
|
||||
]
|
||||
|
||||
def invoke(self, context, event):
|
||||
assert context.window_manager
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
assert self.layout
|
||||
row = self.layout.row()
|
||||
row.prop(self, "target_drawing")
|
||||
|
||||
def _execute(self, context):
|
||||
if not self.target_drawing:
|
||||
self.report({"ERROR"}, "No target drawing selected.")
|
||||
return {"CANCELLED"}
|
||||
target_drawing = tool.Ifc.get().by_id(int(self.target_drawing))
|
||||
annotations = self.get_selected_annotations(context)
|
||||
previous_selection = [obj for a in annotations if (obj := tool.Ifc.get_object(a))]
|
||||
previous_active = context.view_layer.objects.active
|
||||
copied = core.copy_annotations_to_drawing(
|
||||
tool.Ifc,
|
||||
tool.Collector,
|
||||
tool.Drawing,
|
||||
tool.Geometry,
|
||||
annotations=annotations,
|
||||
target_drawing=target_drawing,
|
||||
)
|
||||
for obj in context.selected_objects:
|
||||
obj.select_set(False)
|
||||
for obj in previous_selection:
|
||||
if obj.name in context.view_layer.objects:
|
||||
obj.select_set(True)
|
||||
if previous_active and previous_active.name in context.view_layer.objects:
|
||||
context.view_layer.objects.active = previous_active
|
||||
skipped = len(annotations) - len(copied)
|
||||
message = f"Copied {len(copied)} annotations to {target_drawing.Name or 'Unnamed'}."
|
||||
if skipped:
|
||||
message += f" Skipped {skipped} already in that drawing."
|
||||
self.report({"INFO"}, message)
|
||||
|
||||
|
||||
class CreateDrawing(bpy.types.Operator):
|
||||
"""Creates/refreshes a .svg drawing
|
||||
|
||||
@@ -698,6 +782,8 @@ class CreateDrawing(bpy.types.Operator):
|
||||
layer_set = material
|
||||
offset = 0
|
||||
sense_factor = 1
|
||||
else:
|
||||
assert False, material
|
||||
|
||||
camera_matrix_i = context.scene.camera.matrix_world.inverted()
|
||||
|
||||
@@ -722,7 +808,6 @@ class CreateDrawing(bpy.types.Operator):
|
||||
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001)
|
||||
bmesh.ops.triangle_fill(bm, use_dissolve=True, edges=bm.edges)
|
||||
|
||||
prev_co = None
|
||||
if not usage:
|
||||
sense_factor = 1 # Assume the extrusion vector points in the direction sense
|
||||
no = tool.Drawing.get_extrusion_vector(element).normalized()
|
||||
@@ -739,6 +824,8 @@ class CreateDrawing(bpy.types.Operator):
|
||||
co = Vector((0.0, 0.0, offset))
|
||||
no = tool.Drawing.get_extrusion_vector(element).normalized()
|
||||
no = Vector([1.0, 0.0, 0.0])
|
||||
else:
|
||||
assert False, usage
|
||||
no *= sense_factor
|
||||
last_i = len(layer_set.MaterialLayers) - 1
|
||||
for i, layer in enumerate(layer_set.MaterialLayers):
|
||||
@@ -906,6 +993,10 @@ class CreateDrawing(bpy.types.Operator):
|
||||
if os.path.isfile(svg_path) and self.props.should_use_linework_cache:
|
||||
return svg_path
|
||||
|
||||
ifc = tool.Ifc.get()
|
||||
semantics = None
|
||||
pairs = None
|
||||
|
||||
# in case of printing multiple drawings we need to sync just once
|
||||
if self.sync and self.drawing_index == 0:
|
||||
with profile("sync"):
|
||||
@@ -1309,6 +1400,18 @@ class CreateDrawing(bpy.types.Operator):
|
||||
self.svg_settings = ifcopenshell.geom.settings()
|
||||
self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
|
||||
# SVG edge classification (issue #3668). See edge-classification.md. Settings are
|
||||
# per-drawing, stored in EPset_Drawing and read into self.cprops by import_camera_props.
|
||||
try:
|
||||
self.svg_settings.set("svg-use-edge-classification", self.cprops.use_edge_classification)
|
||||
self.svg_settings.set("svg-render-crease-edges", self.cprops.render_creases)
|
||||
self.svg_settings.set("svg-valley-angle-min-degrees", self.cprops.valley_angle_min_degrees)
|
||||
self.svg_settings.set("svg-render-sharp-edges", self.cprops.render_sharp)
|
||||
self.svg_settings.set("svg-ridge-angle-min-degrees", self.cprops.ridge_angle_min_degrees)
|
||||
self.svg_settings.set("svg-emit-flush-edges", self.cprops.render_flush)
|
||||
except Exception:
|
||||
# Backwards compatibility with older ifcopenshell builds that don't expose these keys.
|
||||
pass
|
||||
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
|
||||
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
|
||||
self.serialiser = ifcopenshell.geom.serializers.svg(
|
||||
@@ -1452,6 +1555,15 @@ class CreateDrawing(bpy.types.Operator):
|
||||
"Material.Name",
|
||||
]
|
||||
|
||||
join_classes = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinClasses")
|
||||
if join_classes:
|
||||
join_classes = tuple(c.strip() for c in join_classes.split(",") if c.strip())
|
||||
else:
|
||||
# Architectural convention only merges these objects by default. E.g. pipe
|
||||
# segments and fittings shouldn't merge. Users may override this per-drawing
|
||||
# via the EPset_Drawing.JoinClasses property (e.g. to also join IfcCovering).
|
||||
join_classes = ("IfcWall", "IfcSlab")
|
||||
|
||||
group = root.find("{http://www.w3.org/2000/svg}g")
|
||||
joined_paths = {}
|
||||
self.is_manifold_cache = {}
|
||||
@@ -1553,8 +1665,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
)
|
||||
path.attrib["d"] = d
|
||||
|
||||
# Architectural convention only merges these objects. E.g. pipe segments and fittings shouldn't merge.
|
||||
if not element.is_a("IfcWall") and not element.is_a("IfcSlab"):
|
||||
if not any(element.is_a(c) for c in join_classes):
|
||||
continue
|
||||
|
||||
keys = []
|
||||
@@ -2199,7 +2310,11 @@ class SelectAllDrawings(bpy.types.Operator):
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Drawing.get_document_props()
|
||||
# When filtering to sheeted drawings only, act on the visible drawings only.
|
||||
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids() if props.show_drawings_on_sheets_only else None
|
||||
for drawing in props.drawings:
|
||||
if sheeted_ids is not None and drawing.is_drawing and drawing.ifc_definition_id not in sheeted_ids:
|
||||
continue
|
||||
if drawing.is_selected != self.select_all:
|
||||
drawing.is_selected = self.select_all
|
||||
return {"FINISHED"}
|
||||
@@ -3759,6 +3874,26 @@ class ToggleTargetView(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ToggleDrawingCategorySelection(bpy.types.Operator):
|
||||
bl_idname = "bim.toggle_drawing_category_selection"
|
||||
bl_label = "Toggle Category Selection"
|
||||
bl_description = "Select or deselect all drawings in this view category"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
target_view: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
target_view: str
|
||||
|
||||
def execute(self, context):
|
||||
drawings = tool.Drawing.get_visible_drawings_in_category(self.target_view)
|
||||
# If everything visible in the category is already selected, deselect all; otherwise select all.
|
||||
new_state = not all(d.is_selected for d in drawings)
|
||||
for drawing in drawings:
|
||||
drawing.is_selected = new_state
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class ExpandSheet(bpy.types.Operator):
|
||||
bl_idname = "bim.expand_sheet"
|
||||
bl_label = "Expand Sheet"
|
||||
|
||||
@@ -409,6 +409,12 @@ class DocProperties(PropertyGroup):
|
||||
options=set(),
|
||||
)
|
||||
is_editing_drawings: BoolProperty(name="Is Editing Drawings", default=False)
|
||||
show_drawings_on_sheets_only: BoolProperty(
|
||||
name="Show Only Drawings on Sheets",
|
||||
description="Only show drawings that are placed on a sheet",
|
||||
default=False,
|
||||
options=set(),
|
||||
)
|
||||
is_editing_schedules: BoolProperty(name="Is Editing Schedules", default=False)
|
||||
is_editing_references: BoolProperty(name="Is Editing References", default=False)
|
||||
target_view: EnumProperty(
|
||||
@@ -439,6 +445,7 @@ class DocProperties(PropertyGroup):
|
||||
should_use_annotation_cache: bool
|
||||
should_draw_linked_projects: bool
|
||||
is_editing_drawings: bool
|
||||
show_drawings_on_sheets_only: bool
|
||||
is_editing_schedules: bool
|
||||
is_editing_references: bool
|
||||
target_view: Literal["PLAN_VIEW", "ELEVATION_VIEW", "SECTION_VIEW", "REFLECTED_PLAN_VIEW", "MODEL_VIEW"]
|
||||
@@ -536,6 +543,50 @@ class BIMCameraProperties(PropertyGroup):
|
||||
default=True,
|
||||
update=get_update_layer_callback("has_annotation", "HasAnnotation"),
|
||||
)
|
||||
use_edge_classification: BoolProperty(
|
||||
name="Use Edge Classification",
|
||||
description="Classify projection edges into boundary/outline/sharp/crease/flush "
|
||||
"instead of drawing all linework identically. See edge-classification.md",
|
||||
default=False,
|
||||
update=get_update_layer_callback("use_edge_classification", "UseEdgeClassification"),
|
||||
)
|
||||
render_creases: BoolProperty(
|
||||
name="Render Creases",
|
||||
description="Render 'crease' (concave) projection edges",
|
||||
default=True,
|
||||
update=get_update_layer_callback("render_creases", "RenderCreases"),
|
||||
)
|
||||
valley_angle_min_degrees: FloatProperty(
|
||||
name="Valley Angle Minimum",
|
||||
description="Minimum concave dihedral deviation from flat, in degrees, for a projection "
|
||||
"edge to be classified as 'crease' rather than 'flush'",
|
||||
default=12.0,
|
||||
min=0.0,
|
||||
max=180.0,
|
||||
update=get_update_layer_callback("valley_angle_min_degrees", "ValleyAngleMinDegrees"),
|
||||
)
|
||||
render_sharp: BoolProperty(
|
||||
name="Render Sharp",
|
||||
description="Render 'sharp' (convex) projection edges",
|
||||
default=True,
|
||||
update=get_update_layer_callback("render_sharp", "RenderSharp"),
|
||||
)
|
||||
ridge_angle_min_degrees: FloatProperty(
|
||||
name="Ridge Angle Minimum",
|
||||
description="Minimum convex dihedral deviation from flat, in degrees, for a projection "
|
||||
"edge to be classified as 'sharp' rather than 'flush'",
|
||||
default=45.0,
|
||||
min=0.0,
|
||||
max=180.0,
|
||||
update=get_update_layer_callback("ridge_angle_min_degrees", "RidgeAngleMinDegrees"),
|
||||
)
|
||||
render_flush: BoolProperty(
|
||||
name="Render Flush",
|
||||
description="Render 'flush' projection edges (dihedral deviation below both ridge/valley "
|
||||
"thresholds). Omitted by default",
|
||||
default=False,
|
||||
update=get_update_layer_callback("render_flush", "RenderFlush"),
|
||||
)
|
||||
target_view: EnumProperty(
|
||||
name="Target View",
|
||||
default="PLAN_VIEW",
|
||||
|
||||
@@ -110,12 +110,14 @@ class Scheduler:
|
||||
y = self.margin
|
||||
rows = list(sheet.iter_rows())
|
||||
total_rows = len(rows)
|
||||
x = None
|
||||
for i, row in enumerate(rows):
|
||||
# The last row may contain only null values
|
||||
if i == (total_rows - 1) and not [c for c in row if c.value is not None]:
|
||||
continue
|
||||
|
||||
x = self.margin
|
||||
unmerged_height = None
|
||||
for cell in row:
|
||||
if isinstance(cell, openpyxl.cell.cell.MergedCell):
|
||||
column_letter = openpyxl.utils.get_column_letter(cell.column)
|
||||
@@ -230,8 +232,11 @@ class Scheduler:
|
||||
)
|
||||
|
||||
x += unmerged_width
|
||||
|
||||
assert unmerged_height is not None
|
||||
y += unmerged_height
|
||||
|
||||
assert x is not None
|
||||
total_width = x + self.margin
|
||||
total_height = y + self.margin
|
||||
self.svg["width"] = "{}mm".format(total_width)
|
||||
@@ -375,6 +380,7 @@ class Scheduler:
|
||||
tri = 0
|
||||
stop_iterating_over_rows = False
|
||||
# TODO: row spans support?
|
||||
x = None
|
||||
for tr in table.getElementsByType(TableRow):
|
||||
if stop_iterating_over_rows:
|
||||
break
|
||||
@@ -491,6 +497,7 @@ class Scheduler:
|
||||
tri += 1
|
||||
y += height
|
||||
|
||||
assert x is not None
|
||||
total_width = x + self.margin
|
||||
total_height = y + self.margin
|
||||
self.svg["width"] = "{}mm".format(total_width)
|
||||
|
||||
@@ -102,16 +102,16 @@ void angle_circle_head(
|
||||
in vec4 circle_start, in float circle_angle,
|
||||
in bool counterclockwise,
|
||||
out vec4 head[CIRCLE_SEGS+1], out float angle_segs) {
|
||||
|
||||
|
||||
// 1 added to CIRCLE_SEGS because we're number of vertices
|
||||
// for n segments is n+1
|
||||
|
||||
|
||||
float angle_d;
|
||||
angle_d = PI * 2 / CIRCLE_SEGS; // 30d
|
||||
// need to bottom clamp it to 1, otherwise it causes Blender crash at extruding the curve
|
||||
angle_segs = max(1, ceil(circle_angle / angle_d));
|
||||
angle_d = circle_angle / angle_segs;
|
||||
|
||||
|
||||
for(int i = 0; i < (angle_segs + 1); i++) {
|
||||
float angle = angle_d * i;
|
||||
if (counterclockwise) {
|
||||
@@ -143,7 +143,7 @@ void cross_head(in vec4 dir, in float size, out vec4 head[3]) {
|
||||
#define do_vertex(pos, e) (do_vertex_util(pos, vec2(-(e).y, (e).x) / winsize.xy))
|
||||
#define do_vertex_win(pos, e) ( do_vertex( WIN2CLIP( pos ), e ) )
|
||||
|
||||
// if vertex is shared by two segments of the line still need to emit it twice
|
||||
// if vertex is shared by two segments of the line still need to emit it twice
|
||||
// to avoid smoothing artifacts
|
||||
// don't forget to initialize `vec2 EDGE_DIR` for macro to work
|
||||
// `pos0` / `pos1` - vertex position in clip space
|
||||
@@ -197,10 +197,13 @@ void do_circle_head(vec4 pos_w, vec4 head[CIRCLE_SEGS]) {
|
||||
|
||||
def add_verts_sequence(verts, start_i, output_verts, output_edges, closed=False):
|
||||
"""Add sequence of verts to output lists, returns next vertex index"""
|
||||
i = None
|
||||
for i, v in enumerate(verts[:-1], start_i):
|
||||
output_verts.append(v)
|
||||
output_edges.append((i, i + 1))
|
||||
output_verts.append(verts[-1])
|
||||
assert i is not None
|
||||
|
||||
if closed:
|
||||
output_edges.append((i + 1, start_i))
|
||||
return i + 2
|
||||
@@ -273,7 +276,7 @@ class BaseShader:
|
||||
FRAG_GLSL = """
|
||||
uniform vec4 color;
|
||||
uniform float lineWidth;
|
||||
|
||||
|
||||
in float smoothline;
|
||||
out vec4 fragColor;
|
||||
void main() {
|
||||
|
||||
@@ -1449,6 +1449,7 @@ class SvgWriter:
|
||||
angle_tg = rise / run
|
||||
angle = round(degrees(atan(angle_tg)))
|
||||
else:
|
||||
angle_tg = None
|
||||
angle = 90
|
||||
|
||||
# ues SLOPE_ANGLE as default
|
||||
@@ -1462,6 +1463,7 @@ class SvgWriter:
|
||||
elif object_type == "SLOPE_PERCENT":
|
||||
if angle == 90:
|
||||
return "-"
|
||||
assert angle_tg is not None
|
||||
return f"{round(angle_tg * 100)} %"
|
||||
|
||||
tag = element.Description or get_label_text()
|
||||
|
||||
@@ -113,6 +113,19 @@ class BIM_PT_camera(Panel):
|
||||
row.prop(props, "fill_mode")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "cut_mode")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "use_edge_classification")
|
||||
if props.use_edge_classification:
|
||||
row = self.layout.row()
|
||||
row.prop(props, "render_creases")
|
||||
row.prop(props, "valley_angle_min_degrees")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "render_sharp")
|
||||
row.prop(props, "ridge_angle_min_degrees")
|
||||
row = self.layout.row()
|
||||
row.prop(props, "render_flush")
|
||||
|
||||
row = self.layout.row()
|
||||
row.prop(props, "width")
|
||||
row = self.layout.row()
|
||||
@@ -319,6 +332,8 @@ class BIM_PT_drawings(Panel):
|
||||
|
||||
row3.separator(factor=0.5, type="SPACE")
|
||||
|
||||
row3.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="")
|
||||
|
||||
row3.operator("bim.select_all_drawings", icon="CHECKBOX_HLT", text="")
|
||||
row3.operator("bim.create_drawing", text="", icon="OUTPUT")
|
||||
row3.operator("bim.convert_svg_to_dxf", text="", icon="SEQ_PREVIEW").view = active_drawing.name
|
||||
@@ -326,6 +341,7 @@ class BIM_PT_drawings(Panel):
|
||||
self.layout.template_list(
|
||||
"BIM_UL_drawinglist", "", self.props, "drawings", self.props, "active_drawing_index"
|
||||
)
|
||||
self.layout.prop(self.props, "show_drawings_on_sheets_only")
|
||||
|
||||
|
||||
class BIM_PT_schedules(Panel):
|
||||
@@ -858,8 +874,8 @@ class BIM_UL_drawinglist(bpy.types.UIList):
|
||||
layout.label(text="", translate=False)
|
||||
return
|
||||
|
||||
row = layout.row(align=True)
|
||||
if item.is_drawing:
|
||||
row = layout.row(align=True)
|
||||
row.label(text="", icon="BLANK1")
|
||||
selected_icon = "CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT"
|
||||
row.prop(item, "is_selected", text="", icon=selected_icon, emboss=False)
|
||||
@@ -880,6 +896,9 @@ class BIM_UL_drawinglist(bpy.types.UIList):
|
||||
item.ifc_definition_id
|
||||
)
|
||||
else:
|
||||
# Give category headers a distinct inset background so they stand out from drawing rows.
|
||||
box = layout.box()
|
||||
row = box.row(align=True)
|
||||
if item.target_view == "PLAN_VIEW":
|
||||
icon = "UV_FACESEL"
|
||||
elif item.target_view == "ELEVATION_VIEW":
|
||||
@@ -900,7 +919,55 @@ class BIM_UL_drawinglist(bpy.types.UIList):
|
||||
op = row.operator("bim.toggle_target_view", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT")
|
||||
op.target_view = item.target_view
|
||||
op.option = "EXPAND"
|
||||
row.prop(item, "name", text="", icon=icon, emboss=False)
|
||||
group = tool.Drawing.get_visible_drawings_in_category(item.target_view)
|
||||
all_selected = bool(group) and all(d.is_selected for d in group)
|
||||
row.operator(
|
||||
"bim.toggle_drawing_category_selection",
|
||||
text="",
|
||||
icon="CHECKBOX_HLT" if all_selected else "CHECKBOX_DEHLT",
|
||||
emboss=False,
|
||||
).target_view = item.target_view
|
||||
row.separator(factor=0.5, type="SPACE")
|
||||
# Clicking the header name toggles expand/contract, same as the disclosure triangle.
|
||||
op = row.operator("bim.toggle_target_view", text=item.name, icon=icon, emboss=False)
|
||||
op.target_view = item.target_view
|
||||
op.option = "CONTRACT" if item.is_expanded else "EXPAND"
|
||||
|
||||
def filter_items(self, context, data: DocProperties, propname: str):
|
||||
drawings = getattr(data, propname)
|
||||
helper_funcs = bpy.types.UI_UL_list
|
||||
|
||||
flt_flags = []
|
||||
flt_neworder = []
|
||||
|
||||
if self.filter_name:
|
||||
flt_flags = helper_funcs.filter_items_by_name(
|
||||
self.filter_name,
|
||||
self.bitflag_filter_item,
|
||||
drawings,
|
||||
"name",
|
||||
reverse=self.use_filter_sort_reverse,
|
||||
)
|
||||
if not flt_flags:
|
||||
flt_flags = [self.bitflag_filter_item] * len(drawings)
|
||||
|
||||
props = tool.Drawing.get_document_props()
|
||||
if props.show_drawings_on_sheets_only:
|
||||
ifc_file = tool.Ifc.get()
|
||||
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids()
|
||||
# Target view headers are only shown if they contain a sheeted drawing.
|
||||
sheeted_target_views = {
|
||||
tool.Drawing.get_drawing_target_view(ifc_file.by_id(drawing_id)) for drawing_id in sheeted_ids
|
||||
}
|
||||
for i, item in enumerate(drawings):
|
||||
if item.is_drawing:
|
||||
is_visible = item.ifc_definition_id in sheeted_ids
|
||||
else:
|
||||
is_visible = item.target_view in sheeted_target_views
|
||||
if not is_visible:
|
||||
flt_flags[i] &= ~self.bitflag_filter_item
|
||||
|
||||
return flt_flags, flt_neworder
|
||||
|
||||
|
||||
class BIM_UL_sheets(bpy.types.UIList):
|
||||
@@ -964,14 +1031,14 @@ class BIM_UL_sheets(bpy.types.UIList):
|
||||
|
||||
if self.filter_name:
|
||||
filter_name = self.filter_name.lower()
|
||||
active_sheet = None
|
||||
active_sheet_index = None
|
||||
for sheet in data.sheets:
|
||||
if sheet.is_sheet:
|
||||
active_sheet = sheet
|
||||
active_sheet_index = len(flt_flags)
|
||||
if filter_name in sheet.name.lower() or filter_name in sheet.identification.lower():
|
||||
flt_flags.append(self.bitflag_filter_item)
|
||||
if not sheet.is_sheet:
|
||||
assert active_sheet_index is not None
|
||||
flt_flags[active_sheet_index] = self.bitflag_filter_item
|
||||
else:
|
||||
flt_flags.append(0)
|
||||
|
||||
@@ -225,6 +225,9 @@ class AnnotationToolUI:
|
||||
def draw_edit_object_interface(cls, context):
|
||||
if DecoratorData.get_text_data(bpy.context.active_object):
|
||||
add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "")
|
||||
if bpy.ops.bim.copy_annotation_to_drawing.poll():
|
||||
row = cls.layout.row(align=True)
|
||||
row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing")
|
||||
|
||||
@classmethod
|
||||
def draw_type_selection_interface(cls):
|
||||
|
||||
@@ -75,9 +75,13 @@ class Helper:
|
||||
for face in bm.faces:
|
||||
if len(face.verts) > 4:
|
||||
potential_faces.append(face)
|
||||
|
||||
# TODO: replace with next(..., None)
|
||||
face = None
|
||||
for face in potential_faces:
|
||||
if face.normal.z < -0.1:
|
||||
break
|
||||
assert face is not None
|
||||
|
||||
profile = [l.vert.index for l in face.loops]
|
||||
extrusion = self.detect_extrusion_edge(bm, face)
|
||||
@@ -108,10 +112,12 @@ class Helper:
|
||||
if not potential_faces:
|
||||
potential_faces = bm.faces
|
||||
|
||||
# TODO: replace with next(..., None)
|
||||
face = None
|
||||
for face in potential_faces:
|
||||
if face.normal.z < -0.1:
|
||||
break
|
||||
|
||||
assert face is not None
|
||||
profile = [l.vert.index for l in face.loops]
|
||||
extrusion = self.detect_extrusion_edge(bm, face)
|
||||
|
||||
@@ -145,9 +151,12 @@ class Helper:
|
||||
if total_verts > 4:
|
||||
potential_faces.append(face)
|
||||
|
||||
# TODO: replace with next(..., None)
|
||||
face = None
|
||||
for face in potential_faces:
|
||||
if face.normal.z < -0.1:
|
||||
break
|
||||
assert face is not None
|
||||
|
||||
end_faces = []
|
||||
end_face_normal = face.normal
|
||||
|
||||
@@ -581,7 +581,11 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if has_openings and not self.apply_openings:
|
||||
# Meshlike things with openings can only be updated without openings applied.
|
||||
if self.from_ui:
|
||||
self.report({"ERROR"}, f"Object '{obj.name}' has openings - representation cannot be updated.")
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Object '{obj.name}' has openings. "
|
||||
"ALT+click the button to bake the openings into the new representation.",
|
||||
)
|
||||
return
|
||||
|
||||
if not product.is_a("IfcGridAxis"):
|
||||
@@ -3523,12 +3527,15 @@ class EditRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if props.representation_item_shape_aspect == "NEW":
|
||||
active_representation = tool.Geometry.get_active_representation(obj)
|
||||
# find IfcProductRepresentationSelect based on current representation
|
||||
product_shape = None
|
||||
if hasattr(element, "Representation"): # IfcProduct
|
||||
product_shape = element.Representation
|
||||
else: # IfcTypeProduct
|
||||
for representation_map in element.RepresentationMaps:
|
||||
if representation_map.MappedRepresentation == active_representation:
|
||||
product_shape = representation_map
|
||||
assert product_shape is not None
|
||||
|
||||
previous_shape_aspect_id = props.active_item.shape_aspect_id
|
||||
# will be None if item didn't had a shape aspect
|
||||
previous_shape_aspect = tool.Ifc.get_entity_by_id(previous_shape_aspect_id)
|
||||
@@ -3878,6 +3885,8 @@ class AddSweptAreaSolidItem(bpy.types.Operator, tool.Ifc.Operator):
|
||||
curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale)
|
||||
elif self.shape == "CYLINDER":
|
||||
curve = builder.circle(radius=0.25 / unit_scale)
|
||||
else:
|
||||
assert False, self.shape
|
||||
item = builder.extrude(
|
||||
curve,
|
||||
magnitude=0.5 / unit_scale,
|
||||
@@ -4115,6 +4124,31 @@ class OverrideMoveSelect(bpy.types.Operator):
|
||||
self.new_active_obj = obj
|
||||
return {"FINISHED"}
|
||||
|
||||
# Get arrays
|
||||
ifc_file = tool.Ifc.get()
|
||||
array_parents_to_move: list[bpy.types.Object] = []
|
||||
for obj in list(context.selected_objects):
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
continue
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
continue
|
||||
parent_element = ifc_file.by_guid(pset["Parent"])
|
||||
parent_obj = tool.Ifc.get_object(parent_element)
|
||||
if parent_obj not in array_parents_to_move:
|
||||
array_parents_to_move.append(parent_obj)
|
||||
if element.GlobalId != pset["Parent"]:
|
||||
obj.select_set(False)
|
||||
|
||||
if array_parents_to_move:
|
||||
for parent_obj in array_parents_to_move:
|
||||
parent_element = tool.Ifc.get_entity(parent_obj)
|
||||
for array_obj in tool.Array.get_all_objects(parent_element):
|
||||
array_obj.select_set(True)
|
||||
self.new_active_obj = parent_obj
|
||||
return {"FINISHED"}
|
||||
|
||||
# Get nests
|
||||
props = tool.Nest.get_nest_props()
|
||||
not_editing_objs = [o.obj for o in props.not_editing_objects]
|
||||
|
||||
@@ -163,14 +163,52 @@ class AssignGroup(bpy.types.Operator, tool.Ifc.Operator):
|
||||
def _execute(self, context):
|
||||
if not self.is_assigning:
|
||||
return bpy.ops.bim.unassign_group(group=self.group)
|
||||
ifc_file = tool.Ifc.get()
|
||||
group = ifc_file.by_id(self.group)
|
||||
products = [
|
||||
element
|
||||
for o in tool.Blender.get_selected_objects(include_active=False)
|
||||
if (element := tool.Ifc.get_entity(o))
|
||||
]
|
||||
ifcopenshell.api.group.assign_group(tool.Ifc.get(), products=products, group=tool.Ifc.get().by_id(self.group))
|
||||
relocated_annotations = self.unassign_from_previous_drawing(ifc_file, group, products)
|
||||
ifcopenshell.api.group.assign_group(ifc_file, products=products, group=group)
|
||||
self.relocate_annotations_to_drawing(relocated_annotations, group)
|
||||
self.report({"INFO"}, f"Assigned {len(products)} objects to group.")
|
||||
|
||||
def unassign_from_previous_drawing(self, ifc_file, group, products) -> list[ifcopenshell.entity_instance]:
|
||||
"""Assigning an annotation to a group that represents a drawing means the
|
||||
annotation should belong to that drawing only, so it needs to leave
|
||||
whichever drawing it was previously part of, instead of ending up
|
||||
visible in both at once.
|
||||
"""
|
||||
new_drawing = tool.Drawing.get_group_drawing(group)
|
||||
if not new_drawing:
|
||||
return []
|
||||
relocated = []
|
||||
for product in products:
|
||||
if not product.is_a("IfcAnnotation") or product.ObjectType == "DRAWING":
|
||||
continue
|
||||
old_drawing = tool.Drawing.get_annotation_drawing(product)
|
||||
if not old_drawing or old_drawing.id() == new_drawing.id():
|
||||
continue
|
||||
if old_group := tool.Drawing.get_drawing_group(old_drawing):
|
||||
ifcopenshell.api.group.unassign_group(ifc_file, products=[product], group=old_group)
|
||||
relocated.append(product)
|
||||
return relocated
|
||||
|
||||
def relocate_annotations_to_drawing(self, products, group) -> None:
|
||||
"""Move the relocated annotations into the new drawing's collection and
|
||||
depth, now that they have actually been assigned to its group.
|
||||
"""
|
||||
if not products:
|
||||
return
|
||||
new_drawing = tool.Drawing.get_group_drawing(group)
|
||||
new_camera = tool.Ifc.get_object(new_drawing) or tool.Drawing.import_drawing(new_drawing)
|
||||
for product in products:
|
||||
if obj := tool.Ifc.get_object(product):
|
||||
tool.Drawing.ensure_annotation_in_drawing_plane(obj, camera=new_camera)
|
||||
tool.Collector.assign(obj)
|
||||
|
||||
|
||||
class UnassignGroup(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.unassign_group"
|
||||
|
||||
@@ -156,6 +156,7 @@ class RadianceRender(bpy.types.Operator):
|
||||
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
|
||||
hdr_image_path, hdr_mask_path, sky_map_cal_path = None, None
|
||||
if use_hdr:
|
||||
hdr_image = "noon_grass_2k.hdr"
|
||||
hdr_mask = "noon_grass_2k_mask.hdr"
|
||||
@@ -254,6 +255,9 @@ class RadianceRender(bpy.types.Operator):
|
||||
# 4 0 0 -1 180
|
||||
|
||||
if use_hdr and choose_hdr_image == "Noon":
|
||||
assert hdr_image_path is not None
|
||||
assert hdr_mask_path is not None
|
||||
assert sky_map_cal_path is not None
|
||||
|
||||
with open(sky_file_path, "w") as f:
|
||||
f.write(sky_description_str)
|
||||
|
||||
@@ -56,6 +56,7 @@ classes = (
|
||||
operator.RemoveMaterial,
|
||||
operator.RemoveMaterialSet,
|
||||
operator.RemoveProfile,
|
||||
operator.RenameMaterial,
|
||||
operator.ReorderMaterialSetItem,
|
||||
operator.SelectByMaterial,
|
||||
operator.SelectMaterialInMaterialsUI,
|
||||
|
||||
@@ -102,6 +102,26 @@ class EditMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
core.edit_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material))
|
||||
|
||||
|
||||
class RenameMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.rename_material"
|
||||
bl_label = "Rename Material"
|
||||
bl_description = "Rename an IfcMaterial"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
material: bpy.props.IntProperty()
|
||||
name: bpy.props.StringProperty(name="Name")
|
||||
|
||||
def invoke(self, context, event):
|
||||
material = tool.Ifc.get().by_id(self.material)
|
||||
self.name = material.Name or ""
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
self.layout.prop(self, "name")
|
||||
|
||||
def _execute(self, context):
|
||||
core.rename_material(tool.Ifc, tool.Material, material=tool.Ifc.get().by_id(self.material), name=self.name)
|
||||
|
||||
|
||||
class DisableEditingMaterial(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_material"
|
||||
bl_label = "Disable Editing Material"
|
||||
|
||||
@@ -564,6 +564,7 @@ class SelectAllArrayObjects(bpy.types.Operator):
|
||||
except RuntimeError:
|
||||
self.report({"ERROR"}, f"Objects that don't have an array parent, were deselected.")
|
||||
object.select_set(False)
|
||||
continue
|
||||
|
||||
array_objects = tool.Array.get_all_objects(parent_element)
|
||||
tool.Blender.set_objects_selection(
|
||||
|
||||
@@ -93,6 +93,30 @@ def _stroke_lines_alpha(
|
||||
gpu.state.blend_set("NONE")
|
||||
|
||||
|
||||
def _connected_components(
|
||||
vertex_groups: dict[int, list[bmesh.types.BMVert]],
|
||||
) -> list[list[bmesh.types.BMVert]]:
|
||||
"""Split each vertex group's members into their connected components,
|
||||
since a duplicated arc/circle loop shares its source loop's group index."""
|
||||
components = []
|
||||
for verts in vertex_groups.values():
|
||||
remaining = set(verts)
|
||||
while remaining:
|
||||
seed = remaining.pop()
|
||||
stack = [seed]
|
||||
component = [seed]
|
||||
while stack:
|
||||
v = stack.pop()
|
||||
for edge in v.link_edges:
|
||||
other = edge.other_vert(v)
|
||||
if other in remaining:
|
||||
remaining.discard(other)
|
||||
stack.append(other)
|
||||
component.append(other)
|
||||
components.append(component)
|
||||
return components
|
||||
|
||||
|
||||
class ProfileDecorator:
|
||||
installed = None
|
||||
|
||||
@@ -265,7 +289,7 @@ class ProfileDecorator:
|
||||
# Draw arcs
|
||||
arc_centroids = []
|
||||
arc_segments = []
|
||||
for arc in arcs.values():
|
||||
for arc in _connected_components(arcs):
|
||||
if len(arc) != 3:
|
||||
continue
|
||||
sorted_arc = [None, None, None]
|
||||
@@ -292,7 +316,7 @@ class ProfileDecorator:
|
||||
# Draw circles
|
||||
circle_centroids = []
|
||||
circle_segments = []
|
||||
for circle in circles.values():
|
||||
for circle in _connected_components(circles):
|
||||
if len(circle) != 2:
|
||||
continue
|
||||
p1 = obj.matrix_world @ circle[0].co
|
||||
|
||||
@@ -408,6 +408,8 @@ class MEPGenerator:
|
||||
compare = tool.Cad.is_x(requested_value, fitting_value, compare_precision)
|
||||
elif isinstance(fitting_value, list):
|
||||
compare = tool.Cad.are_vectors_equal(requested_value, Vector(fitting_value), precision)
|
||||
else:
|
||||
assert False, f"{key} {second_key}"
|
||||
return compare
|
||||
|
||||
ignore_keys = []
|
||||
@@ -476,11 +478,13 @@ class MEPGenerator:
|
||||
if predefined_type == "OBSTRUCTION":
|
||||
return packed_data
|
||||
|
||||
start_port = None
|
||||
for port in ports:
|
||||
port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
||||
if tool.Cad.is_x(port_local_position.length, 0.0):
|
||||
start_port = port
|
||||
break
|
||||
assert start_port is not None
|
||||
|
||||
connected_port = tool.System.get_connected_port(start_port)
|
||||
connected_element = tool.System.get_port_relating_element(connected_port)
|
||||
|
||||
@@ -325,7 +325,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if self.from_invoke and str(self.relating_type_id) in AuthoringData.data["relating_type_id"]:
|
||||
props.relating_type_id = str(self.relating_type_id)
|
||||
|
||||
building_obj = None
|
||||
building_obj, building_element = None, None
|
||||
if len(context.selected_objects) == 1 and context.active_object:
|
||||
building_obj = context.active_object
|
||||
building_element = tool.Ifc.get_entity(building_obj)
|
||||
|
||||
@@ -593,6 +593,8 @@ class DumbProfileJoiner:
|
||||
axisl = (profile2.matrix_world.inverted() @ axis1[1]) - (profile2.matrix_world.inverted() @ axis1[0])
|
||||
elif connection1 == "ATSTART":
|
||||
axisl = (profile2.matrix_world.inverted() @ axis1[0]) - (profile2.matrix_world.inverted() @ axis1[1])
|
||||
else:
|
||||
assert False, connection1
|
||||
xy_angle = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
|
||||
if xy_angle >= -135 and xy_angle <= -45:
|
||||
closest_plane = "bottom"
|
||||
@@ -617,6 +619,8 @@ class DumbProfileJoiner:
|
||||
axisl = (profile1.matrix_world.inverted() @ axis2[1]) - (profile1.matrix_world.inverted() @ axis2[0])
|
||||
elif connection2 == "ATSTART":
|
||||
axisl = (profile1.matrix_world.inverted() @ axis2[0]) - (profile1.matrix_world.inverted() @ axis2[1])
|
||||
else:
|
||||
assert False, connection2
|
||||
xy_angle2 = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
|
||||
if xy_angle2 >= -135 and xy_angle2 <= -45:
|
||||
closest_plane2 = "bottom"
|
||||
@@ -844,6 +848,8 @@ class DumbProfileJoiner:
|
||||
else:
|
||||
y_axis = obj.matrix_world.to_quaternion() @ Vector((0, 1, 0))
|
||||
z_axis = obj.matrix_world.to_quaternion() @ Vector((-1, 0, 0))
|
||||
else:
|
||||
assert False, plane
|
||||
return self.create_matrix(p, x_axis, y_axis, z_axis)
|
||||
|
||||
def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix:
|
||||
|
||||
@@ -508,6 +508,7 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
converter.run()
|
||||
|
||||
profile = tool.Ifc.get().createIfcArbitraryClosedProfileDef("AREA")
|
||||
curve = None
|
||||
for path in converter.paths:
|
||||
points = []
|
||||
lines = path[0]
|
||||
@@ -517,6 +518,7 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
|
||||
points.append(tool.Ifc.get().createIfcCartesianPoint(local_point))
|
||||
points.append(points[0])
|
||||
curve = tool.Ifc.get().createIfcPolyline(points)
|
||||
assert curve
|
||||
profile.OuterCurve = curve
|
||||
|
||||
old_profile = extrusion.SweptArea
|
||||
|
||||
@@ -1577,6 +1577,7 @@ class DumbWallJoiner:
|
||||
# Get the ATEND connection from wall1 to use it in wall2
|
||||
relating_element = None
|
||||
connections = element1.ConnectedTo
|
||||
relating_connection, description = ..., ...
|
||||
for conn in connections:
|
||||
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
|
||||
relating_element = conn.RelatedElement
|
||||
@@ -1591,6 +1592,7 @@ class DumbWallJoiner:
|
||||
description = conn.Description
|
||||
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
|
||||
if relating_element:
|
||||
assert relating_connection is not ... and description is not ...
|
||||
ifcopenshell.api.geometry.connect_path(
|
||||
tool.Ifc.get(),
|
||||
relating_element=relating_element,
|
||||
|
||||
@@ -162,7 +162,7 @@ class ProjectLibraryData:
|
||||
library_file = IfcStore.library_file
|
||||
if library_file is None or library_file.schema == "IFC2X3":
|
||||
return results
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
root = library_file.by_type("IfcProject")[0]
|
||||
results.append((str(root.id()), f"{root.is_a()} {root.Name or 'Unnamed'}", root.Description or ""))
|
||||
for library_id, data in cls.data["project_libraries"].items():
|
||||
results.append((str(library_id), data["Name"] or "Unnamed", data["Description"] or ""))
|
||||
|
||||
@@ -281,7 +281,7 @@ class RefreshLibrary(bpy.types.Operator):
|
||||
elements = {e for e in elements if not tool.Project.is_element_assigned_to_project_library(e, rels)}
|
||||
self.props.add_library_project_library("Unassigned", len(elements), 0, False)
|
||||
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
root_context = library_file.by_type("IfcProject")[0]
|
||||
hierarchy = tool.Project.get_project_hierarchy(library_file)
|
||||
tool.Project.load_project_libraries_to_ui(root_context, hierarchy)
|
||||
return {"FINISHED"}
|
||||
@@ -714,6 +714,8 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
representations = element.RepresentationMaps or []
|
||||
elif element.is_a("IfcProduct"):
|
||||
representations = [element.Representation] if element.Representation else []
|
||||
else:
|
||||
assert False, element
|
||||
for representation in representations or []:
|
||||
for element in self.file.traverse(representation):
|
||||
if not element.is_a("IfcRepresentationItem") or not element.StyledByItem:
|
||||
@@ -759,21 +761,22 @@ class EditProjectLibrary(bpy.types.Operator):
|
||||
attributes = bonsai.bim.helper.export_attributes(props.project_library_attributes)
|
||||
ifcopenshell.api.attribute.edit_attributes(library_file, project_library, attributes)
|
||||
|
||||
# Update parent library.
|
||||
# Update parent library. Tear down the old IfcRelDeclares/IfcRelNests before
|
||||
# creating the new one; a library must have exactly one of the two, never both.
|
||||
previous_parent_library = tool.Project.get_parent_library(project_library)
|
||||
new_parent_library = library_file.by_id(int(props.parent_library))
|
||||
if previous_parent_library != new_parent_library:
|
||||
if previous_parent_library is None:
|
||||
# Edited library was a root in a library-only file; nest it under the new parent.
|
||||
if previous_parent_library is not None:
|
||||
if previous_parent_library.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.unassign_declaration(
|
||||
library_file, [project_library], previous_parent_library
|
||||
)
|
||||
else:
|
||||
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
|
||||
if new_parent_library.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], new_parent_library)
|
||||
else:
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
elif previous_parent_library.is_a("IfcProject"):
|
||||
# Then new one is IfcProjectLibrary.
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
else: # Previous is IfcProjectLibrary.
|
||||
ifcopenshell.api.nest.unassign_object(library_file, [project_library])
|
||||
# If new one is IfcProject, then it's already assigned by default.
|
||||
if new_parent_library.is_a("IfcProjectLibrary"):
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], new_parent_library)
|
||||
|
||||
props.is_editing_project_library = False
|
||||
bpy.ops.bim.refresh_library()
|
||||
@@ -807,12 +810,9 @@ class AddProjectLibrary(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
library_file = IfcStore.library_file
|
||||
assert library_file
|
||||
root_context = tool.Project.get_root_context(library_file)
|
||||
root_context = library_file.by_type("IfcProject")[0]
|
||||
project_library = ifcopenshell.api.root.create_entity(library_file, "IfcProjectLibrary")
|
||||
if root_context.is_a("IfcProject"):
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
|
||||
else:
|
||||
ifcopenshell.api.nest.assign_object(library_file, [project_library], root_context)
|
||||
ifcopenshell.api.project.assign_declaration(library_file, [project_library], root_context)
|
||||
ProjectLibraryData.load() # Update enum.
|
||||
props.selected_project_library = str(project_library.id())
|
||||
props.is_editing_project_library = True
|
||||
@@ -1299,6 +1299,10 @@ class LoadProjectElements(bpy.types.Operator):
|
||||
tool.Project.set_default_modeling_dimensions()
|
||||
tool.Root.reload_grid_decorator()
|
||||
bonsai.bim.handler.refresh_ui_data()
|
||||
for screen in bpy.data.screens:
|
||||
for area in screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
bonsai.bim.handler.viewport_shading_changed_callback(area)
|
||||
return {"FINISHED"}
|
||||
|
||||
def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]:
|
||||
@@ -1576,10 +1580,13 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
|
||||
|
||||
def should_clear_cache() -> bool:
|
||||
if not self.use_cache:
|
||||
return True
|
||||
# Nothing to clear if the cache file was never created (e.g. a
|
||||
# fresh link). Check this first so os.remove below is never
|
||||
# called on a non-existent path, regardless of use_cache.
|
||||
if not blend_filepath.exists():
|
||||
return False
|
||||
if not self.use_cache:
|
||||
return True
|
||||
data = json.loads(json_filepath.read_text())
|
||||
# Empty 'query' - model loaded without custom query.
|
||||
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
|
||||
@@ -2029,6 +2036,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
project_props = tool.Project.get_project_props()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
project_props.use_relative_project_path = self.use_relative_path
|
||||
old_history_size, old_undo_steps = None, None
|
||||
if prefs.should_disable_undo_on_save:
|
||||
old_history_size = tool.Ifc.get().history_size
|
||||
old_undo_steps = context.preferences.edit.undo_steps
|
||||
@@ -2036,6 +2044,7 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
context.preferences.edit.undo_steps = 0
|
||||
IfcStore.execute_ifc_operator(self, context)
|
||||
if prefs.should_disable_undo_on_save:
|
||||
assert old_history_size is not None and old_undo_steps is not None
|
||||
tool.Ifc.get().history_size = old_history_size
|
||||
context.preferences.edit.undo_steps = old_undo_steps
|
||||
return {"FINISHED"}
|
||||
|
||||
@@ -113,6 +113,8 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
|
||||
elif props.active_pset_type == "QTO":
|
||||
pset = ifcopenshell.api.pset.add_qto(self.file, product=element, name=props.active_pset_name)
|
||||
props.active_pset_id = pset.id()
|
||||
else:
|
||||
assert False
|
||||
|
||||
if self.properties:
|
||||
properties = json.loads(self.properties)
|
||||
|
||||
@@ -228,6 +228,8 @@ def get_qto_name(self: "PsetProperties", context: bpy.types.Context) -> tool.Ble
|
||||
if "bpy.data.objects" in pset_type:
|
||||
if prop_type == "PsetProperties":
|
||||
results = get_object_qto_name(self, context)
|
||||
else:
|
||||
assert False
|
||||
elif prop_type == "TaskPsetProperties":
|
||||
results = get_task_qto_names(self, context)
|
||||
elif prop_type == "ResourcePsetProperties":
|
||||
|
||||
@@ -480,6 +480,7 @@ class BIM_PT_material_psets(Panel):
|
||||
def draw(self, context):
|
||||
assert self.layout
|
||||
props = tool.Material.get_material_props()
|
||||
ifc_definition_id = None
|
||||
if material := props.active_material:
|
||||
ifc_definition_id = material.ifc_definition_id
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ def get_footing_length(o: bpy.types.Object) -> float:
|
||||
return get_length(o)
|
||||
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
|
||||
return get_z(o)
|
||||
elif predefined_type == "PAD_FOOTING":
|
||||
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
|
||||
return max(get_x(o), get_y(o))
|
||||
else:
|
||||
return get_length(o)
|
||||
@@ -197,12 +197,26 @@ def get_footing_height(o: bpy.types.Object) -> float:
|
||||
return get_height(o)
|
||||
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
|
||||
return get_y(o)
|
||||
elif predefined_type == "PAD_FOOTING":
|
||||
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
|
||||
return get_z(o)
|
||||
else:
|
||||
return get_height(o)
|
||||
|
||||
|
||||
def get_footing_width(o: bpy.types.Object) -> float:
|
||||
element = tool.Ifc.get_entity(o)
|
||||
assert element
|
||||
predefined_type = ifcopenshell.util.element.get_predefined_type(element)
|
||||
if not predefined_type:
|
||||
return get_width(o)
|
||||
if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING":
|
||||
return get_x(o)
|
||||
elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP":
|
||||
return get_width(o)
|
||||
else:
|
||||
return get_width(o)
|
||||
|
||||
|
||||
def get_height(o: bpy.types.Object) -> float:
|
||||
"""_summary_: Returns the height of the object bounding box
|
||||
|
||||
@@ -225,7 +239,7 @@ def get_opening_depth(obj: bpy.types.Object) -> float:
|
||||
if is_opening_horizontal(obj):
|
||||
return get_height(obj)
|
||||
else:
|
||||
return get_width(obj)
|
||||
return get_y(obj)
|
||||
|
||||
|
||||
def get_opening_mapping_area(obj: bpy.types.Object) -> float:
|
||||
|
||||
@@ -27,6 +27,7 @@ import ifcopenshell.api.material
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.schema
|
||||
import ifcopenshell.util.shape_builder
|
||||
import ifcopenshell.util.type
|
||||
@@ -129,13 +130,25 @@ class ReassignClass(bpy.types.Operator, tool.Ifc.Operator):
|
||||
same_ifc_product = element.is_a(ifc_product)
|
||||
|
||||
if not same_ifc_product:
|
||||
if not (element.is_a("IfcElement") and ifc_product == "IfcElementType") and not (
|
||||
element.is_a("IfcElementType") and ifc_product == "IfcElement"
|
||||
):
|
||||
self.report(
|
||||
{"ERROR"}, f"Not supported class reassignment for object '{obj.name}' -> {ifc_product}."
|
||||
# A spatial element (e.g. IfcSite) anchors the containment
|
||||
# hierarchy, so only allow reassigning it to another family when
|
||||
# it actually carries geometry - i.e. it's a real modelled thing
|
||||
# (a bench dropped onto IfcSite -> IfcFurniture) rather than an
|
||||
# empty spatial container we'd be turning into a loose element.
|
||||
# IfcSpatialStructureElement covers IFC2X3, which has no
|
||||
# IfcSpatialElement supertype.
|
||||
is_spatial = element.is_a("IfcSpatialElement") or element.is_a("IfcSpatialStructureElement")
|
||||
if is_spatial:
|
||||
has_geometry = (
|
||||
next(ifcopenshell.util.representation.get_representations_iter(element), None) is not None
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
if not has_geometry:
|
||||
self.report(
|
||||
{"ERROR"},
|
||||
f"Cannot reassign '{obj.name}' ({element.is_a()}) to {ifc_product}: "
|
||||
"a spatial element can only be reassigned to another class when it has geometry.",
|
||||
)
|
||||
return {"CANCELLED"}
|
||||
|
||||
props = tool.Blender.get_object_bim_props(obj)
|
||||
props.is_reassigning_class = False
|
||||
@@ -400,12 +413,13 @@ class UnlinkObject(bpy.types.Operator, tool.Ifc.Operator):
|
||||
skip_invoke: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def _execute(self, context):
|
||||
objects: list[bpy.types.Object]
|
||||
if self.obj:
|
||||
objects = [bpy.data.objects.get(self.obj)]
|
||||
requested_obj = bpy.data.objects.get(self.obj)
|
||||
objects = [requested_obj] if requested_obj is not None else []
|
||||
else:
|
||||
objects = context.selected_objects
|
||||
|
||||
objects: list[bpy.types.Object]
|
||||
for obj in objects:
|
||||
was_active_object = obj == context.active_object
|
||||
|
||||
@@ -617,10 +631,13 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
local_z = wall_matrix.to_3x3() @ Vector((0, 0, 1))
|
||||
direction_sense = getattr(usage, "DirectionSense", "POSITIVE")
|
||||
|
||||
if usage.LayerSetDirection == "AXIS2":
|
||||
layer_set_direction = usage.LayerSetDirection
|
||||
if layer_set_direction == "AXIS2":
|
||||
z_axis = tuple(local_y) if direction_sense == "POSITIVE" else tuple(-local_y)
|
||||
elif usage.LayerSetDirection == "AXIS3":
|
||||
elif layer_set_direction == "AXIS3":
|
||||
z_axis = tuple(local_z) if direction_sense == "POSITIVE" else tuple(-local_z)
|
||||
else:
|
||||
assert False, layer_set_direction
|
||||
|
||||
item = builder.extrude(
|
||||
profile,
|
||||
@@ -750,6 +767,8 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
|
||||
WebThickness=default_web_thickness / unit_scale,
|
||||
FlangeThickness=default_flange_thickness / unit_scale,
|
||||
)
|
||||
else:
|
||||
assert False, representation_template
|
||||
|
||||
rel = ifcopenshell.api.material.assign_material(
|
||||
tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet"
|
||||
|
||||
@@ -132,5 +132,12 @@ class SelectSimilarData:
|
||||
if pset.endswith("Common"):
|
||||
keys.extend([f'/.*Common/."{name}"' for name in properties.keys() if name != "id"])
|
||||
else:
|
||||
keys.extend([f"{pset}.{name}" for name in properties.keys() if name != "id"])
|
||||
pset_part = f'"{pset}"' if " " in pset else pset
|
||||
keys.extend(
|
||||
[
|
||||
f'{pset_part}."{name}"' if " " in name else f"{pset_part}.{name}"
|
||||
for name in properties.keys()
|
||||
if name != "id"
|
||||
]
|
||||
)
|
||||
return [(k, k, "") for k in keys]
|
||||
|
||||
@@ -1009,6 +1009,7 @@ class ColourByProperty(Operator):
|
||||
palette = props.palette
|
||||
is_qualitative = palette in ("tab10", "paired")
|
||||
|
||||
colours = None
|
||||
if is_qualitative:
|
||||
colours = tool.Search.get_qualitative_palette(palette)
|
||||
|
||||
@@ -1035,6 +1036,7 @@ class ColourByProperty(Operator):
|
||||
if value in colourscheme:
|
||||
colourscheme[value]["total"] += 1
|
||||
else:
|
||||
assert colours is not None
|
||||
colourscheme[value] = {"colour": next(colours)[0:3], "total": 1}
|
||||
obj.color = (*colourscheme[value]["colour"], 1)
|
||||
else:
|
||||
@@ -1139,6 +1141,7 @@ class SelectByProperty(Operator):
|
||||
|
||||
is_qualitative = palette in ("tab10", "paired")
|
||||
|
||||
values = None
|
||||
if not is_qualitative:
|
||||
values = []
|
||||
for colour in props.colourscheme:
|
||||
|
||||
@@ -12,7 +12,7 @@ function create_gantt_chart(json_data) {
|
||||
vShowTaskInfoLink: 1, // Show link in tool tip (0/1)
|
||||
vShowEndWeekDate: 0, // Show/Hide the date for the last day of the week in header for daily
|
||||
vUseSingleCell: 10000, // Set the threshold cell per table row (Helps performance for large data.
|
||||
vFormatArr: ['Day', 'Week', 'Month', 'Quarter'], // Even with setUseSingleCell using Hour format on such a large chart can cause issues in some browsers,
|
||||
vFormatArr: ['Hour', 'Day', 'Week', 'Month', 'Quarter'], // vUseSingleCell keeps Hour usable on large charts.
|
||||
vShowRes: true, // Disable the resource column.
|
||||
vShowComp: false, // Disable the completion column.
|
||||
vShowDur: false, // Disable the duration column, because jsgantt doesn't calculate durations the way we want.
|
||||
|
||||
@@ -281,11 +281,11 @@ class BIM_PT_work_schedules(Panel):
|
||||
def draw_task_operators(self) -> None:
|
||||
row = self.layout.row(align=True)
|
||||
row.alignment = "RIGHT"
|
||||
ifc_definition_id = None
|
||||
task, ifc_definition_id = None, None
|
||||
if self.tprops.tasks and self.props.active_task_index < len(self.tprops.tasks):
|
||||
task = self.tprops.tasks[self.props.active_task_index]
|
||||
ifc_definition_id = task.ifc_definition_id
|
||||
if ifc_definition_id:
|
||||
if task and ifc_definition_id:
|
||||
if self.props.active_task_id:
|
||||
if self.props.editing_task_type == "TASKTIME":
|
||||
row.operator("bim.edit_task_time", text="", icon="CHECKMARK")
|
||||
@@ -341,6 +341,8 @@ class BIM_PT_work_schedules(Panel):
|
||||
row.prop(self.props, "other_columns", text="")
|
||||
column_type, name = self.props.other_columns.split(".")
|
||||
data_type = "string"
|
||||
else:
|
||||
assert False, column_type
|
||||
row.operator("bim.set_task_sort_column", text="", icon="SORTALPHA").column = f"{column_type}.{name}"
|
||||
row.prop(
|
||||
self.props, "is_sort_reversed", text="", icon="SORT_DESC" if self.props.is_sort_reversed else "SORT_ASC"
|
||||
|
||||
@@ -516,7 +516,7 @@ class SetContainerVisibility(bpy.types.Operator):
|
||||
if self.mode == "ISOLATE":
|
||||
if tool.Ifc.get_schema() == "IFC2X3":
|
||||
containers = tool.Ifc.get().by_type("IfcSpatialStructureElement")
|
||||
elif tool.Ifc.get_schema() != "IFC2X3":
|
||||
else:
|
||||
containers = set(tool.Ifc.get().by_type("IfcSpatialElement"))
|
||||
containers -= set(tool.Ifc.get().by_type("IfcSpatialZone"))
|
||||
for container in containers:
|
||||
|
||||
@@ -125,6 +125,7 @@ class BIM_PT_spatial_decomposition(Panel):
|
||||
row.label(text="Warning: No Default Container", icon="ERROR")
|
||||
row.operator("bim.import_spatial_decomposition", icon="FILE_REFRESH", text="")
|
||||
|
||||
ifc_definition_id = None
|
||||
if self.props.active_container:
|
||||
ifc_definition_id = self.props.active_container.ifc_definition_id
|
||||
row = self.layout.row(align=True)
|
||||
@@ -170,6 +171,7 @@ class BIM_PT_spatial_decomposition(Panel):
|
||||
|
||||
if not self.props.active_container:
|
||||
return
|
||||
assert ifc_definition_id is not None
|
||||
|
||||
container_has_elements = bool(self.props.total_elements)
|
||||
if container_has_elements:
|
||||
|
||||
@@ -45,6 +45,7 @@ class SpatialTool(WorkSpaceTool):
|
||||
("bim.spatial_hotkey", {"type": "T", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_T")]}),
|
||||
("bim.spatial_hotkey", {"type": "G", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_G")]}),
|
||||
("bim.spatial_hotkey", {"type": "H", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_H")]}),
|
||||
("bim.spatial_hotkey", {"type": "Q", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_Q")]}),
|
||||
)
|
||||
|
||||
def draw_settings(context, layout, ws_tool):
|
||||
@@ -184,3 +185,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
def hotkey_S_G(self):
|
||||
bpy.ops.bim.generate_space()
|
||||
|
||||
def hotkey_S_Q(self):
|
||||
# Mirrors BimTool.hotkey_S_Q so quantities can be (re)calculated without switching tools.
|
||||
if not bpy.context.selected_objects:
|
||||
return
|
||||
bpy.ops.bim.perform_quantity_take_off()
|
||||
|
||||
@@ -45,7 +45,9 @@ classes = (
|
||||
operator.SelectByStyle,
|
||||
operator.SelectStyleInStylesUI,
|
||||
operator.SetAssetMaterialToExternalStyle,
|
||||
operator.SuggestShadeFromExternalStyle,
|
||||
operator.UnlinkStyle,
|
||||
operator.TogglePreferIfcShading,
|
||||
operator.UpdateCurrentStyle,
|
||||
operator.UpdateStyleColours,
|
||||
operator.UpdateStyleTextures,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import colorsys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
@@ -238,13 +239,15 @@ class UpdateCurrentStyle(bpy.types.Operator):
|
||||
if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)):
|
||||
continue
|
||||
for mat in obj.data.materials:
|
||||
if (
|
||||
mat
|
||||
and mat not in updated_materials
|
||||
and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0
|
||||
):
|
||||
msprops_.active_style_type = current_style_type
|
||||
updated_materials.add(mat)
|
||||
if not mat:
|
||||
continue
|
||||
msprops_ = tool.Style.get_material_style_props(mat)
|
||||
if msprops_.ifc_definition_id == 0:
|
||||
continue
|
||||
if mat in updated_materials:
|
||||
continue
|
||||
msprops_.active_style_type = current_style_type
|
||||
updated_materials.add(mat)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -457,10 +460,14 @@ class ActivateExternalStyle(bpy.types.Operator):
|
||||
self.report({"ERROR"}, f"Error loading external style for \"{material.name}\" - {db['msg']}")
|
||||
return {"CANCELLED"}
|
||||
|
||||
self.copy_material_attributes(db["data_block"], material)
|
||||
ext_mat = db["data_block"]
|
||||
self.copy_material_attributes(ext_mat, material)
|
||||
if tool.Style.get_use_nodes(material):
|
||||
tool.Blender.copy_node_graph(material, db["data_block"])
|
||||
bpy.data.materials.remove(db["data_block"])
|
||||
if material.get("bim_dual_branch"):
|
||||
tool.Style.update_external_branch(material, ext_mat)
|
||||
else:
|
||||
tool.Style.setup_dual_branch(material, ext_mat)
|
||||
bpy.data.materials.remove(ext_mat)
|
||||
return {"FINISHED"}
|
||||
|
||||
def copy_material_attributes(self, source, target):
|
||||
@@ -503,6 +510,218 @@ class ActivateExternalStyle(bpy.types.Operator):
|
||||
set_prop(prop_name)
|
||||
|
||||
|
||||
class TogglePreferIfcShading(bpy.types.Operator):
|
||||
bl_idname = "bim.toggle_prefer_ifc_shading"
|
||||
bl_label = "Toggle Flat/Pretty"
|
||||
bl_description = (
|
||||
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style) for ALL styles.\n\n"
|
||||
"SHIFT+CLICK to apply to this style only"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
|
||||
single_only: bpy.props.BoolProperty(name="Single Only", default=False, options={"SKIP_SAVE"})
|
||||
|
||||
def invoke(self, context, event):
|
||||
if event.shift:
|
||||
self.single_only = True
|
||||
return self.execute(context)
|
||||
|
||||
def execute(self, context):
|
||||
wm = context.window_manager
|
||||
space = tool.Blender.get_view3d_space()
|
||||
is_solid = space and space.shading.type == "SOLID"
|
||||
|
||||
if is_solid:
|
||||
if space.shading.color_type == "TEXTURE":
|
||||
space.shading.color_type = "MATERIAL"
|
||||
else:
|
||||
meshes_needing_uv = []
|
||||
for obj in bpy.context.scene.objects:
|
||||
if not isinstance(obj.data, bpy.types.Mesh):
|
||||
continue
|
||||
for slot in obj.material_slots:
|
||||
mat = slot.material
|
||||
if not mat or not tool.Blender.get_ifc_definition_id(mat):
|
||||
continue
|
||||
style_elements = tool.Style.get_style_elements(mat)
|
||||
if style_elements.get("IfcSurfaceStyleWithTextures") and not obj.data.uv_layers:
|
||||
meshes_needing_uv.append(obj.data)
|
||||
break
|
||||
wm.progress_begin(0, max(len(meshes_needing_uv), 1))
|
||||
try:
|
||||
for i, mesh in enumerate(meshes_needing_uv):
|
||||
tool.Loader.load_generated_uv_map(mesh)
|
||||
wm.progress_update(i)
|
||||
finally:
|
||||
wm.progress_end()
|
||||
space.shading.color_type = "TEXTURE"
|
||||
return {"FINISHED"}
|
||||
|
||||
if self.single_only:
|
||||
mat = bpy.data.materials.get(self.material_name)
|
||||
if not mat:
|
||||
return {"CANCELLED"}
|
||||
msprops = tool.Style.get_material_style_props(mat)
|
||||
msprops.prefer_ifc_shading = not msprops.prefer_ifc_shading
|
||||
else:
|
||||
# Default: apply to all IFC materials
|
||||
source_mat = bpy.data.materials.get(self.material_name)
|
||||
new_value = not tool.Style.get_material_style_props(source_mat).prefer_ifc_shading if source_mat else True
|
||||
ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)]
|
||||
for mat in ifc_mats:
|
||||
tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SuggestShadeFromExternalStyle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.suggest_shade_from_external_style"
|
||||
bl_label = "Suggest Shade from External Style"
|
||||
bl_description = (
|
||||
"Generate a Shade style (Surface Colour + Transparency) from the external .blend style.\n\n"
|
||||
"ALT+CLICK to apply to all styles with an external .blend style"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"})
|
||||
all_styles: bpy.props.BoolProperty(name="All Styles", default=False, options={"SKIP_SAVE"})
|
||||
value_offset: bpy.props.FloatProperty(
|
||||
name="Value",
|
||||
description="Offset added to the colour's value (-1 = fully dark, 0 = unchanged, +1 = fully light)",
|
||||
default=0.0,
|
||||
min=-1.0,
|
||||
max=1.0,
|
||||
step=1,
|
||||
precision=2,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
saturation_factor: bpy.props.FloatProperty(
|
||||
name="Saturation",
|
||||
description="Scale applied to the colour's saturation (0 = greyscale, 1 = unchanged, >1 = more saturated)",
|
||||
default=1.0,
|
||||
min=0.0,
|
||||
max=2.0,
|
||||
step=1,
|
||||
precision=2,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def invoke(self, context, event):
|
||||
if event.alt:
|
||||
self.all_styles = True
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "value_offset", slider=True)
|
||||
layout.prop(self, "saturation_factor", slider=True)
|
||||
if self.all_styles:
|
||||
layout.label(text="Will apply to all external styles", icon="INFO")
|
||||
|
||||
def _execute(self, context):
|
||||
if self.all_styles:
|
||||
candidates = [
|
||||
(mat, tool.Style.get_style_elements(mat))
|
||||
for mat in bpy.data.materials
|
||||
if tool.Blender.get_ifc_definition_id(mat)
|
||||
]
|
||||
candidates = [(mat, se) for mat, se in candidates if tool.Style.has_blender_external_style(se)]
|
||||
wm = context.window_manager
|
||||
wm.progress_begin(0, max(len(candidates), 1))
|
||||
count = 0
|
||||
color_cache: dict[tuple[str, str, str], tuple | None] = {}
|
||||
try:
|
||||
for i, (mat, style_elements) in enumerate(candidates):
|
||||
wm.progress_update(i)
|
||||
if self._apply_to_material(
|
||||
mat, style_elements, self.value_offset, self.saturation_factor, color_cache
|
||||
):
|
||||
count += 1
|
||||
finally:
|
||||
wm.progress_end()
|
||||
self.report({"INFO"}, f"Shade style generated for {count} style(s).")
|
||||
else:
|
||||
mat = bpy.data.materials.get(self.material_name)
|
||||
if not mat:
|
||||
return {"CANCELLED"}
|
||||
style_elements = tool.Style.get_style_elements(mat)
|
||||
if not tool.Style.has_blender_external_style(style_elements):
|
||||
self.report({"ERROR"}, "No external .blend style assigned. Please assign an external style first.")
|
||||
return {"CANCELLED"}
|
||||
self._apply_to_material(mat, style_elements, self.value_offset, self.saturation_factor)
|
||||
props = tool.Style.get_style_props()
|
||||
if props.is_editing:
|
||||
core.load_styles(tool.Style, style_type=props.style_type)
|
||||
|
||||
def _apply_to_material(
|
||||
self,
|
||||
material: bpy.types.Material,
|
||||
style_elements: dict,
|
||||
value_offset: float = 0.0,
|
||||
saturation_factor: float = 1.0,
|
||||
color_cache: "dict[tuple[str, str, str], tuple | None] | None" = None,
|
||||
) -> bool:
|
||||
external_style = style_elements["IfcExternallyDefinedSurfaceStyle"]
|
||||
style_path = Path(tool.Ifc.resolve_uri(external_style.Location))
|
||||
data_block_type, data_block = external_style.Identification.split("/")
|
||||
|
||||
cache_key = (str(style_path), data_block_type, data_block)
|
||||
if color_cache is not None and cache_key in color_cache:
|
||||
cached = color_cache[cache_key]
|
||||
if cached is None:
|
||||
return False # previously failed for this path
|
||||
surface_colour, transparency = cached
|
||||
else:
|
||||
try:
|
||||
db = tool.Blender.append_data_block(str(style_path), data_block_type, data_block)
|
||||
except OSError as e:
|
||||
self.report({"WARNING"}, f'Could not open blend file for "{material.name}": {e}')
|
||||
if color_cache is not None:
|
||||
color_cache[cache_key] = None
|
||||
return False
|
||||
if not db["data_block"]:
|
||||
self.report({"WARNING"}, f'Could not load external style for "{material.name}": {db["msg"]}')
|
||||
if color_cache is not None:
|
||||
color_cache[cache_key] = None
|
||||
return False
|
||||
|
||||
ext_mat = db["data_block"]
|
||||
surface_colour, transparency = tool.Style.get_representative_material_color(ext_mat)
|
||||
bpy.data.materials.remove(ext_mat)
|
||||
if color_cache is not None:
|
||||
color_cache[cache_key] = (surface_colour, transparency)
|
||||
|
||||
if value_offset != 0.0 or saturation_factor != 1.0:
|
||||
h, s, v = colorsys.rgb_to_hsv(*surface_colour)
|
||||
v = max(0.0, min(1.0, v + value_offset))
|
||||
s = max(0.0, min(1.0, s * saturation_factor))
|
||||
surface_colour = colorsys.hsv_to_rgb(h, s, v)
|
||||
|
||||
ifc_style = tool.Ifc.get_entity(material)
|
||||
attributes: dict = {
|
||||
"SurfaceColour": {
|
||||
"Name": None,
|
||||
"Red": surface_colour[0],
|
||||
"Green": surface_colour[1],
|
||||
"Blue": surface_colour[2],
|
||||
},
|
||||
}
|
||||
if tool.Ifc.get_schema() != "IFC2X3":
|
||||
attributes["Transparency"] = transparency
|
||||
|
||||
shading_style = style_elements.get("IfcSurfaceStyleShading")
|
||||
if shading_style:
|
||||
tool.Ifc.run("style.edit_surface_style", style=shading_style, attributes=attributes)
|
||||
else:
|
||||
tool.Ifc.run(
|
||||
"style.add_surface_style",
|
||||
style=ifc_style,
|
||||
ifc_class="IfcSurfaceStyleShading",
|
||||
attributes=attributes,
|
||||
)
|
||||
material.diffuse_color = (*surface_colour, 1.0 - transparency)
|
||||
tool.Style.sync_flat_branch_shading(material, surface_colour, transparency)
|
||||
return True
|
||||
|
||||
|
||||
class DisableEditingStyles(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_styles"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
|
||||
@@ -372,6 +372,16 @@ def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context)
|
||||
tool.Style.switch_shading(blender_material, self.active_style_type)
|
||||
|
||||
|
||||
def update_prefer_ifc_shading(self: "BIMStyleProperties", context: bpy.types.Context) -> None:
|
||||
style_elements = tool.Style.get_style_elements(self.id_data)
|
||||
has_external = tool.Style.has_blender_external_style(style_elements)
|
||||
if self.prefer_ifc_shading or not has_external:
|
||||
self.active_style_type = "Shading"
|
||||
else:
|
||||
self.active_style_type = "External"
|
||||
self.id_data.update_tag()
|
||||
|
||||
|
||||
class BIMStyleProperties(PropertyGroup):
|
||||
ifc_definition_id: IntProperty(name="IFC Definition ID")
|
||||
active_style_type: EnumProperty(
|
||||
@@ -381,9 +391,19 @@ class BIMStyleProperties(PropertyGroup):
|
||||
default="Shading",
|
||||
update=update_shading_style,
|
||||
)
|
||||
prefer_ifc_shading: BoolProperty(
|
||||
name="Flat / Pretty",
|
||||
description=(
|
||||
"Toggle between Flat (IFC-native shading) and Pretty (external .blend style). "
|
||||
"When set to Flat, viewport switches to Material Preview or Rendered will not activate the external style."
|
||||
),
|
||||
default=False,
|
||||
update=update_prefer_ifc_shading,
|
||||
)
|
||||
is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
ifc_definition_id: int
|
||||
active_style_type: tool.Style.StyleType
|
||||
prefer_ifc_shading: bool
|
||||
is_renaming: bool
|
||||
|
||||
@@ -102,6 +102,7 @@ class BIM_PT_styles(Panel):
|
||||
|
||||
# style ui tools
|
||||
if active_style:
|
||||
style = active_style
|
||||
row = self.layout.row(align=True)
|
||||
if material := style.blender_material:
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
@@ -109,6 +110,11 @@ class BIM_PT_styles(Panel):
|
||||
op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="")
|
||||
op.style_id = style.ifc_definition_id
|
||||
|
||||
if active_style and self.props.style_type == "IfcSurfaceStyle":
|
||||
if material := style.blender_material:
|
||||
msprops = tool.Style.get_material_style_props(material)
|
||||
self.draw_style_status_row(material, msprops)
|
||||
|
||||
if self.props.style_type == "IfcSurfaceStyle":
|
||||
self.layout.label(text="Surface Style Element:")
|
||||
col = self.layout.column(align=True)
|
||||
@@ -160,6 +166,66 @@ class BIM_PT_styles(Panel):
|
||||
edit_label = "Save Lighting Style"
|
||||
self.draw_edit_ui(edit_label)
|
||||
|
||||
def draw_style_status_row(self, material: bpy.types.Material, msprops) -> None:
|
||||
space = tool.Blender.get_view3d_space()
|
||||
box = self.layout.box()
|
||||
|
||||
obj = bpy.context.active_object
|
||||
|
||||
parts = []
|
||||
if space:
|
||||
shading_type = space.shading.type
|
||||
shading_labels = {
|
||||
"SOLID": "Solid",
|
||||
"MATERIAL": "Material Preview",
|
||||
"RENDERED": "Rendered",
|
||||
"WIREFRAME": "Wireframe",
|
||||
}
|
||||
parts.append(f"Viewport: {shading_labels.get(shading_type, shading_type)}")
|
||||
else:
|
||||
parts.append("No 3D viewport")
|
||||
shading_type = None
|
||||
|
||||
if obj:
|
||||
obj_has_uv = isinstance(obj.data, bpy.types.Mesh) and bool(obj.data.uv_layers)
|
||||
uv_label = "UV \u2713" if obj_has_uv else "UV \u2717"
|
||||
parts.append(f"Selected Object: {obj.name} {uv_label}")
|
||||
else:
|
||||
parts.append("Selected Object: None")
|
||||
|
||||
if shading_type == "SOLID":
|
||||
is_flat = space.shading.color_type != "TEXTURE"
|
||||
mode_label = "Flat"
|
||||
dep_label = "Shade"
|
||||
if not is_flat:
|
||||
mode_label = "Pretty"
|
||||
dep_label = "Texture \u2192 Shade"
|
||||
elif shading_type in ("MATERIAL", "RENDERED"):
|
||||
is_flat = msprops.prefer_ifc_shading
|
||||
mode_label = "Flat"
|
||||
dep_label = "Render+Texture \u2192 Render \u2192 Shade"
|
||||
if not is_flat:
|
||||
mode_label = "Pretty"
|
||||
dep_label = "External \u2192 Render+Texture \u2192 Render \u2192 Shade"
|
||||
else:
|
||||
is_flat = False
|
||||
mode_label = ""
|
||||
dep_label = ""
|
||||
|
||||
row1 = box.row(align=True)
|
||||
row1.label(text=" | ".join(parts))
|
||||
|
||||
if mode_label:
|
||||
row2 = box.row(align=True)
|
||||
row2.label(text=f"Current Mode: {mode_label} \u2014 {dep_label}")
|
||||
|
||||
row3 = box.row(align=True)
|
||||
row3.alignment = "RIGHT"
|
||||
op = row3.operator("bim.suggest_shade_from_external_style", text="", icon="BRUSHES_ALL")
|
||||
op.material_name = material.name
|
||||
op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT")
|
||||
op.material_name = material.name
|
||||
|
||||
def draw_surface_style_shading(self):
|
||||
row = self.layout.row()
|
||||
row.prop(self.props, "surface_colour")
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.api.attribute
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.api.type
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.representation
|
||||
@@ -115,51 +116,94 @@ class UnassignType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
if TYPE_CHECKING:
|
||||
related_object: str
|
||||
|
||||
def _execute(self, context):
|
||||
@staticmethod
|
||||
def _reattach_styles(file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance]) -> None:
|
||||
"""copy_deep only follows forward references, so IfcStyledItem (an inverse,
|
||||
``StyledByItem``) is not carried onto the copied geometry. Re-create a
|
||||
styled item on each copy that points at the same presentation styles as
|
||||
the original, so the unmapped occurrence keeps its appearance."""
|
||||
for original_id, copied in copied_entities.items():
|
||||
original = file.by_id(original_id)
|
||||
for styled_item in getattr(original, "StyledByItem", None) or []:
|
||||
file.create_entity(
|
||||
"IfcStyledItem",
|
||||
Item=copied,
|
||||
Styles=styled_item.Styles,
|
||||
Name=styled_item.Name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def unassign_and_unmap(obj: bpy.types.Object) -> None:
|
||||
"""Unassign the type from ``obj`` and bake a private copy of any mapped
|
||||
representation onto it, so the occurrence keeps its geometry, styles, and
|
||||
material once the type (the source of all three) is gone."""
|
||||
|
||||
def exclude_callback(attribute):
|
||||
return attribute.is_a("IfcProfileDef") and attribute.ProfileName
|
||||
|
||||
self.file = tool.Ifc.get()
|
||||
file = tool.Ifc.get()
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcObject"):
|
||||
return
|
||||
|
||||
# Capture the material inherited from the type before we sever the link,
|
||||
# but only if the occurrence has no material of its own to override it.
|
||||
own_material = ifcopenshell.util.element.get_material(element, should_inherit=False)
|
||||
inherited_material = ifcopenshell.util.element.get_material(element, should_inherit=True)
|
||||
|
||||
ifcopenshell.api.type.unassign_type(file, related_objects=[element])
|
||||
|
||||
if element.Representation:
|
||||
new_active_representation = None
|
||||
active_representation = tool.Geometry.get_active_representation(obj)
|
||||
active_context = active_representation.ContextOfItems
|
||||
representations = []
|
||||
for representation in element.Representation.Representations:
|
||||
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
if representation == resolved_representation:
|
||||
representations.append(representation)
|
||||
else:
|
||||
# We must unmap representations, carrying over their styles.
|
||||
copied_entities: dict[int, ifcopenshell.entity_instance] = {}
|
||||
copied_representation = ifcopenshell.util.element.copy_deep(
|
||||
file,
|
||||
resolved_representation,
|
||||
exclude=["IfcGeometricRepresentationContext"],
|
||||
exclude_callback=exclude_callback,
|
||||
copied_entities=copied_entities,
|
||||
)
|
||||
UnassignType._reattach_styles(file, copied_entities)
|
||||
representations.append(copied_representation)
|
||||
if representation.ContextOfItems == active_context:
|
||||
new_active_representation = copied_representation
|
||||
element.Representation.Representations = representations
|
||||
|
||||
if new_active_representation:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=new_active_representation,
|
||||
)
|
||||
|
||||
# Bake the inherited material down onto the occurrence now that its type
|
||||
# link (and, in the delete-type case, the type itself) is gone. Usages are
|
||||
# occurrence-specific and never inherited, so they need no handling here.
|
||||
if inherited_material is not None and own_material is None:
|
||||
material_type = inherited_material.is_a()
|
||||
if material_type not in ("IfcMaterialLayerSetUsage", "IfcMaterialProfileSetUsage"):
|
||||
ifcopenshell.api.material.assign_material(
|
||||
file, products=[element], type=material_type, material=inherited_material
|
||||
)
|
||||
|
||||
def _execute(self, context):
|
||||
if self.related_object:
|
||||
related_objects = [bpy.data.objects[self.related_object]]
|
||||
else:
|
||||
related_objects = tool.Blender.get_selected_objects()
|
||||
|
||||
for obj in related_objects:
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not element.is_a("IfcObject"):
|
||||
continue
|
||||
ifcopenshell.api.type.unassign_type(self.file, related_objects=[element])
|
||||
|
||||
if element.Representation:
|
||||
new_active_representation = None
|
||||
active_representation = tool.Geometry.get_active_representation(obj)
|
||||
active_context = active_representation.ContextOfItems
|
||||
representations = []
|
||||
for representation in element.Representation.Representations:
|
||||
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
|
||||
if representation == resolved_representation:
|
||||
representations.append(representation)
|
||||
else:
|
||||
# We must unmap representations.
|
||||
copied_representation = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(),
|
||||
resolved_representation,
|
||||
exclude=["IfcGeometricRepresentationContext"],
|
||||
exclude_callback=exclude_callback,
|
||||
)
|
||||
representations.append(copied_representation)
|
||||
if representation.ContextOfItems == active_context:
|
||||
new_active_representation = copied_representation
|
||||
element.Representation.Representations = representations
|
||||
|
||||
if new_active_representation:
|
||||
bonsai.core.geometry.switch_representation(
|
||||
tool.Ifc,
|
||||
tool.Geometry,
|
||||
obj=obj,
|
||||
representation=new_active_representation,
|
||||
)
|
||||
self.unassign_and_unmap(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -305,14 +349,82 @@ class SelectTypeObjects(bpy.types.Operator):
|
||||
|
||||
class RemoveType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.remove_type"
|
||||
bl_label = "Remove Type"
|
||||
bl_label = "Delete Type"
|
||||
bl_description = (
|
||||
"Delete this type. Its occurrences are kept but become untyped.\n\n"
|
||||
"SHIFT+Click to also delete every occurrence of this type in the project"
|
||||
)
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
element: bpy.props.IntProperty()
|
||||
also_delete_instances: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
element: int
|
||||
also_delete_instances: bool
|
||||
|
||||
@staticmethod
|
||||
def _detach_type_material_set(element: ifcopenshell.entity_instance) -> None:
|
||||
"""Cascade-free removal of the type's IfcMaterialLayerSet / IfcMaterialProfileSet
|
||||
association, called just before the type is deleted.
|
||||
|
||||
``remove_product`` would otherwise route the type's material association
|
||||
through ``unassign_material``, which deletes *every* usage of that set
|
||||
across the model (documented behaviour, with an upstream TODO calling it
|
||||
too aggressive) — stripping the material off the very occurrences we are
|
||||
trying to keep. By unhooking the type<->set link by hand here, the type
|
||||
has no material at delete time, so that cascade never fires and the set
|
||||
plus the occurrences' usages survive intact."""
|
||||
file = tool.Ifc.get()
|
||||
material = ifcopenshell.util.element.get_material(element, should_inherit=False)
|
||||
if not material or material.is_a() not in ("IfcMaterialLayerSet", "IfcMaterialProfileSet"):
|
||||
return
|
||||
for rel in list(getattr(element, "HasAssociations", None) or []):
|
||||
if not (rel.is_a("IfcRelAssociatesMaterial") and rel.RelatingMaterial == material):
|
||||
continue
|
||||
remaining = [o for o in rel.RelatedObjects if o != element]
|
||||
if remaining:
|
||||
rel.RelatedObjects = remaining
|
||||
else:
|
||||
history = rel.OwnerHistory
|
||||
file.remove(rel)
|
||||
if history:
|
||||
ifcopenshell.util.element.remove_deep2(file, history)
|
||||
|
||||
def invoke(self, context, event):
|
||||
self.also_delete_instances = event.shift
|
||||
if self.also_delete_instances:
|
||||
element = tool.Ifc.get().by_id(self.element)
|
||||
count = len(ifcopenshell.util.element.get_types(element))
|
||||
return context.window_manager.invoke_confirm(
|
||||
self,
|
||||
event,
|
||||
title="Delete Type and Occurrences",
|
||||
message=f"This will delete the type and all {count} of its occurrences.",
|
||||
confirm_text="Delete",
|
||||
)
|
||||
return self.execute(context)
|
||||
|
||||
def _execute(self, context):
|
||||
element = tool.Ifc.get().by_id(self.element)
|
||||
occurrences = ifcopenshell.util.element.get_types(element)
|
||||
if self.also_delete_instances:
|
||||
for occurrence in occurrences:
|
||||
occurrence_obj = tool.Ifc.get_object(occurrence)
|
||||
if occurrence_obj:
|
||||
tool.Geometry.delete_ifc_object(occurrence_obj)
|
||||
else:
|
||||
# Keep the occurrences: bake their (previously type-mapped) geometry,
|
||||
# styles, and inherited material onto each one so nothing is lost when
|
||||
# the type is deleted...
|
||||
for occurrence in occurrences:
|
||||
occ_obj = tool.Ifc.get_object(occurrence)
|
||||
if occ_obj:
|
||||
UnassignType.unassign_and_unmap(occ_obj)
|
||||
# ...and keep any layer/profile-set material usages alive across the deletion.
|
||||
self._detach_type_material_set(element)
|
||||
obj = tool.Ifc.get_object(element)
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
if obj:
|
||||
tool.Geometry.delete_ifc_object(obj)
|
||||
|
||||
|
||||
class RenameType(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
@@ -144,8 +144,10 @@ class BIM_PT_type_attributes(Panel):
|
||||
|
||||
bonsai.bim.helper.draw_attributes(props.type_attributes, layout)
|
||||
else:
|
||||
row = layout.row()
|
||||
row = layout.row(align=True)
|
||||
row.operator("bim.enable_editing_type_attributes", icon="GREASEPENCIL", text="Edit")
|
||||
op = row.operator("bim.remove_type", icon="TRASH", text="")
|
||||
op.element = TypeData.data["relating_type"]["id"]
|
||||
|
||||
for attribute in TypeData.data["relating_type_attributes"]:
|
||||
row = layout.row(align=True)
|
||||
|
||||
@@ -72,6 +72,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
|
||||
opening_objects = [obj for obj in selected_objects if obj != target_object]
|
||||
|
||||
obj1 = ...
|
||||
for opening_obj in opening_objects:
|
||||
element1 = tool.Ifc.get_entity(target_object)
|
||||
obj1 = target_object
|
||||
@@ -196,6 +197,7 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bpy.data.objects.remove(obj2)
|
||||
|
||||
tool.Model.purge_scene_openings()
|
||||
assert obj1 is not ...
|
||||
context.view_layer.objects.active = obj1
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import bonsai.bim.helper
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.ifc import is_cache_locked_by_other_process
|
||||
from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDProperty
|
||||
from bonsai.bim.module.material.operator import SelectByMaterial
|
||||
from bonsai.bim.module.model import prop as _model_prop
|
||||
from bonsai.bim.module.model import ui as _model_ui
|
||||
from bonsai.bim.module.pset.prop import IfcProperty
|
||||
@@ -284,11 +285,13 @@ class GizmoPreferences(bpy.types.PropertyGroup):
|
||||
draw_gizmos_in_3d_viewport: bool
|
||||
|
||||
|
||||
_gizmo_pref_entry = None
|
||||
for _gizmo_pref_entry in tool.Parametric.EDIT_TYPES:
|
||||
GizmoPreferences.__annotations__[_gizmo_pref_entry.name] = BoolProperty(
|
||||
name=_gizmo_pref_entry.name.replace("_", " ").title(),
|
||||
default=True,
|
||||
)
|
||||
assert _gizmo_pref_entry is not None
|
||||
del _gizmo_pref_entry
|
||||
|
||||
|
||||
@@ -394,12 +397,14 @@ class DefaultParameters(bpy.types.PropertyGroup):
|
||||
and gives the create operator a preset to copy from."""
|
||||
|
||||
|
||||
_default_params_entry = None
|
||||
for _default_params_entry in tool.Parametric.EDIT_TYPES:
|
||||
if not _default_params_entry.has_default_parameters:
|
||||
continue
|
||||
DefaultParameters.__annotations__[_default_params_entry.name] = bpy.props.PointerProperty(
|
||||
type=getattr(_model_prop, _default_params_entry.props_attr),
|
||||
)
|
||||
assert _default_params_entry is not None
|
||||
del _default_params_entry
|
||||
|
||||
|
||||
@@ -1850,6 +1855,21 @@ def draw_statusbar(self, context):
|
||||
|
||||
def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) -> None:
|
||||
# https://blender.stackexchange.com/a/275555/86891
|
||||
|
||||
# Context menu for material name buttons (e.g. `bim.select_by_material`),
|
||||
# offering a quick "Rename Material" entry instead of having to look up
|
||||
# the material in the scene Materials panel to rename it.
|
||||
button_operator = getattr(context, "button_operator", None)
|
||||
if button_operator is not None and button_operator.bl_rna.identifier == SelectByMaterial.bl_rna.identifier:
|
||||
ifc_file = tool.Ifc.get()
|
||||
material = ifc_file.by_id(button_operator.material) if ifc_file else None
|
||||
if material is not None and material.is_a("IfcMaterial"):
|
||||
assert self.layout
|
||||
self.layout.separator()
|
||||
op = self.layout.operator("bim.rename_material", text="Rename Material", icon="GREASEPENCIL")
|
||||
op.material = material.id()
|
||||
return
|
||||
|
||||
if (
|
||||
not hasattr(context, "button_pointer")
|
||||
or not hasattr(context, "button_prop")
|
||||
|
||||
@@ -74,10 +74,11 @@ def add_instance_ceiling_covering_from_cursor(
|
||||
if not relating_type.is_a("IfcCoveringType"):
|
||||
relating_type = None
|
||||
|
||||
ceiling_height = None
|
||||
if selected_objects and active_obj:
|
||||
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj)
|
||||
x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj)
|
||||
else:
|
||||
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
|
||||
x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_cursor()
|
||||
ceiling_height = covering.get_z_from_ceiling_height()
|
||||
|
||||
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
@@ -87,6 +88,7 @@ def add_instance_ceiling_covering_from_cursor(
|
||||
|
||||
obj = spatial.create_object("Covering")
|
||||
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
|
||||
assert ceiling_height is not None
|
||||
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
|
||||
spatial.assign_type_to_obj(obj)
|
||||
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
|
||||
@@ -100,7 +102,9 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
|
||||
selected_objects = spatial.get_selected_objects()
|
||||
|
||||
if selected_objects and active_obj:
|
||||
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj)
|
||||
x, y, _, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj)
|
||||
else:
|
||||
assert False, "Object has to be active and selected."
|
||||
|
||||
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
|
||||
|
||||
|
||||
@@ -411,6 +411,37 @@ def duplicate_drawing(
|
||||
return new_drawing
|
||||
|
||||
|
||||
def copy_annotations_to_drawing(
|
||||
ifc: type[tool.Ifc],
|
||||
collector: type[tool.Collector],
|
||||
drawing_tool: type[tool.Drawing],
|
||||
geometry: type[tool.Geometry],
|
||||
annotations: list[ifcopenshell.entity_instance],
|
||||
target_drawing: ifcopenshell.entity_instance,
|
||||
) -> list[ifcopenshell.entity_instance]:
|
||||
"""Duplicate annotations into another drawing, leaving the originals untouched."""
|
||||
target_group = drawing_tool.get_drawing_group(target_drawing)
|
||||
if not target_group:
|
||||
return []
|
||||
annotations = [a for a in annotations if drawing_tool.get_annotation_drawing(a) != target_drawing]
|
||||
annotation_objs = [obj for a in annotations if (obj := ifc.get_object(a))]
|
||||
if not annotation_objs:
|
||||
return []
|
||||
camera = ifc.get_object(target_drawing) or drawing_tool.import_drawing(target_drawing)
|
||||
old_to_new, _ = geometry.duplicate_ifc_objects(annotation_objs)
|
||||
copied: list[ifcopenshell.entity_instance] = []
|
||||
for new_elements in old_to_new.values():
|
||||
for new_element in new_elements:
|
||||
if old_group := drawing_tool.get_drawing_group(new_element):
|
||||
ifc.run("group.unassign_group", group=old_group, products=[new_element])
|
||||
ifc.run("group.assign_group", group=target_group, products=[new_element])
|
||||
new_obj = ifc.get_object(new_element)
|
||||
drawing_tool.ensure_annotation_in_drawing_plane(new_obj, camera)
|
||||
collector.assign(new_obj, should_clean_users_collection=True)
|
||||
copied.append(new_element)
|
||||
return copied
|
||||
|
||||
|
||||
def remove_drawing(
|
||||
ifc: type[tool.Ifc], drawing_tool: type[tool.Drawing], drawing: ifcopenshell.entity_instance
|
||||
) -> None:
|
||||
@@ -497,6 +528,7 @@ def add_annotation(
|
||||
drawing_tool.show_decorations()
|
||||
obj = drawing_tool.create_annotation_object(drawing, object_type)
|
||||
element = ifc.get_entity(obj)
|
||||
relating_type_rep = None
|
||||
if not element: # Brand new annotation
|
||||
relating_type_rep = drawing_tool.get_annotation_representation(relating_type) if relating_type else None
|
||||
element = drawing_tool.run_root_assign_class(
|
||||
|
||||
@@ -107,6 +107,15 @@ def disable_editing_material(material_tool: type[tool.Material]) -> None:
|
||||
material_tool.disable_editing_material()
|
||||
|
||||
|
||||
def rename_material(
|
||||
ifc: type[tool.Ifc], material_tool: type[tool.Material], material: ifcopenshell.entity_instance, name: str
|
||||
) -> None:
|
||||
ifc.run("material.edit_material", material=material, attributes={"Name": name})
|
||||
if material_tool.is_editing_materials():
|
||||
material_tool.import_material_definitions(material_tool.get_active_material_type())
|
||||
material_tool.refresh()
|
||||
|
||||
|
||||
def assign_material(
|
||||
ifc: type[tool.Ifc],
|
||||
material_tool: type[tool.Material],
|
||||
|
||||
@@ -354,6 +354,7 @@ class Drawing:
|
||||
def enable_editing_schedules(cls): pass
|
||||
def enable_editing_sheets(cls): pass
|
||||
def enable_editing_text(cls, obj): pass
|
||||
def ensure_annotation_in_drawing_plane(cls, obj, camera=None): pass
|
||||
def ensure_drawings_parent_document(cls): pass
|
||||
def ensure_drawings_parent_group(cls): pass
|
||||
def ensure_unique_drawing_name(cls, name): pass
|
||||
@@ -367,6 +368,7 @@ class Drawing:
|
||||
def generate_reference_attributes(cls, reference, **attributes): pass
|
||||
def generate_sheet_identification(cls): pass
|
||||
def get_annotation_context(cls, target_view, object_type=None): pass
|
||||
def get_annotation_drawing(cls, element): pass
|
||||
def get_annotation_representation(cls, element_type): pass
|
||||
def get_assigned_product(cls, element): pass
|
||||
def get_assigned_product_workaround(cls, element): pass
|
||||
@@ -384,6 +386,7 @@ class Drawing:
|
||||
def get_drawing_group(cls, drawing): pass
|
||||
def get_drawing_references(cls, drawing): pass
|
||||
def get_drawing_target_view(cls, drawing): pass
|
||||
def get_group_drawing(cls, group): pass
|
||||
def get_group_elements(cls, group): pass
|
||||
def get_ifc_representation_class(cls, object_type): pass
|
||||
def get_name(cls, element): pass
|
||||
@@ -397,6 +400,7 @@ class Drawing:
|
||||
def get_unit_system(cls): pass
|
||||
def import_assigned_product(cls, obj): pass
|
||||
def import_documents(cls, document_type): pass
|
||||
def import_drawing(cls, drawing): pass
|
||||
def import_drawings(cls): pass
|
||||
def import_sheets(cls): pass
|
||||
def import_text_attributes(cls, obj): pass
|
||||
@@ -663,6 +667,7 @@ class Material:
|
||||
def is_editing_materials(cls): pass
|
||||
def is_material_used_in_sets(cls, material): pass
|
||||
def load_material_attributes(cls, material): pass
|
||||
def refresh(cls): pass
|
||||
def replace_material_with_material_profile(cls, element): pass
|
||||
def update_elements_using_material(cls, material): pass
|
||||
|
||||
|
||||
@@ -803,6 +803,40 @@ class Blender(bonsai.core.tool.Blender):
|
||||
# restore shader editor settings
|
||||
shader_editor.pin = previous_pin_setting
|
||||
|
||||
@classmethod
|
||||
def copy_node_graph_additive(
|
||||
cls, material_to: bpy.types.Material, material_from: bpy.types.Material
|
||||
) -> bpy.types.ShaderNodeOutputMaterial | None:
|
||||
"""Paste nodes from material_from alongside the existing nodes in material_to.
|
||||
|
||||
Unlike copy_node_graph this does NOT clear the existing node tree first.
|
||||
Returns the OUTPUT_MATERIAL node that was added from material_from, or None.
|
||||
"""
|
||||
temp_override = cls.get_shader_editor_context()
|
||||
shader_editor = temp_override["space"]
|
||||
|
||||
before_names = {n.name for n in material_to.node_tree.nodes}
|
||||
|
||||
previous_pin_setting = shader_editor.pin
|
||||
shader_editor.pin = True
|
||||
shader_editor.node_tree = material_from.node_tree
|
||||
|
||||
for node in material_from.node_tree.nodes:
|
||||
node.select = True
|
||||
with bpy.context.temp_override(**temp_override):
|
||||
bpy.ops.node.clipboard_copy()
|
||||
|
||||
shader_editor.node_tree = material_to.node_tree
|
||||
with bpy.context.temp_override(**temp_override):
|
||||
bpy.ops.node.clipboard_paste(offset=(0, 0))
|
||||
|
||||
shader_editor.pin = previous_pin_setting
|
||||
|
||||
for node in material_to.node_tree.nodes:
|
||||
if node.name not in before_names and node.type == "OUTPUT_MATERIAL":
|
||||
return node
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_material_node(
|
||||
cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {}
|
||||
|
||||
@@ -138,7 +138,6 @@ class Bsdd(bonsai.core.tool.Bsdd):
|
||||
def get_dictionaries(cls) -> list[bsdd.DictionaryContractV1]:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
baseurl = getattr(prefs, "bsdd_baseurl", "https://api.bsdd.buildingsmart.org/api/")
|
||||
cls.client = bsdd.Client()
|
||||
if hasattr(cls.client, "baseurl"):
|
||||
cls.client.baseurl = baseurl
|
||||
response = cls.client.get_dictionary(include_test_dictionaries=prefs.bsdd_load_test_dictionaries)
|
||||
|
||||
@@ -981,6 +981,7 @@ class Cad:
|
||||
has_found_connected_edge = True
|
||||
loops.append(loop)
|
||||
|
||||
new_verts = None
|
||||
for loop in loops:
|
||||
all_verts = {v.index for e in loop for v in e.verts}
|
||||
possible_v1s = []
|
||||
@@ -1084,6 +1085,7 @@ class Cad:
|
||||
break
|
||||
|
||||
v1 = v2
|
||||
assert new_verts is not None
|
||||
|
||||
return new_verts
|
||||
|
||||
|
||||
@@ -1270,6 +1270,17 @@ class ClipBox:
|
||||
def on_depsgraph_update_caps(cls, scene, depsgraph) -> None:
|
||||
"""Depsgraph entry-point — guard, then delegate to the
|
||||
modal-aware debounce in :meth:`_handle_cap_tick`."""
|
||||
# Same file-load danger window as on_depsgraph_update: a real
|
||||
# depsgraph tick during load (between load_pre and the new file's
|
||||
# first paint) must not re-arm a cap-rebuild timer. _on_load_pre
|
||||
# already cancels any in-flight timer via _cancel_pending_cap_rebuild;
|
||||
# without this gate, a depsgraph_update_post event firing later in
|
||||
# the same load (Blender fires these while building the new file's
|
||||
# scene) would immediately reschedule one via _handle_cap_tick,
|
||||
# undoing that cancellation and re-arming against regions whose GPU
|
||||
# state is not yet wired.
|
||||
if cls._file_loading:
|
||||
return
|
||||
if getattr(bpy.context, "screen", None) is None:
|
||||
return
|
||||
if cls._active_scene_props(scene) is None:
|
||||
|
||||
@@ -280,6 +280,8 @@ class Cost(bonsai.core.tool.Cost):
|
||||
new = props.cost_item_processes.add()
|
||||
elif related_object.is_a("IfcResource"):
|
||||
new = props.cost_item_resources.add()
|
||||
else:
|
||||
assert False, related_object
|
||||
new.ifc_definition_id = related_object.id()
|
||||
new.name = related_object.Name or "Unnamed"
|
||||
|
||||
|
||||
@@ -756,6 +756,17 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING":
|
||||
return rel.RelatingGroup
|
||||
|
||||
@classmethod
|
||||
def get_group_drawing(cls, group: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Get the drawing that owns this group, if the group represents a drawing."""
|
||||
if group.ObjectType != "DRAWING":
|
||||
return None
|
||||
for rel in group.IsGroupedBy or []:
|
||||
for related_object in rel.RelatedObjects:
|
||||
if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING":
|
||||
return related_object
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_drawing_document(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
|
||||
for rel in drawing.HasAssociations:
|
||||
@@ -1072,6 +1083,12 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.has_annotation = True
|
||||
camera_props.target_view = "PLAN_VIEW"
|
||||
camera_props.is_nts = False
|
||||
camera_props.use_edge_classification = False
|
||||
camera_props.render_creases = True
|
||||
camera_props.valley_angle_min_degrees = 12.0
|
||||
camera_props.render_sharp = True
|
||||
camera_props.ridge_angle_min_degrees = 45.0
|
||||
camera_props.render_flush = False
|
||||
camera.shift_x = 0.0
|
||||
camera.shift_y = 0.0
|
||||
|
||||
@@ -1101,6 +1118,18 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
camera_props.has_annotation = bool(pset["HasAnnotation"])
|
||||
if "IsNTS" in pset:
|
||||
camera_props.is_nts = bool(pset["IsNTS"])
|
||||
if "UseEdgeClassification" in pset:
|
||||
camera_props.use_edge_classification = bool(pset["UseEdgeClassification"])
|
||||
if "RenderCreases" in pset:
|
||||
camera_props.render_creases = bool(pset["RenderCreases"])
|
||||
if "ValleyAngleMinDegrees" in pset:
|
||||
camera_props.valley_angle_min_degrees = float(pset["ValleyAngleMinDegrees"])
|
||||
if "RenderSharp" in pset:
|
||||
camera_props.render_sharp = bool(pset["RenderSharp"])
|
||||
if "RidgeAngleMinDegrees" in pset:
|
||||
camera_props.ridge_angle_min_degrees = float(pset["RidgeAngleMinDegrees"])
|
||||
if "RenderFlush" in pset:
|
||||
camera_props.render_flush = bool(pset["RenderFlush"])
|
||||
if "DPI" in pset:
|
||||
camera_props.dpi = int(pset["DPI"])
|
||||
if "LineworkMode" in pset:
|
||||
@@ -2575,16 +2604,15 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
if not obj:
|
||||
continue
|
||||
current_representation = tool.Geometry.get_active_representation(obj)
|
||||
current_representation_subcontext = None
|
||||
if current_representation:
|
||||
subcontext = current_representation.ContextOfItems
|
||||
current_representation_subcontext = tool.Geometry.get_subcontext_parameters(subcontext)
|
||||
|
||||
has_context = False
|
||||
for subcontext in subcontexts:
|
||||
# prioritize already active representation if it matches the subcontext
|
||||
# (element could have multiple representations in the same subcontext)
|
||||
if current_representation and subcontext == current_representation_subcontext:
|
||||
has_context = True
|
||||
if current_representation_subcontext and subcontext == current_representation_subcontext:
|
||||
break
|
||||
priority_representation = ifcopenshell.util.representation.get_representation(element, *subcontext)
|
||||
if priority_representation:
|
||||
@@ -2594,7 +2622,6 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
obj=obj,
|
||||
representation=priority_representation,
|
||||
)
|
||||
has_context = True
|
||||
break
|
||||
|
||||
linked_handles: set[bpy.types.Object] = set()
|
||||
@@ -2860,6 +2887,50 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
break
|
||||
return sheet_references
|
||||
|
||||
@classmethod
|
||||
def get_sheeted_drawing_ids(cls) -> set[int]:
|
||||
"""Get the IFC ids of all drawings that are placed on at least one sheet."""
|
||||
ifc_file = tool.Ifc.get()
|
||||
sheet_locations: set[Union[str, None]] = set()
|
||||
for sheet in ifc_file.by_type("IfcDocumentInformation"):
|
||||
if sheet.Scope != "SHEET":
|
||||
continue
|
||||
for reference in cls.get_document_references(sheet):
|
||||
sheet_locations.add(reference.Location)
|
||||
if not sheet_locations:
|
||||
return set()
|
||||
result: set[int] = set()
|
||||
for drawing in ifc_file.by_type("IfcAnnotation"):
|
||||
if drawing.ObjectType != "DRAWING":
|
||||
continue
|
||||
drawing_document = cls.get_drawing_document(drawing)
|
||||
if drawing_document and drawing_document.Location in sheet_locations:
|
||||
result.add(drawing.id())
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_visible_drawings_in_category(cls, target_view: str) -> list[DrawingProperties]:
|
||||
"""Get the drawing items in a target view category that are currently visible in the drawing list.
|
||||
|
||||
Grouping is positional: individual drawing items don't carry their own ``target_view``, they belong to
|
||||
the most recent header item above them. Only expanded categories contribute drawing items to the
|
||||
collection, so a collapsed category yields an empty list. Respects the ``show_drawings_on_sheets_only``
|
||||
filter so that select-all only affects visible drawings.
|
||||
"""
|
||||
props = cls.get_document_props()
|
||||
drawings: list[DrawingProperties] = []
|
||||
in_category = False
|
||||
for item in props.drawings:
|
||||
if not item.is_drawing:
|
||||
# Header row: we're inside the requested category until the next header.
|
||||
in_category = item.target_view == target_view
|
||||
elif in_category:
|
||||
drawings.append(item)
|
||||
if props.show_drawings_on_sheets_only:
|
||||
sheeted_ids = cls.get_sheeted_drawing_ids()
|
||||
drawings = [d for d in drawings if d.ifc_definition_id in sheeted_ids]
|
||||
return drawings
|
||||
|
||||
@classmethod
|
||||
def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix:
|
||||
matrix_world = camera.matrix_world.copy().normalized()
|
||||
|
||||
@@ -23,7 +23,6 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import bpy
|
||||
import ifcopenshell.api.feature
|
||||
import ifcopenshell.util.representation
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.tool
|
||||
@@ -50,6 +49,7 @@ class Feature(bonsai.core.tool.Feature):
|
||||
has_visible_openings = True
|
||||
break
|
||||
|
||||
element_had_openings = None
|
||||
for feature_obj in feature_objs:
|
||||
feature_element = tool.Ifc.get_entity(feature_obj)
|
||||
|
||||
@@ -58,7 +58,6 @@ class Feature(bonsai.core.tool.Feature):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=featured_obj)
|
||||
|
||||
element_had_openings = tool.Geometry.has_openings(featured_element)
|
||||
body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body")
|
||||
ifcopenshell.api.feature.add_feature(tool.Ifc.get(), feature=feature_element, element=featured_element)
|
||||
|
||||
if tool.Ifc.is_moved(feature_obj):
|
||||
@@ -73,6 +72,7 @@ class Feature(bonsai.core.tool.Feature):
|
||||
if voided_obj.data:
|
||||
if tool.Ifc.is_edited(voided_obj):
|
||||
voided_element_ = tool.Ifc.get_entity(voided_obj)
|
||||
assert element_had_openings is not None
|
||||
if element_had_openings or (voided_element_ != featured_element and voided_element_.HasOpenings):
|
||||
voided_obj.scale = (1.0, 1.0, 1.0)
|
||||
tool.Ifc.finish_edit(voided_obj)
|
||||
|
||||
@@ -757,6 +757,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
# its centroid not obscured (tested via raycasting) by any other
|
||||
# face.
|
||||
distance = max(obj.dimensions.xyz)
|
||||
min_y, max_z = None, None
|
||||
if axis == "+Z":
|
||||
max_z = max([co[2] for co in obj.bound_box]) + 0.002
|
||||
direction = Vector((0, 0, -1))
|
||||
@@ -771,8 +772,10 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
if direction.dot(face.normal) > 0:
|
||||
continue
|
||||
if axis == "+Z":
|
||||
assert max_z is not None
|
||||
face_centroid_at_max = Vector((*face.calc_center_median().xy, max_z))
|
||||
elif axis == "-Y":
|
||||
assert min_y is not None
|
||||
centroid = face.calc_center_median()
|
||||
face_centroid_at_max = Vector((centroid.x, min_y, centroid.z))
|
||||
face_centroid_at_max = obj.matrix_world @ face_centroid_at_max
|
||||
@@ -1148,6 +1151,9 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
settings.set("layerset-first", True)
|
||||
settings.set("keep-bounding-boxes", True)
|
||||
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
settings.set("mesher-linear-deflection", ifc_import_settings.deflection_tolerance)
|
||||
settings.set("mesher-angular-deflection", ifc_import_settings.angular_tolerance)
|
||||
geometry_library = ifc_import_settings.geometry_library
|
||||
|
||||
ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings)
|
||||
ifc_importer.file = tool.Ifc.get()
|
||||
@@ -1159,7 +1165,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
shape = None
|
||||
if elements:
|
||||
iterator = ifcopenshell.geom.iterator(
|
||||
settings, tool.Ifc.get(), multiprocessing.cpu_count(), include=elements
|
||||
settings,
|
||||
tool.Ifc.get(),
|
||||
multiprocessing.cpu_count(),
|
||||
include=elements,
|
||||
geometry_library=geometry_library,
|
||||
)
|
||||
else:
|
||||
iterator = None # For example, when switching representation of a type with no occurrences
|
||||
@@ -1214,7 +1224,9 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
for element in element_types:
|
||||
if obj := tool.Ifc.get_object(element):
|
||||
if representation := ifcopenshell.util.representation.get_representation(element, context):
|
||||
geometry = ifcopenshell.geom.create_shape(settings, representation)
|
||||
geometry = ifcopenshell.geom.create_shape(
|
||||
settings, representation, geometry_library=geometry_library
|
||||
)
|
||||
mesh_name = tool.Loader.get_mesh_name_from_shape(geometry)
|
||||
mesh = meshes.get(mesh_name)
|
||||
if mesh is None:
|
||||
@@ -1885,6 +1897,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
"""NOTE: we assume that all items belonged to the same representation and to the same shape aspect"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
previous_shape_aspect = None
|
||||
base_representation = None
|
||||
for inverse in ifc_file.get_inverse(representation_items[0]):
|
||||
if inverse.is_a("IfcShapeRepresentation"):
|
||||
if inverse.OfShapeAspect:
|
||||
@@ -1894,6 +1907,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
previous_shape_aspect = inverse.OfShapeAspect[0]
|
||||
else:
|
||||
base_representation = inverse
|
||||
assert base_representation
|
||||
|
||||
# remove item from previous shape aspect
|
||||
if previous_shape_aspect:
|
||||
@@ -2211,6 +2225,7 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
assert item
|
||||
obj.data.clear_geometry()
|
||||
|
||||
cartesian_point_offset = None
|
||||
if item.is_a("IfcHalfSpaceSolid"):
|
||||
bm = bmesh.new()
|
||||
bmesh.ops.create_grid(bm, size=0.5)
|
||||
|
||||
@@ -1087,18 +1087,21 @@ class Loader(bonsai.core.tool.Loader):
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
prev_co = None
|
||||
if usage.LayerSetDirection == "AXIS2":
|
||||
layer_set_direction = usage.LayerSetDirection
|
||||
if layer_set_direction == "AXIS2":
|
||||
co = Vector((0.0, offset, 0.0))
|
||||
no = cls.get_extrusion_vector(element).normalized()
|
||||
no = no.cross(Vector([1.0, 0.0, 0.0]))
|
||||
elif usage.LayerSetDirection == "AXIS3":
|
||||
elif layer_set_direction == "AXIS3":
|
||||
co = Vector((0.0, 0.0, offset))
|
||||
no = cls.get_extrusion_vector(element).normalized()
|
||||
no = Vector([0.0, 0.0, 1.0])
|
||||
elif usage.LayerSetDirection == "AXIS1":
|
||||
elif layer_set_direction == "AXIS1":
|
||||
co = Vector((0.0, 0.0, offset))
|
||||
no = cls.get_extrusion_vector(element).normalized()
|
||||
no = Vector([1.0, 0.0, 0.0])
|
||||
else:
|
||||
assert False, layer_set_direction
|
||||
no *= sense_factor
|
||||
# Cache this
|
||||
body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
|
||||
@@ -1108,6 +1111,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
if style := tool.Ifc.get_entity(material):
|
||||
styles[style] = i
|
||||
last_i = len(layer_set.MaterialLayers) - 1
|
||||
bisect_geom = None
|
||||
for i, layer in enumerate(layer_set.MaterialLayers):
|
||||
if i != last_i:
|
||||
prev_co = co.copy()
|
||||
@@ -1121,6 +1125,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
if (material_index := styles.get(style, None)) is None:
|
||||
material_index = len(mesh.materials)
|
||||
mesh.materials.append(tool.Ifc.get_object(style))
|
||||
assert bisect_geom is not None
|
||||
if i == last_i:
|
||||
for face in bisect_geom["geom"]:
|
||||
if isinstance(face, bmesh.types.BMFace):
|
||||
@@ -1286,6 +1291,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
polyline.material_index = material_index
|
||||
return polyline
|
||||
|
||||
item = None
|
||||
for item_data, item_style in zip(rep_items, item_styles):
|
||||
item = item_data["item"]
|
||||
|
||||
@@ -1313,6 +1319,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
polyline.points.add(1)
|
||||
polyline.points[-1].co = native_data["matrix"] @ Vector(v2)
|
||||
|
||||
assert item is not None
|
||||
curve.bevel_depth = unit_scale * item.Radius
|
||||
thickness = None
|
||||
if (inner_radius := item.InnerRadius) and (thickness := max(item.Radius - inner_radius, 0)):
|
||||
|
||||
@@ -146,6 +146,12 @@ class Material(bonsai.core.tool.Material):
|
||||
|
||||
MaterialsData.data["material_styles_data"] = MaterialsData.material_styles_data()
|
||||
|
||||
@classmethod
|
||||
def refresh(cls) -> None:
|
||||
from bonsai.bim.module.material.data import refresh as refresh_material_data
|
||||
|
||||
refresh_material_data()
|
||||
|
||||
@classmethod
|
||||
def is_editing_materials(cls) -> bool:
|
||||
props = tool.Material.get_material_props()
|
||||
|
||||
@@ -220,10 +220,12 @@ class Misc(bonsai.core.tool.Misc):
|
||||
related_objects.append((element, ifcopenshell.util.placement.get_storey_elevation(element)))
|
||||
related_objects = sorted(related_objects, key=lambda e: e[1])
|
||||
storey_elevation = None
|
||||
i = None
|
||||
for i, related_object in enumerate(related_objects):
|
||||
if related_object[0] == storey:
|
||||
storey_elevation = related_object[1]
|
||||
break
|
||||
assert i is not None
|
||||
if i + total_storeys < len(related_objects):
|
||||
next_storey_elevation = related_objects[i + total_storeys][1]
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
|
||||
@@ -2292,32 +2292,18 @@ class Model(bonsai.core.tool.Model):
|
||||
deform_layer = bm.verts.layers.deform.active
|
||||
|
||||
# Sanity check
|
||||
group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
|
||||
if deform_layer:
|
||||
for vert in bm.verts:
|
||||
vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer)
|
||||
is_circle = False
|
||||
for group_index in vert_group_indices:
|
||||
group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE"
|
||||
group_verts[group_type].setdefault(group_index, 0)
|
||||
group_verts[group_type][group_index] += 1
|
||||
if group_type == "IFCCIRCLE":
|
||||
is_circle = True
|
||||
is_circle = any(gi in groups["IFCCIRCLE"] for gi in vert_group_indices)
|
||||
is_arc = any(gi in groups["IFCARCINDEX"] for gi in vert_group_indices)
|
||||
if (is_circle or is_arc) and not vert.link_edges:
|
||||
return (False, "CIRCLE" if is_circle else "3POINT_ARC")
|
||||
if is_circle:
|
||||
pass # Circles are allowed to be unclosed
|
||||
elif len(vert.link_edges) != 2: # Unclosed loop or forked loop
|
||||
return (False, "UNCLOSED_LOOP")
|
||||
|
||||
for group_type, group_counts in group_verts.items():
|
||||
if group_type == "IFCARCINDEX":
|
||||
for group_count in group_counts.values():
|
||||
if group_count != 3: # Each arc needs 3 verts
|
||||
return (False, "3POINT_ARC")
|
||||
elif group_type == "IFCCIRCLE":
|
||||
for group_count in group_counts.values():
|
||||
if group_count != 2: # Each circle needs 2 verts
|
||||
return (False, "CIRCLE")
|
||||
|
||||
loop_edges = list(bm.edges)
|
||||
|
||||
# Create loops from edges
|
||||
@@ -2340,6 +2326,28 @@ class Model(bonsai.core.tool.Model):
|
||||
has_found_connected_edge = True
|
||||
loops.append(loop)
|
||||
|
||||
# Sanity check, per loop rather than across the whole mesh
|
||||
if deform_layer:
|
||||
for loop in loops:
|
||||
loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
|
||||
loop_verts = {v for edge in loop for v in edge.verts}
|
||||
for vert in loop_verts:
|
||||
for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer):
|
||||
if group_index in groups["IFCARCINDEX"]:
|
||||
group_type = "IFCARCINDEX"
|
||||
elif group_index in groups["IFCCIRCLE"]:
|
||||
group_type = "IFCCIRCLE"
|
||||
else:
|
||||
continue
|
||||
loop_group_counts[group_type].setdefault(group_index, 0)
|
||||
loop_group_counts[group_type][group_index] += 1
|
||||
for group_count in loop_group_counts["IFCARCINDEX"].values():
|
||||
if group_count != 3: # Each arc needs 3 verts
|
||||
return (False, "3POINT_ARC")
|
||||
for group_count in loop_group_counts["IFCCIRCLE"].values():
|
||||
if group_count != 2: # Each circle needs 2 verts
|
||||
return (False, "CIRCLE")
|
||||
|
||||
tmp = ifcopenshell.file(schema=tool.Ifc.get().schema)
|
||||
|
||||
def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool:
|
||||
@@ -2520,27 +2528,11 @@ class Model(bonsai.core.tool.Model):
|
||||
deform_layer = bm.verts.layers.deform.active
|
||||
|
||||
# Sanity check
|
||||
group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
|
||||
if deform_layer:
|
||||
for vert in bm.verts:
|
||||
vert_group_indices = tool.Blender.bmesh_get_vertex_groups(vert, deform_layer)
|
||||
for group_index in vert_group_indices:
|
||||
group_type = "IFCARCINDEX" if group_index in groups["IFCARCINDEX"] else "IFCCIRCLE"
|
||||
group_verts[group_type].setdefault(group_index, 0)
|
||||
group_verts[group_type][group_index] += 1
|
||||
if len(vert.link_edges) > 2: # Forked loop
|
||||
return (False, "FORKED_LOOP")
|
||||
|
||||
for group_type, group_counts in group_verts.items():
|
||||
if group_type == "IFCARCINDEX":
|
||||
for group_count in group_counts.values():
|
||||
if group_count != 3: # Each arc needs 3 verts
|
||||
return (False, "3POINT_ARC")
|
||||
elif group_type == "IFCCIRCLE":
|
||||
for group_count in group_counts.values():
|
||||
if group_count != 2: # Each circle needs 2 verts
|
||||
return (False, "CIRCLE")
|
||||
|
||||
loop_edges = list(bm.edges)
|
||||
|
||||
# Create loops from edges
|
||||
@@ -2563,6 +2555,28 @@ class Model(bonsai.core.tool.Model):
|
||||
has_found_connected_edge = True
|
||||
loops.append(loop)
|
||||
|
||||
# Sanity check, per loop rather than across the whole mesh
|
||||
if deform_layer:
|
||||
for loop in loops:
|
||||
loop_group_counts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}}
|
||||
loop_verts = {v for edge in loop for v in edge.verts}
|
||||
for vert in loop_verts:
|
||||
for group_index in tool.Blender.bmesh_get_vertex_groups(vert, deform_layer):
|
||||
if group_index in groups["IFCARCINDEX"]:
|
||||
group_type = "IFCARCINDEX"
|
||||
elif group_index in groups["IFCCIRCLE"]:
|
||||
group_type = "IFCCIRCLE"
|
||||
else:
|
||||
continue
|
||||
loop_group_counts[group_type].setdefault(group_index, 0)
|
||||
loop_group_counts[group_type][group_index] += 1
|
||||
for group_count in loop_group_counts["IFCARCINDEX"].values():
|
||||
if group_count != 3: # Each arc needs 3 verts
|
||||
return (False, "3POINT_ARC")
|
||||
for group_count in loop_group_counts["IFCCIRCLE"].values():
|
||||
if group_count != 2: # Each circle needs 2 verts
|
||||
return (False, "CIRCLE")
|
||||
|
||||
tmp = ifcopenshell.file(schema=tool.Ifc.get().schema)
|
||||
|
||||
def is_in_group(v: bmesh.types.BMVert, group_name: str) -> bool:
|
||||
|
||||
@@ -47,7 +47,15 @@ class Nest(bonsai.core.tool.Nest):
|
||||
return False
|
||||
if relating_object == related_object:
|
||||
return False
|
||||
is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")
|
||||
# IfcRelNests.RelatingObject/RelatedObjects are typed as the general
|
||||
# IfcObjectDefinition, so nesting is schema-legal both between element
|
||||
# occurrences (the common case, e.g. a faucet nested into a sink) and
|
||||
# between element types (e.g. an assembly type nesting its component
|
||||
# types). Mixing an occurrence with a type isn't a real modeling
|
||||
# pattern, so only allow same-kind pairs. See #2283.
|
||||
is_compatible_class = (relating_object.is_a("IfcElement") and related_object.is_a("IfcElement")) or (
|
||||
relating_object.is_a("IfcTypeProduct") and related_object.is_a("IfcTypeProduct")
|
||||
)
|
||||
if not is_compatible_class:
|
||||
return False
|
||||
# Prevent cyclic references: walk up the full hierarchy from the
|
||||
|
||||
@@ -641,6 +641,8 @@ del _edit_type_names
|
||||
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
|
||||
# registry entry renames the constant; a typo at the call site surfaces as
|
||||
# AttributeError at module load.
|
||||
_entry = None
|
||||
for _entry in Parametric.EDIT_TYPES:
|
||||
setattr(Parametric, _entry.name.upper(), _entry)
|
||||
assert _entry is not None
|
||||
del _entry
|
||||
|
||||
@@ -168,11 +168,12 @@ class Polyline(bonsai.core.tool.Polyline):
|
||||
distance = (mouse_vector - last_point).length
|
||||
if distance < 0:
|
||||
return
|
||||
angle, orientation_angle = None, None
|
||||
angle_round_threshold = 1000 # Avoids rounding when distance is too big
|
||||
if distance > 0:
|
||||
angle = tool.Cad.angle_3_vectors(
|
||||
second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True
|
||||
)
|
||||
angle_round_threshold = 1000 # Avoids rounding when distance is too big
|
||||
|
||||
# Round angle to the nearest 0.05
|
||||
angle = round(angle / 0.05) * 0.05 if distance < angle_round_threshold else angle
|
||||
@@ -188,6 +189,7 @@ class Polyline(bonsai.core.tool.Polyline):
|
||||
angle = 0
|
||||
orientation_angle = 0
|
||||
if input_ui:
|
||||
assert angle is not None and orientation_angle is not None
|
||||
if should_round:
|
||||
angle_snap = tool.Snap.get_angle_snap_value(context)
|
||||
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
|
||||
|
||||
@@ -370,47 +370,35 @@ class Project(bonsai.core.tool.Project):
|
||||
props = cls.get_project_props()
|
||||
active_library_breadcrumb = props.get_active_library_breadcrumb()
|
||||
change_back = False
|
||||
breadcrumb = None
|
||||
if active_library_breadcrumb:
|
||||
name = active_library_breadcrumb.name
|
||||
breadcrumb_type = active_library_breadcrumb.breadcrumb_type
|
||||
library_id = active_library_breadcrumb.library_id
|
||||
breadcrumb = (name, breadcrumb_type, library_id)
|
||||
change_back = True
|
||||
|
||||
bpy.ops.bim.rewind_library()
|
||||
if change_back:
|
||||
assert breadcrumb
|
||||
bpy.ops.bim.change_library_element(
|
||||
element_name=name,
|
||||
breadcrumb_type=breadcrumb_type,
|
||||
library_id=library_id,
|
||||
element_name=breadcrumb[0],
|
||||
breadcrumb_type=breadcrumb[1],
|
||||
library_id=breadcrumb[2],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_parent_library(
|
||||
cls, project_library: ifcopenshell.entity_instance
|
||||
) -> Union[ifcopenshell.entity_instance, None]:
|
||||
"""Return the IfcContext that declares or nests ``project_library``.
|
||||
|
||||
Returns ``None`` when ``project_library`` is itself the root of a
|
||||
library-only file (no IfcRelNests, no IfcRelDeclares).
|
||||
"""
|
||||
"""Return the IfcContext that declares or nests ``project_library``, or ``None``
|
||||
if neither relationship is present."""
|
||||
if nests := project_library.Nests:
|
||||
return nests[0].RelatingObject
|
||||
if has_context := project_library.HasContext:
|
||||
return has_context[0].RelatingContext
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_root_context(cls, ifc_file: ifcopenshell.file) -> ifcopenshell.entity_instance:
|
||||
"""Return the file's root IfcContext.
|
||||
|
||||
Prefers IfcProject if present, otherwise falls back to IfcProjectLibrary —
|
||||
library-only files are valid per IFC4+ and contain no IfcProject. Caller is
|
||||
responsible for the IFC2X3 guard; IfcContext does not exist in that schema.
|
||||
"""
|
||||
if projects := ifc_file.by_type("IfcProject"):
|
||||
return projects[0]
|
||||
return ifc_file.by_type("IfcProjectLibrary")[0]
|
||||
|
||||
@classmethod
|
||||
def get_project_hierarchy(cls, ifc_file: ifcopenshell.file) -> HiearchyDict:
|
||||
"""Get project hierarchy in the following form:
|
||||
|
||||
@@ -124,6 +124,9 @@ class Pset(bonsai.core.tool.Pset):
|
||||
return bpy.context.scene.GroupPsetProperties
|
||||
elif obj_type == "Zone":
|
||||
return bpy.context.scene.ZonePsetProperties
|
||||
elif obj_type == "Cost":
|
||||
# No psets for cost items currently.
|
||||
assert False, obj_type
|
||||
assert_never(obj_type)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -505,6 +505,8 @@ class Search(bonsai.core.tool.Search):
|
||||
(0.773, 0.922, 0.816),
|
||||
(0.871, 0.957, 0.894),
|
||||
]
|
||||
else:
|
||||
assert False, theme
|
||||
|
||||
if value < min_val:
|
||||
value = min_val
|
||||
@@ -574,8 +576,10 @@ class ImportFilterQueryTransformer(lark.Transformer):
|
||||
new = self.filter_groups.add()
|
||||
global_ids = []
|
||||
is_first_group = len(self.filter_groups) == 1
|
||||
new2 = None
|
||||
for filter_index, arg in enumerate(args):
|
||||
if arg["type"] == "instance" and global_ids:
|
||||
assert new2
|
||||
if "bpy.data.texts" in new2.value:
|
||||
data_name = new2.value.split("bpy.data.texts")[1][2:-2]
|
||||
bpy.data.texts[data_name].write("," + arg["value"])
|
||||
|
||||
@@ -23,7 +23,7 @@ import re
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import time as datetime_time
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, assert_never
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
@@ -1127,7 +1127,8 @@ class Sequence(bonsai.core.tool.Sequence):
|
||||
|
||||
@classmethod
|
||||
def load_default_animation_color_scheme(cls):
|
||||
groups = {
|
||||
GroupType = Literal["CREATION", "OPERATION", "MOVEMENT_TO", "DESTRUCTION", "MOVEMENT_FROM", "USERDEFINED"]
|
||||
groups: dict[GroupType, dict[str, Any]] = {
|
||||
"CREATION": {
|
||||
"PredefinedType": ["CONSTRUCTION", "INSTALLATION"],
|
||||
"Color": (0.0, 1.0, 0.0),
|
||||
@@ -1158,15 +1159,17 @@ class Sequence(bonsai.core.tool.Sequence):
|
||||
props.task_input_colors.clear()
|
||||
for group, data in groups.items():
|
||||
for predefined_type in data["PredefinedType"]:
|
||||
if group in ["CREATION", "OPERATION", "MOVEMENT_TO"]:
|
||||
if group in ("CREATION", "OPERATION", "MOVEMENT_TO"):
|
||||
predefined_type_item = props.task_output_colors.add()
|
||||
elif group in ["MOVEMENT_FROM"]:
|
||||
elif group in ("MOVEMENT_FROM",):
|
||||
predefined_type_item = props.task_input_colors.add()
|
||||
elif group in ["USERDEFINED", "DESTRUCTION"]:
|
||||
elif group in ("USERDEFINED", "DESTRUCTION"):
|
||||
predefined_type_item = props.task_input_colors.add()
|
||||
predefined_type_item2 = props.task_output_colors.add()
|
||||
predefined_type_item2.name = predefined_type
|
||||
predefined_type_item2.color = data["Color"]
|
||||
else:
|
||||
assert_never(group)
|
||||
# TO DO: consider cases where users confuses inputs and outputs
|
||||
predefined_type_item.name = predefined_type
|
||||
predefined_type_item.color = data["Color"]
|
||||
|
||||
@@ -225,6 +225,7 @@ class Snap(bonsai.core.tool.Snap):
|
||||
# Get axis that are closer than the stick factor threshold
|
||||
elegible_axis = []
|
||||
|
||||
axis = None
|
||||
for axis in snap_axis:
|
||||
if not axis:
|
||||
continue
|
||||
@@ -326,6 +327,7 @@ class Snap(bonsai.core.tool.Snap):
|
||||
detected_snaps: list[dict[str, Any]] = []
|
||||
|
||||
def select_plane_method():
|
||||
plane_origin, plane_normal = None, None
|
||||
if not last_polyline_point:
|
||||
plane_origin = Vector((0, 0, 0))
|
||||
plane_normal = Vector((0, 0, 1))
|
||||
@@ -357,6 +359,7 @@ class Snap(bonsai.core.tool.Snap):
|
||||
plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z))
|
||||
plane_normal = Vector((1, 0, 0))
|
||||
|
||||
assert plane_origin and plane_normal
|
||||
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
|
||||
return plane_origin, plane_normal
|
||||
|
||||
@@ -583,6 +586,7 @@ class Snap(bonsai.core.tool.Snap):
|
||||
|
||||
snaps_by_group = filter_snapping_points_by_group(detected_snaps)
|
||||
edges = [] # Get edges to create edge-intersection snap
|
||||
axis_start, axis_end = ..., ...
|
||||
for snapping_point in snaps_by_group:
|
||||
if snapping_point["group"] in {"Polyline", "Measure", "Wireframe", "Object"}:
|
||||
if snapping_point["type"] == "Edge":
|
||||
@@ -607,6 +611,7 @@ class Snap(bonsai.core.tool.Snap):
|
||||
if point["type"] == "Axis":
|
||||
if ordered_snaps[0]["type"] not in {"Axis", "Plane"}:
|
||||
obj = ordered_snaps[0]["object"]
|
||||
assert axis_start is not ... and axis_end is not ...
|
||||
mixed_snap = cls.mix_snap_and_axis(ordered_snaps[0], axis_start, axis_end)
|
||||
for mixed_point in mixed_snap:
|
||||
snap_point = {
|
||||
|
||||
@@ -304,12 +304,14 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
|
||||
while True:
|
||||
has_parent = None
|
||||
new_current_results = None
|
||||
for key in current_results:
|
||||
if flat_key.startswith(key):
|
||||
has_parent = True
|
||||
new_current_results = current_results[key]["children"]
|
||||
break
|
||||
if has_parent:
|
||||
assert new_current_results is not None
|
||||
current_results = new_current_results
|
||||
else:
|
||||
break
|
||||
@@ -978,19 +980,24 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
interiors_list = []
|
||||
|
||||
if union_geom.geom_type == "MultiPolygon":
|
||||
poly = None
|
||||
for poly in union_geom.geoms:
|
||||
interiors_list = cls.get_poly_valid_interior_list(
|
||||
poly=poly, min_area=min_area, interiors_list=interiors_list
|
||||
)
|
||||
|
||||
assert poly
|
||||
new_poly = Polygon(poly.exterior.coords, holes=interiors_list)
|
||||
|
||||
if union_geom.geom_type == "Polygon":
|
||||
elif union_geom.geom_type == "Polygon":
|
||||
interiors_list = cls.get_poly_valid_interior_list(
|
||||
poly=union_geom, min_area=min_area, interiors_list=interiors_list
|
||||
)
|
||||
new_poly = Polygon(union_geom.exterior.coords, holes=interiors_list)
|
||||
|
||||
else:
|
||||
assert False, union_geom.geom_type
|
||||
|
||||
return new_poly
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -360,6 +360,10 @@ class Style(bonsai.core.tool.Style):
|
||||
material_output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL", {"is_active_output": True})
|
||||
surface_output = get_input_node(material_output, "Surface")
|
||||
|
||||
# TODO: this variable is not really needed,
|
||||
# just workaround a for ty issue detecting unresolved refs.
|
||||
bsdf = None
|
||||
|
||||
if surface_output and surface_output.type == "MIX_SHADER":
|
||||
mix_shader = surface_output
|
||||
if (
|
||||
@@ -388,6 +392,7 @@ class Style(bonsai.core.tool.Style):
|
||||
and (bsdf := get_input_node(surface_output, input_index=1, of_type="BSDF_PRINCIPLED"))
|
||||
)
|
||||
):
|
||||
assert bsdf
|
||||
report(f"Because of {BLUE}BSDF_PRINCIPLED{R} node reflectance method identified as {BLUE}PHYSICAL{R}")
|
||||
attributes["ReflectanceMethod"] = "NOTDEFINED" if tool.Ifc.get_schema() != "IFC4X3" else "PHYSICAL"
|
||||
|
||||
@@ -592,6 +597,146 @@ class Style(bonsai.core.tool.Style):
|
||||
external_style = style_elements.get("IfcExternallyDefinedSurfaceStyle", None)
|
||||
return bool(external_style and external_style.Location and external_style.Location.endswith(".blend"))
|
||||
|
||||
@classmethod
|
||||
def _color_from_principled(cls, node: bpy.types.Node) -> tuple[tuple[float, float, float], float]:
|
||||
color = cls._resolve_color_socket(node.inputs["Base Color"])
|
||||
alpha_socket = node.inputs["Alpha"]
|
||||
alpha_source = cls._upstream_color_source(alpha_socket)
|
||||
if alpha_source and alpha_source[0] == "IMAGE":
|
||||
pixels = alpha_source[1].pixels[:]
|
||||
n = len(pixels) // 4
|
||||
step = max(1, n // 4096)
|
||||
a_sum = sum(pixels[i * 4 + 3] for i in range(0, n, step))
|
||||
count = len(range(0, n, step)) or 1
|
||||
transparency = 1.0 - (a_sum / count)
|
||||
else:
|
||||
transparency = 1.0 - alpha_socket.default_value
|
||||
return color, transparency
|
||||
|
||||
@classmethod
|
||||
def _color_from_shader_socket(
|
||||
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
|
||||
) -> tuple[tuple[float, float, float], float] | None:
|
||||
if seen is None:
|
||||
seen = set()
|
||||
for link in socket.links:
|
||||
node = link.from_node
|
||||
if node.name in seen:
|
||||
continue
|
||||
seen.add(node.name)
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
return cls._color_from_principled(node)
|
||||
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
if node.type == "BSDF_GLASS":
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
if node.type in ("MIX_SHADER", "ADD_SHADER"):
|
||||
for inp in node.inputs:
|
||||
if inp.type == "SHADER" and inp.is_linked:
|
||||
result = cls._color_from_shader_socket(inp, seen)
|
||||
if result:
|
||||
return result
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_representative_material_color(
|
||||
cls, material: bpy.types.Material
|
||||
) -> tuple[tuple[float, float, float], float]:
|
||||
if material.node_tree:
|
||||
nodes = material.node_tree.nodes
|
||||
output_node = next((n for n in nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None) or next(
|
||||
(n for n in nodes if n.type == "OUTPUT_MATERIAL"), None
|
||||
)
|
||||
if output_node:
|
||||
result = cls._color_from_shader_socket(output_node.inputs["Surface"])
|
||||
if result:
|
||||
return result
|
||||
# Fallback: scan all shader nodes if no output node or graph traversal found nothing
|
||||
for node in nodes:
|
||||
if node.type == "BSDF_PRINCIPLED":
|
||||
return cls._color_from_principled(node)
|
||||
for node in nodes:
|
||||
if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"):
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
for node in nodes:
|
||||
if node.type == "BSDF_GLASS":
|
||||
return cls._resolve_color_socket(node.inputs["Color"]), 0.0
|
||||
color = tuple(material.diffuse_color[:3])
|
||||
transparency = 1.0 - material.diffuse_color[3]
|
||||
return color, transparency
|
||||
|
||||
@classmethod
|
||||
def _collect_upstream_sources(cls, socket: bpy.types.NodeSocket, seen: set[str]) -> list[tuple[str, object]]:
|
||||
"""Recursively collect all upstream colour/image sources reachable from *socket*."""
|
||||
results = []
|
||||
for link in socket.links:
|
||||
node = link.from_node
|
||||
if node.name in seen:
|
||||
continue
|
||||
seen.add(node.name)
|
||||
if node.type == "TEX_IMAGE":
|
||||
results.append(("IMAGE", node.image))
|
||||
elif node.type == "VALTORGB":
|
||||
results.append(("COLORRAMP", node))
|
||||
else:
|
||||
for inp in node.inputs:
|
||||
if inp.is_linked:
|
||||
results.extend(cls._collect_upstream_sources(inp, seen))
|
||||
return results
|
||||
|
||||
@classmethod
|
||||
def _upstream_color_source(
|
||||
cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None
|
||||
) -> tuple[str, object] | None:
|
||||
sources = cls._collect_upstream_sources(socket, set() if seen is None else seen)
|
||||
# Prefer a concrete image texture over a colour ramp (which may be greyscale/procedural).
|
||||
for s in sources:
|
||||
if s[0] == "IMAGE":
|
||||
return s
|
||||
for s in sources:
|
||||
if s[0] == "COLORRAMP":
|
||||
return s
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _resolve_color_socket(cls, socket: bpy.types.NodeSocket) -> tuple[float, float, float]:
|
||||
source = cls._upstream_color_source(socket)
|
||||
if source is None:
|
||||
return tuple(socket.default_value[:3])
|
||||
kind, obj = source
|
||||
if kind == "IMAGE":
|
||||
return cls._average_image_color(obj)
|
||||
if kind == "COLORRAMP":
|
||||
return cls._average_colorramp_color(obj)
|
||||
return tuple(socket.default_value[:3])
|
||||
|
||||
@staticmethod
|
||||
def _average_image_color(image: bpy.types.Image) -> tuple[float, float, float]:
|
||||
pixels = image.pixels[:]
|
||||
n = len(pixels) // 4
|
||||
if n == 0:
|
||||
return (0.5, 0.5, 0.5)
|
||||
step = max(1, n // 4096)
|
||||
r_sum = g_sum = b_sum = 0.0
|
||||
count = 0
|
||||
for i in range(0, n, step):
|
||||
base = i * 4
|
||||
r_sum += pixels[base]
|
||||
g_sum += pixels[base + 1]
|
||||
b_sum += pixels[base + 2]
|
||||
count += 1
|
||||
return (r_sum / count, g_sum / count, b_sum / count)
|
||||
|
||||
@staticmethod
|
||||
def _average_colorramp_color(node: bpy.types.Node) -> tuple[float, float, float]:
|
||||
elements = node.color_ramp.elements
|
||||
if not elements:
|
||||
return (0.5, 0.5, 0.5)
|
||||
r = sum(e.color[0] for e in elements) / len(elements)
|
||||
g = sum(e.color[1] for e in elements) / len(elements)
|
||||
b = sum(e.color[2] for e in elements) / len(elements)
|
||||
return (r, g, b)
|
||||
|
||||
@classmethod
|
||||
def is_editing_styles(cls) -> bool:
|
||||
props = cls.get_style_props()
|
||||
@@ -669,9 +814,179 @@ class Style(bonsai.core.tool.Style):
|
||||
props = cls.get_material_style_props(blender_material)
|
||||
props.active_style_type = props.active_style_type
|
||||
|
||||
@classmethod
|
||||
def get_branch_outputs(
|
||||
cls, material: bpy.types.Material
|
||||
) -> tuple[bpy.types.ShaderNode | None, bpy.types.ShaderNode | None]:
|
||||
"""Return (external_output_node, flat_output_node), or (None, None) if not dual-branch."""
|
||||
if not material.node_tree:
|
||||
return None, None
|
||||
ext = material.node_tree.nodes.get("BIM_Output_External")
|
||||
fast = material.node_tree.nodes.get("BIM_Output_Flat")
|
||||
return ext, fast
|
||||
|
||||
@classmethod
|
||||
def _remove_external_branch(cls, material: bpy.types.Material) -> None:
|
||||
"""Remove all nodes reachable from BIM_Output_External (walks links backwards)."""
|
||||
if not material.node_tree:
|
||||
return
|
||||
nodes = material.node_tree.nodes
|
||||
output = nodes.get("BIM_Output_External")
|
||||
if not output:
|
||||
return
|
||||
to_remove: set[str] = set()
|
||||
stack = [output]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node.name in to_remove:
|
||||
continue
|
||||
to_remove.add(node.name)
|
||||
for inp in node.inputs:
|
||||
for link in inp.links:
|
||||
stack.append(link.from_node)
|
||||
for name in list(to_remove):
|
||||
n = nodes.get(name)
|
||||
if n:
|
||||
nodes.remove(n)
|
||||
|
||||
@classmethod
|
||||
def _build_flat_branch_nodes(cls, material: bpy.types.Material) -> bpy.types.ShaderNode:
|
||||
"""Add a Principled BSDF flat-branch to material's existing node tree.
|
||||
|
||||
Reads IfcSurfaceStyleRendering or IfcSurfaceStyleShading from the linked IFC entity.
|
||||
Defaults to a white BSDF when no IFC shading data is available.
|
||||
Returns the new Material Output node (named BIM_Output_Flat, is_active_output=False).
|
||||
"""
|
||||
from mathutils import Vector
|
||||
|
||||
style_elements = cls.get_style_elements(material)
|
||||
nodes = material.node_tree.nodes
|
||||
links = material.node_tree.links
|
||||
|
||||
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
|
||||
bsdf.location = Vector((10, -600))
|
||||
output = nodes.new("ShaderNodeOutputMaterial")
|
||||
output.name = "BIM_Output_Flat"
|
||||
output.location = Vector((300, -600))
|
||||
output.is_active_output = False
|
||||
links.new(bsdf.outputs["BSDF"], output.inputs["Surface"])
|
||||
|
||||
rendering_style = None
|
||||
shading_only = None
|
||||
for surface_style in style_elements.values():
|
||||
if surface_style.is_a() == "IfcSurfaceStyleShading":
|
||||
shading_only = surface_style
|
||||
elif surface_style.is_a("IfcSurfaceStyleRendering"):
|
||||
rendering_style = surface_style
|
||||
shading_only = None
|
||||
|
||||
if rendering_style:
|
||||
d = tool.Loader.surface_style_to_dict(rendering_style)
|
||||
if d.get("DiffuseColour"):
|
||||
ctype, cval = d["DiffuseColour"]
|
||||
if ctype == "IfcColourRgb":
|
||||
bsdf.inputs["Base Color"].default_value = cval + (1,)
|
||||
solid_color = cval
|
||||
else:
|
||||
cval = tuple(v * cval for v in d["SurfaceColour"])
|
||||
bsdf.inputs["Base Color"].default_value = cval + (1,)
|
||||
solid_color = cval
|
||||
else:
|
||||
r, g, b = d["SurfaceColour"]
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
|
||||
solid_color = (r, g, b)
|
||||
if d.get("SpecularColour"):
|
||||
ctype, cval = d["SpecularColour"]
|
||||
if ctype == "IfcNormalisedRatioMeasure":
|
||||
bsdf.inputs["Metallic"].default_value = cval
|
||||
if d.get("SpecularHighlight"):
|
||||
bsdf.inputs["Roughness"].default_value = d["SpecularHighlight"]
|
||||
transparency = d.get("Transparency") or 0.0
|
||||
bsdf.inputs["Alpha"].default_value = 1 - transparency
|
||||
if transparency > 0:
|
||||
material.blend_method = "BLEND"
|
||||
material.diffuse_color = solid_color + (1.0 - transparency,)
|
||||
elif shading_only:
|
||||
d = tool.Loader.surface_style_to_dict(shading_only)
|
||||
r, g, b = d["SurfaceColour"]
|
||||
alpha = 1 - (d.get("Transparency") or 0.0)
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
|
||||
bsdf.inputs["Alpha"].default_value = alpha
|
||||
if alpha < 1.0:
|
||||
material.blend_method = "BLEND"
|
||||
material.diffuse_color = (r, g, b, alpha)
|
||||
# else: leave default white Principled BSDF
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
def setup_dual_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> bool:
|
||||
"""Build a dual-branch node tree: flat branch from IFC data + external branch from ext_material.
|
||||
|
||||
Clears any existing nodes and builds both branches from scratch.
|
||||
External branch output (BIM_Output_External) is set active — Pretty mode.
|
||||
Flat branch output (BIM_Output_Flat) is inactive — Flat mode.
|
||||
Returns True on success; False if no shader editor is available (falls back to single-branch).
|
||||
"""
|
||||
cls.set_use_nodes(material, True)
|
||||
for n in material.node_tree.nodes[:]:
|
||||
material.node_tree.nodes.remove(n)
|
||||
|
||||
cls._build_flat_branch_nodes(material)
|
||||
|
||||
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
|
||||
if not ext_output:
|
||||
# No shader editor available: fall back to single-branch
|
||||
tool.Blender.copy_node_graph(material, ext_material)
|
||||
return False
|
||||
|
||||
ext_output.name = "BIM_Output_External"
|
||||
ext_output.is_active_output = True
|
||||
material["bim_dual_branch"] = True
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def update_external_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> None:
|
||||
"""Replace the external-branch nodes of an already dual-branch material."""
|
||||
cls._remove_external_branch(material)
|
||||
ext_output = tool.Blender.copy_node_graph_additive(material, ext_material)
|
||||
if ext_output:
|
||||
ext_output.name = "BIM_Output_External"
|
||||
ext_output.is_active_output = True
|
||||
fast = material.node_tree.nodes.get("BIM_Output_Flat")
|
||||
if fast:
|
||||
fast.is_active_output = False
|
||||
|
||||
@classmethod
|
||||
def sync_flat_branch_shading(
|
||||
cls, material: bpy.types.Material, surface_colour: tuple[float, float, float], transparency: float
|
||||
) -> None:
|
||||
"""Update the flat-branch Principled BSDF with new shading values.
|
||||
|
||||
Call this after creating or editing IfcSurfaceStyleShading so the flat branch
|
||||
stays in sync without requiring a full setup_dual_branch rebuild.
|
||||
"""
|
||||
if not material.node_tree:
|
||||
return
|
||||
fast_output = material.node_tree.nodes.get("BIM_Output_Flat")
|
||||
if not fast_output:
|
||||
return
|
||||
for link in fast_output.inputs["Surface"].links:
|
||||
if link.from_node.type == "BSDF_PRINCIPLED":
|
||||
bsdf = link.from_node
|
||||
r, g, b = surface_colour
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
|
||||
bsdf.inputs["Alpha"].default_value = 1.0 - transparency
|
||||
break
|
||||
|
||||
@classmethod
|
||||
def switch_shading(cls, blender_material: bpy.types.Material, style_type: StyleType) -> None:
|
||||
if style_type == "External":
|
||||
ext, fast = cls.get_branch_outputs(blender_material)
|
||||
if ext and fast:
|
||||
ext.is_active_output = True
|
||||
fast.is_active_output = False
|
||||
blender_material.update_tag()
|
||||
return
|
||||
try:
|
||||
bpy.ops.bim.activate_external_style(material_name=blender_material.name)
|
||||
except RuntimeError as error:
|
||||
@@ -679,21 +994,41 @@ class Style(bonsai.core.tool.Style):
|
||||
return
|
||||
raise error
|
||||
elif style_type == "Shading":
|
||||
ext, fast = cls.get_branch_outputs(blender_material)
|
||||
if ext and fast:
|
||||
fast.is_active_output = True
|
||||
ext.is_active_output = False
|
||||
blender_material.update_tag()
|
||||
return
|
||||
style_elements = tool.Style.get_style_elements(blender_material)
|
||||
rendering_style = None
|
||||
texture_style = None
|
||||
shading_only_style = None
|
||||
|
||||
for surface_style in style_elements.values():
|
||||
if surface_style.is_a() == "IfcSurfaceStyleShading":
|
||||
shading_only_style = surface_style
|
||||
tool.Loader.create_surface_style_shading(blender_material, surface_style)
|
||||
elif surface_style.is_a("IfcSurfaceStyleRendering"):
|
||||
rendering_style = surface_style
|
||||
shading_only_style = None # rendering overrides shading-only path
|
||||
tool.Loader.create_surface_style_rendering(blender_material, surface_style)
|
||||
elif surface_style.is_a("IfcSurfaceStyleWithTextures"):
|
||||
texture_style = surface_style
|
||||
|
||||
if rendering_style and texture_style:
|
||||
tool.Loader.create_surface_style_with_textures(blender_material, rendering_style, texture_style)
|
||||
elif shading_only_style and not rendering_style:
|
||||
# create a minimal Principled BSDF so Material Preview/Rendered shows the colour instead of white.
|
||||
tool.Style.set_use_nodes(blender_material, True)
|
||||
tool.Loader.restart_material_node_tree(blender_material)
|
||||
bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED")
|
||||
if bsdf:
|
||||
r, g, b, a = blender_material.diffuse_color
|
||||
bsdf.inputs["Base Color"].default_value = (r, g, b, 1)
|
||||
bsdf.inputs["Alpha"].default_value = a
|
||||
if a < 1.0:
|
||||
blender_material.blend_method = "BLEND"
|
||||
else:
|
||||
assert False, f"Invalid style type found: {style_type}"
|
||||
|
||||
@@ -739,3 +1074,36 @@ class Style(bonsai.core.tool.Style):
|
||||
elements = ifcopenshell.util.element.get_elements_by_style(tool.Ifc.get(), style)
|
||||
objects = [tool.Ifc.get_object(e) for e in elements]
|
||||
tool.Geometry.reload_representation(objects)
|
||||
|
||||
_last_shading_type: str | None = None
|
||||
|
||||
@classmethod
|
||||
def restore_material_style_types(cls, shading_type: str) -> None:
|
||||
"""Set each IFC material's active_style_type to the richest available for the given viewport mode.
|
||||
|
||||
In SOLID mode all materials use "Shading".
|
||||
In MATERIAL_PREVIEW / RENDERED, materials with an external .blend style use "External"
|
||||
unless prefer_ifc_shading is set on that material.
|
||||
"""
|
||||
if cls._last_shading_type == shading_type:
|
||||
return
|
||||
cls._last_shading_type = shading_type
|
||||
|
||||
for material in bpy.data.materials:
|
||||
if not tool.Blender.get_ifc_definition_id(material):
|
||||
continue
|
||||
props = cls.get_material_style_props(material)
|
||||
style_elements = cls.get_style_elements(material)
|
||||
if shading_type == "SOLID":
|
||||
props.active_style_type = "Shading"
|
||||
shading = style_elements.get("IfcSurfaceStyleRendering") or style_elements.get("IfcSurfaceStyleShading")
|
||||
if shading:
|
||||
d = tool.Loader.surface_style_to_dict(shading)
|
||||
alpha = 1.0 - (d.get("Transparency") or 0.0)
|
||||
material.diffuse_color = d["SurfaceColour"] + (alpha,)
|
||||
else: # MATERIAL_PREVIEW or RENDERED
|
||||
if cls.has_blender_external_style(style_elements) and not props.prefer_ifc_shading:
|
||||
props.active_style_type = "External"
|
||||
else:
|
||||
props.active_style_type = "Shading"
|
||||
material.update_tag()
|
||||
|
||||
@@ -24,7 +24,7 @@ Blender versions:
|
||||
- 64-bit MacOS Intel (``macos-x64``)
|
||||
- 64-bit MacOS Silicon (``macos-arm64``)
|
||||
- 64-bit Windows (``windows-x64``)
|
||||
- Blender 4.3, 4.4, or 4.5 with Python 3.11
|
||||
- Blender 5.1 or 5.2 with Python 3.13
|
||||
|
||||
Developer builds may exist for different versions of Python but there will be
|
||||
no guarantee of the uptime or stability of these builds.
|
||||
|
||||
@@ -216,6 +216,7 @@ def update_translations_from_po(po_directory: Path, translations_module: Path):
|
||||
|
||||
|
||||
if BPY_IS_LOADED:
|
||||
import bpy
|
||||
|
||||
class SetupTranslationUI(bpy.types.Operator):
|
||||
bl_idname = "bim.setup_translation_ui"
|
||||
|
||||
@@ -82,6 +82,7 @@ class Generator:
|
||||
}
|
||||
""".replace("{entity}", location.split("#")[-1]))
|
||||
# filter parents for the brick entity
|
||||
parent = None
|
||||
for row in query:
|
||||
parent = row.get("parent").toPython()
|
||||
if "brickschema.org" in parent and parent in references.keys():
|
||||
|
||||
@@ -15,6 +15,7 @@ Example usage:
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -27,6 +28,17 @@ if sys.platform not in available_platforms:
|
||||
print(f"Currently only available on {', '.join(available_platforms)}. Not available on {sys.platform}.")
|
||||
exit(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--skip-binaries",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Skip copying compiled dependencies (e.g. ifcopenshell_wrapper) to the repo. "
|
||||
"Useful if you already have the latest binaries in the repo and don't want them to be overridden."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# ---------------------------
|
||||
# SETTINGS.
|
||||
# ---------------------------
|
||||
@@ -125,17 +137,20 @@ def main() -> None:
|
||||
path.unlink()
|
||||
subprocess.check_call(("git", "checkout", "--", symlinks_glob), cwd=REPO_PATH)
|
||||
|
||||
print("Copying compiled dependencies to the repo...")
|
||||
dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell"
|
||||
for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"):
|
||||
if path.suffix.lower() == ".pyi":
|
||||
continue
|
||||
dest_ = dest / path.name
|
||||
print(f"Copying {path} -> {dest_}")
|
||||
try:
|
||||
shutil.copy(path, dest_)
|
||||
except shutil.SameFileError:
|
||||
pass
|
||||
if args.skip_binaries:
|
||||
print("Skipping copying compiled dependencies to the repo...")
|
||||
else:
|
||||
print("Copying compiled dependencies to the repo...")
|
||||
dest = REPO_PATH / "src" / "ifcopenshell-python" / "ifcopenshell"
|
||||
for path in PACKAGE_PATH.glob("ifcopenshell/*_wrapper*"):
|
||||
if path.suffix.lower() == ".pyi":
|
||||
continue
|
||||
dest_ = dest / path.name
|
||||
print(f"Copying {path} -> {dest_}")
|
||||
try:
|
||||
shutil.copy(path, dest_)
|
||||
except shutil.SameFileError:
|
||||
pass
|
||||
|
||||
print("Symlinking extension to the git repo...")
|
||||
# fmt: off
|
||||
|
||||
@@ -16,20 +16,26 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import argparse
|
||||
|
||||
import ezdxf
|
||||
import ifcopenshell
|
||||
import ifcopenshell.guid
|
||||
|
||||
|
||||
class Dxf2Ifc:
|
||||
def __init__(self, dxf_path, outfile):
|
||||
self.dxf_path = dxf_path
|
||||
self.outfile = outfile
|
||||
|
||||
def execute(self):
|
||||
self.create_ifc_file()
|
||||
doc = ezdxf.readfile("input.dxf")
|
||||
doc = ezdxf.readfile(self.dxf_path)
|
||||
model = doc.modelspace()
|
||||
products = []
|
||||
for entity in model:
|
||||
print(entity)
|
||||
if entity.get_mode() == "AcDbPolyFaceMesh":
|
||||
if entity.dxftype() == "POLYLINE" and entity.is_poly_face_mesh:
|
||||
ifc_faces = []
|
||||
for face in entity.faces():
|
||||
ifc_faces.append(
|
||||
@@ -67,15 +73,18 @@ class Dxf2Ifc:
|
||||
"Name": entity.dxf.layer,
|
||||
"ObjectPlacement": self.placement,
|
||||
"Representation": representation,
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
else:
|
||||
print("Not yet implemented")
|
||||
self.file.createIfcRelContainedInSpatialStructure(
|
||||
ifcopenshell.guid.new(), None, None, None, products, self.site
|
||||
)
|
||||
self.file.write("test.ifc")
|
||||
print(f"Skipping unsupported entity: {entity.dxftype()}")
|
||||
if products:
|
||||
self.file.createIfcRelContainedInSpatialStructure(
|
||||
ifcopenshell.guid.new(), None, None, None, products, self.site
|
||||
)
|
||||
else:
|
||||
print("No AcDbPolyFaceMesh entities found, writing an empty IFC")
|
||||
self.file.write(self.outfile)
|
||||
|
||||
def create_ifc_file(self):
|
||||
self.file = ifcopenshell.file()
|
||||
@@ -97,14 +106,20 @@ class Dxf2Ifc:
|
||||
"Name": "DXF Conversion",
|
||||
"RepresentationContexts": [self.context],
|
||||
"UnitsInContext": units,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.site = self.file.create_entity(
|
||||
"IfcSite",
|
||||
**{"GlobalId": ifcopenshell.guid.new(), "Name": "DXF Conversion Site", "ObjectPlacement": self.placement}
|
||||
**{"GlobalId": ifcopenshell.guid.new(), "Name": "DXF Conversion Site", "ObjectPlacement": self.placement},
|
||||
)
|
||||
self.file.createIfcRelAggregates(ifcopenshell.guid.new(), None, None, None, self.project, [self.site])
|
||||
|
||||
|
||||
dxf2ifc = Dxf2Ifc()
|
||||
dxf2ifc.execute()
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Converts DXF polyface meshes (AcDbPolyFaceMesh) to an IFC")
|
||||
parser.add_argument("dxf", type=str, help="The input DXF file")
|
||||
parser.add_argument("-o", "--output", type=str, help="The output IFC file", default="out.ifc")
|
||||
args = parser.parse_args()
|
||||
|
||||
dxf2ifc = Dxf2Ifc(args.dxf, args.output)
|
||||
dxf2ifc.execute()
|
||||
|
||||
@@ -1036,6 +1036,7 @@ class LibraryGenerator:
|
||||
seat_width_offset = 0.7 * width / 2 if cistern_depth else width / 2
|
||||
seat_start_width_offset = 0.6 * width
|
||||
|
||||
cistern_3d = None
|
||||
if cistern_height:
|
||||
cistern = builder.rectangle(size=V(width, cistern_depth), position=shift_to_center)
|
||||
cistern_3d = ifcopenshell.util.element.copy_deep(self.file, cistern)
|
||||
@@ -1118,6 +1119,7 @@ class LibraryGenerator:
|
||||
|
||||
# cistern
|
||||
if cistern_height:
|
||||
assert cistern_3d
|
||||
cistern_3d = builder.extrude(
|
||||
cistern_3d, cistern_height + seat_level / 2, position=V(0, 0, seat_level / 2)
|
||||
)
|
||||
|
||||
@@ -143,6 +143,7 @@ class LibraryGenerator:
|
||||
if "unused" in ifc_params:
|
||||
del ifc_params["unused"]
|
||||
|
||||
profiles_gap = ...
|
||||
if prof_type == "profile_hollow*_square":
|
||||
ifc_params["YDim"] = ifc_params["XDim"]
|
||||
elif ifc_profile_name == "IfcCircleHollowProfileDef":
|
||||
@@ -160,6 +161,7 @@ class LibraryGenerator:
|
||||
profile = self.file.create_entity(ifc_profile_name, ProfileName=prof_name, ProfileType="AREA", **ifc_params)
|
||||
|
||||
if prof_type == "profile_l*lbeam_2l":
|
||||
assert profiles_gap is not ...
|
||||
profile.ProfileName = None # to avoid name confusion
|
||||
mode = "SLBB" if prof_name.endswith("_SLBB") else "LLBB"
|
||||
profile = self.create_double_l_profile(profile, prof_name, profiles_gap, mode)
|
||||
|
||||
@@ -12,16 +12,19 @@ Scenario: Ensure added booleans are marked as manual
|
||||
And I click "OK"
|
||||
And the object "IfcFurniture/Unnamed" exists
|
||||
And I toggle edit mode
|
||||
And the object "Item/IfcExtrudedAreaSolid/73" exists
|
||||
And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()"
|
||||
And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists
|
||||
And I open the "Add Item" menu
|
||||
When I click "Half Space Solid"
|
||||
And the object "Item/IfcHalfSpaceSolid/90" exists
|
||||
And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()"
|
||||
And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()"
|
||||
And the object "Item/IfcHalfSpaceSolid/{half_space}" exists
|
||||
And I deselect all objects
|
||||
And I toggle edit mode
|
||||
And I select the object "IfcFurniture/Unnamed"
|
||||
And I look at the "Property Sets" panel
|
||||
Then I see "BBIM_Boolean"
|
||||
And I see "[91]"
|
||||
And I see "[{boolean}]"
|
||||
|
||||
Scenario: Ensure removed booleans are unmarked as manual
|
||||
Given an empty IFC project
|
||||
@@ -33,17 +36,20 @@ Scenario: Ensure removed booleans are unmarked as manual
|
||||
And I click "OK"
|
||||
And the object "IfcFurniture/Unnamed" exists
|
||||
And I toggle edit mode
|
||||
And the object "Item/IfcExtrudedAreaSolid/73" exists
|
||||
And the variable "extrusion" is "{ifc}.by_type('IfcExtrudedAreaSolid')[0].id()"
|
||||
And the object "Item/IfcExtrudedAreaSolid/{extrusion}" exists
|
||||
And I open the "Add Item" menu
|
||||
And I click "Half Space Solid"
|
||||
And the variable "half_space" is "{ifc}.by_type('IfcHalfSpaceSolid')[0].id()"
|
||||
And the variable "boolean" is "{ifc}.by_type('IfcBooleanResult')[0].id()"
|
||||
And I deselect all objects
|
||||
And I toggle edit mode
|
||||
And I select the object "IfcFurniture/Unnamed"
|
||||
And I toggle edit mode
|
||||
And I select the object "Item/IfcHalfSpaceSolid/90"
|
||||
And I select the object "Item/IfcHalfSpaceSolid/{half_space}"
|
||||
When I delete the selected objects
|
||||
And I toggle edit mode
|
||||
And I select the object "IfcFurniture/Unnamed"
|
||||
And I look at the "Property Sets" panel
|
||||
Then I don't see "BBIM_Boolean"
|
||||
And I don't see "[91]"
|
||||
And I don't see "[{boolean}]"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# This file was generated with the assistance of an AI coding tool.
|
||||
|
||||
"""Regression tests for ``ProfileDecorator``'s ``_connected_components`` helper.
|
||||
|
||||
Duplicating a circle/arc void's loop in Edit Mode (Tab, Tab, select the
|
||||
loop, Shift+D) copies the ``IFCARCINDEX``/``IFCCIRCLE`` vertex-group
|
||||
weights onto the new geometry, since Blender's mesh duplicate never
|
||||
allocates a new vertex group. Before this fix, ``ProfileDecorator``
|
||||
grouped arc/circle vertices by group index alone, so the duplicate's
|
||||
vertices piled into the same entry as the source loop's, failing the
|
||||
"exactly 2/3 verts" check and silently dropping *both* loops from the
|
||||
decorator until the mesh was re-imported (e.g. by leaving and re-entering
|
||||
Edit Mode). ``_connected_components`` splits each group's vertices back
|
||||
into their connected loops so a duplicate is drawn immediately, see #6944."""
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
from bonsai.bim.module.model.decorator import _connected_components
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
def _bm_with_groups(verts, edges, groups):
|
||||
"""Build a standalone bmesh with a deform layer, and populate ``groups``:
|
||||
a list of (group_index, [vert_indices]) pairs, mirroring how
|
||||
``tool.Model.import_profile``/``convert_curve_to_mesh`` assign one
|
||||
vertex group per circle/arc loop."""
|
||||
bm = bmesh.new()
|
||||
deform_layer = bm.verts.layers.deform.new()
|
||||
bm_verts = [bm.verts.new(v) for v in verts]
|
||||
bm.verts.ensure_lookup_table()
|
||||
for a, b in edges:
|
||||
bm.edges.new((bm_verts[a], bm_verts[b]))
|
||||
bm.verts.ensure_lookup_table()
|
||||
bm_verts = list(bm.verts)
|
||||
for group_index, vert_indices in groups:
|
||||
for vi in vert_indices:
|
||||
bm_verts[vi][deform_layer][group_index] = 1.0
|
||||
return bm, bm_verts, deform_layer
|
||||
|
||||
|
||||
def _group_dict(bm_verts, deform_layer, group_index, vert_indices):
|
||||
return {group_index: [bm_verts[i] for i in vert_indices]}
|
||||
|
||||
|
||||
def test_single_circle_loop_is_one_component():
|
||||
# A circle is exactly 2 verts joined by 1 edge (tool.Model.convert_curve_to_mesh).
|
||||
bm, bm_verts, deform_layer = _bm_with_groups(
|
||||
verts=[(0, -1, 0), (0, 1, 0)],
|
||||
edges=[(0, 1)],
|
||||
groups=[(0, [0, 1])],
|
||||
)
|
||||
circles = _group_dict(bm_verts, deform_layer, 0, [0, 1])
|
||||
components = _connected_components(circles)
|
||||
assert len(components) == 1
|
||||
assert len(components[0]) == 2
|
||||
bm.free()
|
||||
|
||||
|
||||
def test_single_arc_loop_is_one_component():
|
||||
# An arc is exactly 3 verts: endpoint-midpoint-endpoint (2 edges).
|
||||
bm, bm_verts, deform_layer = _bm_with_groups(
|
||||
verts=[(0, -1, 0), (0, 0, 0.3), (0, 1, 0)],
|
||||
edges=[(0, 1), (1, 2)],
|
||||
groups=[(0, [0, 1, 2])],
|
||||
)
|
||||
arcs = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2])
|
||||
components = _connected_components(arcs)
|
||||
assert len(components) == 1
|
||||
assert len(components[0]) == 3
|
||||
bm.free()
|
||||
|
||||
|
||||
def test_duplicated_circle_loop_splits_into_two_components():
|
||||
# Duplicating verts 0-1 (Shift+D) yields verts 2-3, connected to each
|
||||
# other but NOT to the source loop, while keeping the same group index
|
||||
# (0) -- exactly what bmesh.ops.duplicate produces mid Edit-Mode.
|
||||
bm, bm_verts, deform_layer = _bm_with_groups(
|
||||
verts=[(0, -1, 0), (0, 1, 0), (5, -1, 0), (5, 1, 0)],
|
||||
edges=[(0, 1), (2, 3)],
|
||||
groups=[(0, [0, 1, 2, 3])],
|
||||
)
|
||||
circles = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2, 3])
|
||||
components = _connected_components(circles)
|
||||
assert len(components) == 2
|
||||
assert sorted(len(c) for c in components) == [2, 2]
|
||||
bm.free()
|
||||
|
||||
|
||||
def test_duplicated_arc_loop_splits_into_two_components():
|
||||
bm, bm_verts, deform_layer = _bm_with_groups(
|
||||
verts=[(0, -1, 0), (0, 0, 0.3), (0, 1, 0), (5, -1, 0), (5, 0, 0.3), (5, 1, 0)],
|
||||
edges=[(0, 1), (1, 2), (3, 4), (4, 5)],
|
||||
groups=[(0, [0, 1, 2, 3, 4, 5])],
|
||||
)
|
||||
arcs = _group_dict(bm_verts, deform_layer, 0, [0, 1, 2, 3, 4, 5])
|
||||
components = _connected_components(arcs)
|
||||
assert len(components) == 2
|
||||
assert sorted(len(c) for c in components) == [3, 3]
|
||||
bm.free()
|
||||
|
||||
|
||||
def test_distinct_circle_groups_stay_separate_and_correctly_sized():
|
||||
# Multiple genuinely different circles (distinct group indices) must
|
||||
# each still resolve to their own single 2-vert component.
|
||||
verts = []
|
||||
edges = []
|
||||
groups = []
|
||||
for i in range(5):
|
||||
base = len(verts)
|
||||
verts += [(i * 3, -1, 0), (i * 3, 1, 0)]
|
||||
edges.append((base, base + 1))
|
||||
groups.append((i, [base, base + 1]))
|
||||
bm, bm_verts, deform_layer = _bm_with_groups(verts, edges, groups)
|
||||
circles = {}
|
||||
for group_index, vert_indices in groups:
|
||||
circles[group_index] = [bm_verts[i] for i in vert_indices]
|
||||
components = _connected_components(circles)
|
||||
assert len(components) == 5
|
||||
assert all(len(c) == 2 for c in components)
|
||||
bm.free()
|
||||
@@ -32,64 +32,91 @@ from test.bim.bootstrap import NewIfc
|
||||
pytestmark = pytest.mark.project
|
||||
|
||||
|
||||
def _make_library_only_file(*, with_child: bool = False) -> ifcopenshell.file:
|
||||
"""Build a minimal IFC4 file containing only an IfcProjectLibrary (no IfcProject).
|
||||
def _make_library_file(*, with_child: bool = False) -> ifcopenshell.file:
|
||||
"""Build a spec-valid IFC4 library file: IfcProject + IfcProjectLibrary declared to it.
|
||||
|
||||
Per IFC4+, a file must contain at least one IfcContext; IfcProjectLibrary is a
|
||||
valid root on its own. ``with_child=True`` nests a sub-library under the root via
|
||||
IfcRelNests, mirroring real authored library files.
|
||||
Per the IFC Project Context concept template, every project data set (library
|
||||
files included) shall contain exactly one IfcProject, and IfcProjectLibrary
|
||||
instances are assigned to it via IfcRelDeclares. This matches how every library
|
||||
file shipped in bonsai/bim/data/libraries is actually authored. ``with_child=True``
|
||||
also nests a sub-library under the root via IfcRelNests.
|
||||
"""
|
||||
library_file = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
project = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProject", name="Demo Project")
|
||||
root = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
|
||||
ifcopenshell.api.project.assign_declaration(library_file, definitions=[root], relating_context=project)
|
||||
if with_child:
|
||||
child = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="ChildLib")
|
||||
ifcopenshell.api.nest.assign_object(library_file, [child], root)
|
||||
return library_file
|
||||
|
||||
|
||||
class TestLibraryOnlyFile(NewIfc):
|
||||
def test_get_root_context_returns_project_library_when_no_project(self):
|
||||
library_file = _make_library_only_file()
|
||||
assert not library_file.by_type("IfcProject")
|
||||
class TestLibraryFile(NewIfc):
|
||||
"""Project-library UI code operating on a spec-valid model (IfcProject root).
|
||||
|
||||
root = tool.Project.get_root_context(library_file)
|
||||
A file containing only IfcProjectLibrary and no IfcProject is not valid IFC and
|
||||
is not supported; see test_parent_libraries_enum_raises_for_a_file_without_a_project.
|
||||
"""
|
||||
|
||||
assert root.is_a("IfcProjectLibrary")
|
||||
assert root.Name == "RootLib"
|
||||
def test_parent_libraries_enum_raises_for_a_file_without_a_project(self):
|
||||
library_file = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="RootLib")
|
||||
IfcStore.library_file = library_file
|
||||
try:
|
||||
with pytest.raises(IndexError):
|
||||
ProjectLibraryData.parent_libraries_enum()
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
|
||||
def test_get_parent_library_returns_none_for_root_library(self):
|
||||
library_file = _make_library_only_file()
|
||||
def test_get_parent_library_returns_project_for_declared_root_library(self):
|
||||
library_file = _make_library_file()
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
|
||||
assert tool.Project.get_parent_library(root) is None
|
||||
assert tool.Project.get_parent_library(root) == project
|
||||
|
||||
def test_get_project_hierarchy_skips_root_library(self):
|
||||
library_file = _make_library_only_file(with_child=True)
|
||||
def test_get_parent_library_returns_the_library_for_a_nested_sub_library(self):
|
||||
library_file = _make_library_file(with_child=True)
|
||||
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
|
||||
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
|
||||
|
||||
assert tool.Project.get_parent_library(child) == root
|
||||
|
||||
def test_get_parent_library_returns_none_for_an_orphaned_library(self):
|
||||
library_file = ifcopenshell.api.project.create_file(version="IFC4")
|
||||
orphan = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="Orphan")
|
||||
|
||||
assert tool.Project.get_parent_library(orphan) is None
|
||||
|
||||
def test_get_project_hierarchy_roots_libraries_under_the_project(self):
|
||||
library_file = _make_library_file(with_child=True)
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "RootLib")
|
||||
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
|
||||
|
||||
hierarchy = tool.Project.get_project_hierarchy(library_file)
|
||||
|
||||
assert root in hierarchy
|
||||
assert root in hierarchy[project]
|
||||
assert child in hierarchy[root]
|
||||
|
||||
def test_project_library_data_loads_without_crash(self):
|
||||
IfcStore.library_file = _make_library_only_file()
|
||||
def test_project_library_data_loads_with_unique_enum_keys(self):
|
||||
IfcStore.library_file = _make_library_file(with_child=True)
|
||||
try:
|
||||
ProjectLibraryData.is_loaded = False
|
||||
ProjectLibraryData.load()
|
||||
assert ProjectLibraryData.is_loaded
|
||||
enum = ProjectLibraryData.data["parent_libraries_enum"]
|
||||
assert len(enum) == 1
|
||||
assert enum[0][1].startswith("IfcProjectLibrary ")
|
||||
keys = [entry[0] for entry in enum]
|
||||
assert len(keys) == len(set(keys))
|
||||
assert enum[0][1].startswith("IfcProject ")
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_refresh_library_succeeds_on_library_only_file(self):
|
||||
def test_refresh_library_succeeds(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_only_file(with_child=True)
|
||||
IfcStore.library_file = _make_library_file(with_child=True)
|
||||
try:
|
||||
result = bpy.ops.bim.refresh_library()
|
||||
assert result == {"FINISHED"}
|
||||
@@ -97,13 +124,65 @@ class TestLibraryOnlyFile(NewIfc):
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_add_project_library_nests_under_root_when_no_project(self):
|
||||
def test_edit_project_library_moves_a_project_declared_library_under_another_library(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_only_file()
|
||||
library_file = _make_library_file()
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
target = ifcopenshell.api.root.create_entity(library_file, ifc_class="IfcProjectLibrary", name="TargetLib")
|
||||
ifcopenshell.api.project.assign_declaration(library_file, definitions=[target], relating_context=project)
|
||||
IfcStore.library_file = library_file
|
||||
try:
|
||||
props = tool.Project.get_project_props()
|
||||
props.selected_project_library = str(root.id())
|
||||
props.is_editing_project_library = True
|
||||
props.parent_library = str(target.id())
|
||||
|
||||
result = bpy.ops.bim.edit_project_library()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert tool.Project.get_parent_library(root) == target
|
||||
assert root.Nests and root.Nests[0].RelatingObject == target
|
||||
assert not root.HasContext
|
||||
finally:
|
||||
if props.is_editing_project_library:
|
||||
props.is_editing_project_library = False
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_edit_project_library_moves_a_nested_library_back_under_the_project(self):
|
||||
import bpy
|
||||
|
||||
library_file = _make_library_file(with_child=True)
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
child = next(lib for lib in library_file.by_type("IfcProjectLibrary") if lib.Name == "ChildLib")
|
||||
IfcStore.library_file = library_file
|
||||
try:
|
||||
props = tool.Project.get_project_props()
|
||||
props.selected_project_library = str(child.id())
|
||||
props.is_editing_project_library = True
|
||||
props.parent_library = str(project.id())
|
||||
|
||||
result = bpy.ops.bim.edit_project_library()
|
||||
|
||||
assert result == {"FINISHED"}
|
||||
assert tool.Project.get_parent_library(child) == project
|
||||
assert child.HasContext and child.HasContext[0].RelatingContext == project
|
||||
assert not child.Nests
|
||||
finally:
|
||||
if props.is_editing_project_library:
|
||||
props.is_editing_project_library = False
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
def test_add_project_library_declares_new_library_under_the_project_root(self):
|
||||
import bpy
|
||||
|
||||
IfcStore.library_file = _make_library_file()
|
||||
library_file = IfcStore.library_file
|
||||
try:
|
||||
root = library_file.by_type("IfcProjectLibrary")[0]
|
||||
project = library_file.by_type("IfcProject")[0]
|
||||
before = set(library_file.by_type("IfcProjectLibrary"))
|
||||
|
||||
result = bpy.ops.bim.add_project_library()
|
||||
@@ -113,9 +192,9 @@ class TestLibraryOnlyFile(NewIfc):
|
||||
new_libraries = after - before
|
||||
assert len(new_libraries) == 1
|
||||
new_library = next(iter(new_libraries))
|
||||
assert new_library.Nests
|
||||
assert new_library.Nests[0].RelatingObject == root
|
||||
assert not new_library.HasContext
|
||||
assert new_library.HasContext
|
||||
assert new_library.HasContext[0].RelatingContext == project
|
||||
assert not new_library.Nests
|
||||
finally:
|
||||
IfcStore.library_file = None
|
||||
ProjectLibraryData.is_loaded = False
|
||||
|
||||
@@ -21,6 +21,10 @@ from typing import Any, Optional, Union
|
||||
|
||||
|
||||
class bSDDClientStub:
|
||||
def __init__(self):
|
||||
# Mirrors bsdd.Client so tool.Bsdd.identifier_url() works against the stub.
|
||||
self.baseurl = "https://api.bsdd.buildingsmart.org/api/"
|
||||
|
||||
def get_dictionary(self, dictionary_uri=None, include_test_dictionaries=False):
|
||||
dicts = {
|
||||
"dictionaries": [
|
||||
|
||||
@@ -187,7 +187,14 @@ class PanelSpy:
|
||||
after = ""
|
||||
if self.spied_labels:
|
||||
after = self.spied_labels[-1]
|
||||
spied_operator = {"operator": operator, "icon": icon, "text": text, "kwargs": {}, "after": after}
|
||||
spied_operator = {
|
||||
"operator": operator,
|
||||
"icon": icon,
|
||||
"text": text,
|
||||
"kwargs": {},
|
||||
"after": after,
|
||||
"bl_idname": bl_idname,
|
||||
}
|
||||
self.spied_operators.append(spied_operator)
|
||||
return OperatorSpy(spied_operator)
|
||||
elif self.spied_attr == "panel":
|
||||
@@ -210,6 +217,14 @@ class OperatorSpy:
|
||||
else:
|
||||
self.spied_data["kwargs"][name] = value
|
||||
|
||||
@property
|
||||
def bl_rna(self) -> Any:
|
||||
# Mirror the real `UILayout.operator()` return value (an OperatorProperties
|
||||
# instance), which exposes `.bl_rna` so panel code such as
|
||||
# `"module" in op.bl_rna.properties` (bonsai/bim/helper.py) also works when
|
||||
# drawing is spied on during BDD tests.
|
||||
return getattr(bpy.types, self.spied_data["bl_idname"]).bl_rna
|
||||
|
||||
|
||||
class TemplateListSpy(PanelSpy):
|
||||
items: bpy.types.bpy_prop_collection_idprop[bpy.types.PropertyGroup]
|
||||
@@ -453,6 +468,7 @@ def i_trigger_operator(operator):
|
||||
@then(parsers.parse('I see "{text}"'))
|
||||
def i_see_text(text):
|
||||
assert panel_spy
|
||||
text = replace_variables(text)
|
||||
panel_spy.refresh_spy()
|
||||
assert [l for l in panel_spy.spied_labels if text in l], f"Text {text} not found in {panel_spy.spied_labels}"
|
||||
|
||||
@@ -587,6 +603,7 @@ def i_select_the_row_where_i_see_text_in_the_nth_list(text, nth):
|
||||
@then(parsers.parse('I don\'t see "{text}"'))
|
||||
def i_dont_see_text(text):
|
||||
assert panel_spy
|
||||
text = replace_variables(text)
|
||||
panel_spy.refresh_spy()
|
||||
assert not [l for l in panel_spy.spied_labels if text in l], f"Text {text} found in {panel_spy.spied_labels}"
|
||||
|
||||
@@ -773,7 +790,11 @@ def i_create_default_mep_types():
|
||||
with bpy.context.temp_override(active_object=bpy.data.objects["IfcActuatorType/ACTUATOR"]):
|
||||
bpy.ops.bim.add_port()
|
||||
# port at cube's left side
|
||||
bpy.data.objects["IfcDistributionPort/Port"].location = (-0.5, 0, 0)
|
||||
# Newly created ports are never given an explicit IFC `.Name` (see
|
||||
# `core/system.py:create_port_at_cursor` / `tool/system.py`), so
|
||||
# `tool.Loader.get_name()` falls back to the standard "Unnamed" convention
|
||||
# used throughout Bonsai for freshly-created, not-yet-named elements.
|
||||
bpy.data.objects["IfcDistributionPort/Unnamed"].location = (-0.5, 0, 0)
|
||||
bpy.ops.bim.hide_ports()
|
||||
|
||||
|
||||
@@ -1073,6 +1094,7 @@ def then_the_object_name_is_placed_in_the_collection_collection(name: str, colle
|
||||
@given(parsers.parse('additionally the object "{name}" is selected'))
|
||||
@when(parsers.parse('additionally the object "{name}" is selected'))
|
||||
def additionally_the_object_name_is_selected(name):
|
||||
name = replace_variables(name)
|
||||
obj = bpy.context.scene.objects.get(name)
|
||||
if not obj:
|
||||
total = len(bpy.context.scene.objects)
|
||||
@@ -1153,6 +1175,7 @@ def nothing_happens():
|
||||
@when(parsers.parse('the object "{name}" exists'))
|
||||
@then(parsers.parse('the object "{name}" exists'))
|
||||
def the_object_name_exists(name: str) -> bpy.types.Object:
|
||||
name = replace_variables(name)
|
||||
# Some objects from linked collections may share the same name. This disambiguates them.
|
||||
if name.startswith("Col:"):
|
||||
_, collection_name, name = name.split(":")
|
||||
@@ -1167,6 +1190,7 @@ def the_object_name_exists(name: str) -> bpy.types.Object:
|
||||
|
||||
@then(parsers.parse('the object "{name}" does not exist'))
|
||||
def the_object_name_does_not_exist(name) -> None:
|
||||
name = replace_variables(name)
|
||||
obj = bpy.data.objects.get(name)
|
||||
assert obj is None, f'The object "{name}" exists'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user