mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
pyodide/app-demo
This commit is contained in:
@@ -20,3 +20,6 @@
|
|||||||
[submodule "src/ifcopenshell-python/ifcopenshell/simple_spf"]
|
[submodule "src/ifcopenshell-python/ifcopenshell/simple_spf"]
|
||||||
path = src/ifcopenshell-python/ifcopenshell/simple_spf
|
path = src/ifcopenshell-python/ifcopenshell/simple_spf
|
||||||
url = https://github.com/IfcOpenShell/step-file-parser
|
url = https://github.com/IfcOpenShell/step-file-parser
|
||||||
|
[submodule "src/pyodide/demo-app/wheels"]
|
||||||
|
path = src/pyodide/demo-app/wheels
|
||||||
|
url = https://github.com/IfcOpenShell/wasm-wheels
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import ifcopenshell
|
||||||
|
import ifcopenshell.geom
|
||||||
|
import ifcopenshell.api
|
||||||
|
import ifcopenshell.util.unit
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import propertygroups
|
||||||
|
|
||||||
|
O = 0.0, 0.0, 0.0
|
||||||
|
X = 1.0, 0.0, 0.0
|
||||||
|
Y = 0.0, 1.0, 0.0
|
||||||
|
Z = 0.0, 0.0, 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
model = None
|
||||||
|
body = None
|
||||||
|
storey = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._create_empty_model()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._create_empty_model()
|
||||||
|
|
||||||
|
def open(self, file_content):
|
||||||
|
self.model = ifcopenshell.file.from_string(file_content)
|
||||||
|
body = [ctx for ctx in self.model.by_type('IfcGeometricRepresentationContext') if ctx.ContextIdentifier == 'Body']
|
||||||
|
if body:
|
||||||
|
self.body = body[0]
|
||||||
|
else:
|
||||||
|
context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model")
|
||||||
|
self.body = ifcopenshell.api.run(
|
||||||
|
"context.add_context",
|
||||||
|
self.model,
|
||||||
|
context_type="Model",
|
||||||
|
context_identifier="Body",
|
||||||
|
target_view="MODEL_VIEW",
|
||||||
|
parent=context,
|
||||||
|
)
|
||||||
|
axis = [ctx for ctx in self.model.by_type('IfcGeometricRepresentationContext') if ctx.ContextIdentifier == 'Axis']
|
||||||
|
if body:
|
||||||
|
self.axis = axis[0]
|
||||||
|
else:
|
||||||
|
context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model")
|
||||||
|
self.axis = ifcopenshell.api.run(
|
||||||
|
"context.add_context",
|
||||||
|
self.model,
|
||||||
|
context_type="Model",
|
||||||
|
context_identifier="Axis",
|
||||||
|
target_view="GRAPH_VIEW",
|
||||||
|
parent=context,
|
||||||
|
)
|
||||||
|
self.storey = self.model.by_type('IfcBuildingStorey')[0]
|
||||||
|
|
||||||
|
def _create_empty_model(self):
|
||||||
|
# Create a blank model
|
||||||
|
self.model = ifcopenshell.file()
|
||||||
|
# All projects must have one IFC Project element
|
||||||
|
project = ifcopenshell.api.run(
|
||||||
|
"root.create_entity", self.model, ifc_class="IfcProject", name="My Project"
|
||||||
|
)
|
||||||
|
# Geometry is optional in IFC, but because we want to use geometry in this example, let's define units
|
||||||
|
# Assigning without arguments defaults to metric units
|
||||||
|
ifcopenshell.api.run("unit.assign_unit", self.model)
|
||||||
|
# Let's create a modeling geometry context, so we can store 3D geometry (note: IFC supports 2D too!)
|
||||||
|
context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model")
|
||||||
|
# In particular, in this example we want to store the 3D "body" geometry of objects, i.e. the body shape
|
||||||
|
self.body = ifcopenshell.api.run(
|
||||||
|
"context.add_context",
|
||||||
|
self.model,
|
||||||
|
context_type="Model",
|
||||||
|
context_identifier="Body",
|
||||||
|
target_view="MODEL_VIEW",
|
||||||
|
parent=context,
|
||||||
|
)
|
||||||
|
self.axis = ifcopenshell.api.run(
|
||||||
|
"context.add_context",
|
||||||
|
self.model,
|
||||||
|
context_type="Model",
|
||||||
|
context_identifier="Axis",
|
||||||
|
target_view="GRAPH_VIEW",
|
||||||
|
parent=context,
|
||||||
|
)
|
||||||
|
# Create a site, building, and storey. Many hierarchies are possible.
|
||||||
|
site = ifcopenshell.api.run(
|
||||||
|
"root.create_entity", self.model, ifc_class="IfcSite", name="My Site"
|
||||||
|
)
|
||||||
|
building = ifcopenshell.api.run(
|
||||||
|
"root.create_entity", self.model, ifc_class="IfcBuilding", name="Building A"
|
||||||
|
)
|
||||||
|
self.storey = ifcopenshell.api.run(
|
||||||
|
"root.create_entity",
|
||||||
|
self.model,
|
||||||
|
ifc_class="IfcBuildingStorey",
|
||||||
|
name="Ground Floor",
|
||||||
|
)
|
||||||
|
# Since the site is our top level location, assign it to the project
|
||||||
|
# Then place our building on the site, and our storey in the building
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"aggregate.assign_object",
|
||||||
|
self.model,
|
||||||
|
relating_object=project,
|
||||||
|
products=[site],
|
||||||
|
)
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"aggregate.assign_object",
|
||||||
|
self.model,
|
||||||
|
relating_object=site,
|
||||||
|
products=[building],
|
||||||
|
)
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"aggregate.assign_object",
|
||||||
|
self.model,
|
||||||
|
relating_object=building,
|
||||||
|
products=[self.storey],
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_2pt_wall(
|
||||||
|
self, p1, p2, elevation, height, thickness, container, wall_type=None
|
||||||
|
):
|
||||||
|
p1 = np.array([p1[0], p1[1]])
|
||||||
|
p2 = np.array([p2[0], p2[1]])
|
||||||
|
|
||||||
|
wall = ifcopenshell.api.run("root.create_entity", self.model, ifc_class="IfcWall")
|
||||||
|
length = float(np.linalg.norm(p2 - p1))
|
||||||
|
representation = ifcopenshell.api.run(
|
||||||
|
"geometry.add_wall_representation",
|
||||||
|
self.model,
|
||||||
|
context=self.body,
|
||||||
|
length=length,
|
||||||
|
height=height,
|
||||||
|
thickness=thickness,
|
||||||
|
)
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"geometry.assign_representation",
|
||||||
|
self.model,
|
||||||
|
product=wall,
|
||||||
|
representation=representation,
|
||||||
|
)
|
||||||
|
representation = ifcopenshell.api.run(
|
||||||
|
"geometry.add_axis_representation",
|
||||||
|
self.model,
|
||||||
|
context=self.axis,
|
||||||
|
axis=[(0.0, 0.0), (length, 0.0)],
|
||||||
|
)
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"geometry.assign_representation",
|
||||||
|
self.model,
|
||||||
|
product=wall,
|
||||||
|
representation=representation,
|
||||||
|
)
|
||||||
|
v = p2 - p1
|
||||||
|
v = np.divide(v, float(np.linalg.norm(v)), casting="unsafe")
|
||||||
|
matrix = np.array(
|
||||||
|
[
|
||||||
|
[v[0], -v[1], 0, p1[0]],
|
||||||
|
[v[1], v[0], 0, p1[1]],
|
||||||
|
[0, 0, 1, elevation],
|
||||||
|
[0, 0, 0, 1],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ifcopenshell.api.run("geometry.edit_object_placement", self.model, product=wall, matrix=matrix)
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"spatial.assign_container",
|
||||||
|
self.model,
|
||||||
|
relating_structure=container,
|
||||||
|
products=[wall],
|
||||||
|
)
|
||||||
|
if wall_type:
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"type.assign_type",
|
||||||
|
self.model,
|
||||||
|
related_object=wall,
|
||||||
|
relating_type=wall_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return wall
|
||||||
|
|
||||||
|
def get_element(self, guid):
|
||||||
|
return self.model[guid]
|
||||||
|
|
||||||
|
def get_model(self):
|
||||||
|
return self.model
|
||||||
|
|
||||||
|
def create_fill(self, ty, pt, wall):
|
||||||
|
if isinstance(wall, str):
|
||||||
|
wall = self.model[wall]
|
||||||
|
if not wall.is_a('IfcWall'):
|
||||||
|
raise ValueError("Only 'wall' hosts are supported")
|
||||||
|
if ty == 'door':
|
||||||
|
props = propertygroups.BIMDoorProperties()
|
||||||
|
elif ty == 'window':
|
||||||
|
props = propertygroups.BIMWindowProperties()
|
||||||
|
else:
|
||||||
|
raise ValueError("Only 'door' or 'window' fills are supported")
|
||||||
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.model)
|
||||||
|
body = ifcopenshell.util.representation.get_context(
|
||||||
|
self.model, "Model", "Body", "MODEL_VIEW"
|
||||||
|
)
|
||||||
|
representation_data = props.to_dict(si_conversion=si_conversion)
|
||||||
|
representation_data["context"] = body
|
||||||
|
door_representation = ifcopenshell.api.run(
|
||||||
|
f"geometry.add_{ty}_representation", self.model, **representation_data
|
||||||
|
)
|
||||||
|
door = ifcopenshell.api.run(
|
||||||
|
"root.create_entity", self.model, ifc_class=f"ifc{ty}"
|
||||||
|
)
|
||||||
|
door.OverallWidth = props.overall_width / si_conversion
|
||||||
|
door.OverallHeight = props.overall_height / si_conversion
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"geometry.assign_representation",
|
||||||
|
self.model,
|
||||||
|
product=door,
|
||||||
|
representation=door_representation,
|
||||||
|
)
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"spatial.assign_container",
|
||||||
|
self.model,
|
||||||
|
relating_structure=self.storey,
|
||||||
|
products=[door],
|
||||||
|
)
|
||||||
|
|
||||||
|
r = [
|
||||||
|
r
|
||||||
|
for r in wall.Representation.Representations
|
||||||
|
if r.RepresentationIdentifier == "Axis"
|
||||||
|
]
|
||||||
|
if not r:
|
||||||
|
raise ValueError("Axis representation is needed")
|
||||||
|
r = r[0]
|
||||||
|
axis_geometry = ifcopenshell.geom.create_shape(
|
||||||
|
ifcopenshell.geom.settings(
|
||||||
|
DIMENSIONALITY=ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS,
|
||||||
|
USE_WORLD_COORDS=True,
|
||||||
|
),
|
||||||
|
wall,
|
||||||
|
r,
|
||||||
|
)
|
||||||
|
vs = np.array(axis_geometry.geometry.verts).reshape((-1, 3))
|
||||||
|
es = np.array(axis_geometry.geometry.edges).reshape((-1, 2))
|
||||||
|
A, B = vs[es[0]]
|
||||||
|
v = B - A
|
||||||
|
P = np.zeros(3)
|
||||||
|
P[0 : len(pt)] = pt
|
||||||
|
AP = P - A
|
||||||
|
AP_dot_v = np.dot(AP, v)
|
||||||
|
v_dot_v = np.dot(v, v)
|
||||||
|
t = AP_dot_v / v_dot_v * np.linalg.norm(v) / si_conversion
|
||||||
|
|
||||||
|
opening = ifcopenshell.api.run(
|
||||||
|
"root.create_entity",
|
||||||
|
self.model,
|
||||||
|
ifc_class="IfcOpeningElement",
|
||||||
|
predefined_type="OPENING",
|
||||||
|
name="Opening",
|
||||||
|
)
|
||||||
|
|
||||||
|
position_3d = None
|
||||||
|
if self.model.schema == "IFC2X3":
|
||||||
|
position_3d = self.model.createIfcAxis2Placement2D(
|
||||||
|
self.model.createIfcCartesianPoint([0.0, 0.0, 0.0])
|
||||||
|
)
|
||||||
|
position_2d = self.model.createIfcAxis2Placement2D(
|
||||||
|
self.model.createIfcCartesianPoint([door.OverallWidth / 2.0, 0.0])
|
||||||
|
)
|
||||||
|
|
||||||
|
opening.Representation = self.model.createIfcProductDefinitionShape(
|
||||||
|
Representations=[
|
||||||
|
self.model.createIfcRepresentation(
|
||||||
|
body,
|
||||||
|
"Body",
|
||||||
|
"SweptSolid",
|
||||||
|
Items=[
|
||||||
|
self.model.createIfcExtrudedAreaSolid(
|
||||||
|
self.model.createIfcRectangleProfileDef(
|
||||||
|
"AREA",
|
||||||
|
None,
|
||||||
|
position_2d,
|
||||||
|
door.OverallWidth,
|
||||||
|
1.2 / si_conversion,
|
||||||
|
),
|
||||||
|
position_3d,
|
||||||
|
self.model.createIfcDirection((0.0, 0.0, 1.0)),
|
||||||
|
door.OverallHeight,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"void.add_opening", self.model, opening=opening, element=wall
|
||||||
|
)
|
||||||
|
ifcopenshell.api.run(
|
||||||
|
"void.add_filling", self.model, opening=opening, element=door
|
||||||
|
)
|
||||||
|
|
||||||
|
z_offsets = {
|
||||||
|
'door': 0,
|
||||||
|
'window': 1
|
||||||
|
}
|
||||||
|
opening.ObjectPlacement = self.model.createIfcLocalPlacement(
|
||||||
|
wall.ObjectPlacement,
|
||||||
|
self.model.createIfcAxis2Placement3D(
|
||||||
|
self.model.createIfcCartesianPoint((float(t), 0.0, z_offsets[ty] / si_conversion))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
door.ObjectPlacement = self.model.createIfcLocalPlacement(
|
||||||
|
opening.ObjectPlacement,
|
||||||
|
self.model.createIfcAxis2Placement3D(
|
||||||
|
self.model.createIfcCartesianPoint((0.0, 0.0, 0.0))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return door
|
||||||
|
|
||||||
|
def to_obj_file(self, fn):
|
||||||
|
st = ifcopenshell.geom.settings(USE_WORLD_COORDS=True, WELD_VERTICES=False)
|
||||||
|
it = ifcopenshell.geom.iterator(st, self.model, exclude=("IfcOpeningElement",))
|
||||||
|
sr = ifcopenshell.geom.serializers.obj(
|
||||||
|
fn, fn + ".mtl", st, ifcopenshell.geom.serializer_settings()
|
||||||
|
)
|
||||||
|
if it.initialize():
|
||||||
|
for el in ifcopenshell.geom.consume_iterator(it):
|
||||||
|
sr.write(el)
|
||||||
|
sr.finalize()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
m = Context()
|
||||||
|
w1 = m.create_2pt_wall((0.0, 0.0), (4.0, 0.0), 0.0, 3.0, 0.1, m.storey)
|
||||||
|
w2 = m.create_2pt_wall((1.0, 3.0), (1.0, 0.0), 0.0, 3.0, 0.1, m.storey)
|
||||||
|
m.create_fill('door', [2, 0.0], w1)
|
||||||
|
m.create_fill('window', [1, 1.5], w2)
|
||||||
|
m.model.write("out.ifc")
|
||||||
|
m.to_obj_file("out.obj")
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@48,200,0,0" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class='loading'>
|
||||||
|
<div class='menu'>
|
||||||
|
<ul>
|
||||||
|
<li class="material-symbols-outlined">note_add</li>
|
||||||
|
<li class="material-symbols-outlined">folder_open</li>
|
||||||
|
<li class="material-symbols-outlined">save</li>
|
||||||
|
</ul>
|
||||||
|
<div id='branding'>
|
||||||
|
<b>IfcOpenShell</b> WebAssembly
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='toolbar'>
|
||||||
|
<ul>
|
||||||
|
<li class="material-symbols-outlined active">arrow_selector_tool</li>
|
||||||
|
<li class="material-symbols-outlined">diagonal_line</li>
|
||||||
|
<li class="material-symbols-outlined">door_open</li>
|
||||||
|
<li class="material-symbols-outlined">window</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class='main'>
|
||||||
|
<input type="file" id="modelupload" style="display:none" />
|
||||||
|
<div class='msg'>
|
||||||
|
<div id='status1'>Loading...</div>
|
||||||
|
<div id='status2'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
document.querySelector("#status2").innerHTML = "Fetching pyodide";
|
||||||
|
</script>
|
||||||
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js"></script>
|
||||||
|
<script async src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"></script>
|
||||||
|
<script type="importmap">
|
||||||
|
{"imports": {"three": "https://unpkg.com/three@0.141.0/build/three.module.js",
|
||||||
|
"OrbitControls": "https://unpkg.com/three@0.141.0/examples/jsm/controls/OrbitControls.js"}}
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import {
|
||||||
|
OrbitControls
|
||||||
|
} from 'OrbitControls';
|
||||||
|
|
||||||
|
THREE.Object3D.DefaultUp = new THREE.Vector3(0, 0, 1);
|
||||||
|
|
||||||
|
let pyodide = null;
|
||||||
|
let previousPoint = null;
|
||||||
|
let objectMapping = {};
|
||||||
|
|
||||||
|
function performDownload(filename, text) {
|
||||||
|
let element = document.createElement('a');
|
||||||
|
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
|
||||||
|
element.setAttribute('download', filename);
|
||||||
|
|
||||||
|
element.style.display = 'none';
|
||||||
|
document.body.appendChild(element);
|
||||||
|
|
||||||
|
element.click();
|
||||||
|
|
||||||
|
document.body.removeChild(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
document.querySelector("#status2").innerHTML = "Initializing pyodide";
|
||||||
|
pyodide = await loadPyodide();
|
||||||
|
document.querySelector("#status2").innerHTML = "Loading dependencies";
|
||||||
|
await pyodide.loadPackage("micropip");
|
||||||
|
await pyodide.loadPackage("numpy");
|
||||||
|
const micropip = pyodide.pyimport("micropip");
|
||||||
|
await micropip.install("typing-extensions");
|
||||||
|
document.querySelector("#status2").innerHTML = "Loading IfcOpenShell";
|
||||||
|
await micropip.install("wheels/ifcopenshell-0.8.1+latest-cp312-cp312-emscripten_3_1_58_wasm32.whl");
|
||||||
|
|
||||||
|
document.body.className = '';
|
||||||
|
|
||||||
|
let ifcopenshell = pyodide.pyimport('ifcopenshell');
|
||||||
|
let ifcopenshell_geom = pyodide.pyimport('ifcopenshell.geom');
|
||||||
|
let s = ifcopenshell_geom.settings();
|
||||||
|
s.set(s.WELD_VERTICES, false);
|
||||||
|
|
||||||
|
// Load custom Python modules
|
||||||
|
await pyodide.runPythonAsync(`
|
||||||
|
from pyodide.http import pyfetch
|
||||||
|
for fn in ['context', 'propertygroups']:
|
||||||
|
response = await pyfetch(f"./{fn}.py")
|
||||||
|
with open(f"{fn}.py", "wb") as f:
|
||||||
|
f.write(await response.bytes())`)
|
||||||
|
|
||||||
|
// Initialize model context and make accessibly to JS
|
||||||
|
pyodide.runPython(`
|
||||||
|
from context import Context
|
||||||
|
import numpy as np
|
||||||
|
modelObject = Context()`);
|
||||||
|
let modelObject = pyodide.globals.get('modelObject').toJs();
|
||||||
|
|
||||||
|
// Menu and toolbar init
|
||||||
|
let mouseMode = 0;
|
||||||
|
let buttons = Array.from(document.querySelectorAll('.toolbar li'));
|
||||||
|
buttons.forEach((el, i) => {
|
||||||
|
el.onclick = (e) => {
|
||||||
|
buttons.forEach(el => el.classList.remove('active'));
|
||||||
|
mouseMode = i;
|
||||||
|
previousPoint = null;
|
||||||
|
e.stopPropagation();
|
||||||
|
el.classList.add('active');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
let newfile = () => {
|
||||||
|
modelObject.clear();
|
||||||
|
clearScene();
|
||||||
|
};
|
||||||
|
let open = async () => {
|
||||||
|
let uploadField = document.querySelector('#modelupload');
|
||||||
|
let input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.onchange = async () => {
|
||||||
|
clearScene();
|
||||||
|
let file = input.files[0];
|
||||||
|
let contents = await file.text();
|
||||||
|
modelObject.open(contents);
|
||||||
|
loadScene();
|
||||||
|
initCamera();
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
let save = () => {
|
||||||
|
performDownload('model.ifc', modelObject.get_model().to_string());
|
||||||
|
};
|
||||||
|
let commands = Array.from(document.querySelectorAll('.menu li'));
|
||||||
|
commands.forEach((el, i) => {
|
||||||
|
el.onclick = [newfile, open, save][i];
|
||||||
|
});
|
||||||
|
|
||||||
|
let clearScene = () => {
|
||||||
|
const lights = [];
|
||||||
|
scene.traverse((obj) => {
|
||||||
|
if (obj.type.endsWith('Light')) {
|
||||||
|
lights.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scene.children = lights;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init ThreeJS
|
||||||
|
let d = document.querySelector('.main');
|
||||||
|
const renderer = new THREE.WebGLRenderer();
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, d.offsetWidth / d.offsetHeight, 1, 1000);
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.mouseButtons = {
|
||||||
|
MIDDLE: THREE.MOUSE.ROTATE,
|
||||||
|
RIGHT: THREE.MOUSE.PAN
|
||||||
|
}
|
||||||
|
renderer.setSize(d.offsetWidth, d.offsetHeight);
|
||||||
|
d.appendChild(renderer.domElement);
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
let light = new THREE.DirectionalLight(0xFFFFFF);
|
||||||
|
light.position.set(20, 10, 30);
|
||||||
|
scene.add(light);
|
||||||
|
light = new THREE.DirectionalLight(0xFFFFFF, 0.8);
|
||||||
|
light.position.set(-10, 1, -30);
|
||||||
|
scene.add(light);
|
||||||
|
scene.add(new THREE.AmbientLight(0x404050));
|
||||||
|
let lbm = new THREE.LineBasicMaterial({
|
||||||
|
color: 0x222222
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate ThreeJS mesh from an IfcOpenShell python geometry object
|
||||||
|
let last_geometries = null;
|
||||||
|
function generateMesh(last_mesh_id, obj) {
|
||||||
|
let geometries;
|
||||||
|
if (last_mesh_id == obj.geometry.id) {
|
||||||
|
geometries = last_geometries;
|
||||||
|
} else {
|
||||||
|
geometries = [];
|
||||||
|
|
||||||
|
let materials = obj.geometry.materials.toJs().map(e => new THREE.MeshLambertMaterial({
|
||||||
|
color: new THREE.Color(...e.diffuse.components.toJs()),
|
||||||
|
opacity: 1.0 - e.transparency,
|
||||||
|
transparent: e.transparency > 1.e-5,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
polygonOffset: true,
|
||||||
|
polygonOffsetFactor: 1.0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mapping = {};
|
||||||
|
obj.geometry.material_ids.toJs().forEach((i, idx) => {
|
||||||
|
mapping[i] = mapping[i] || []
|
||||||
|
mapping[i].push(idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let vs = new Float32Array(obj.geometry.verts.toJs());
|
||||||
|
let ns = new Float32Array(obj.geometry.normals.toJs());
|
||||||
|
let es = obj.geometry.edges.toJs();
|
||||||
|
let fs = obj.geometry.faces.toJs();
|
||||||
|
|
||||||
|
// Default material
|
||||||
|
let offset = 0;
|
||||||
|
if (mapping[-1]) {
|
||||||
|
materials.unshift(new THREE.MeshLambertMaterial({
|
||||||
|
color: new THREE.Color(0.6, 0.6, 0.6),
|
||||||
|
side: THREE.DoubleSide
|
||||||
|
}));
|
||||||
|
offset = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
materials.forEach((m, mi) => {
|
||||||
|
let geometry = new THREE.BufferGeometry();
|
||||||
|
|
||||||
|
geometry.setIndex(mapping[mi - offset].flatMap(i => [fs[3 * i + 0], fs[3 * i + 1], fs[3 * i + 2]]));
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vs, 3));
|
||||||
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(ns, 3));
|
||||||
|
|
||||||
|
geometries.push([geometry, m]);
|
||||||
|
});
|
||||||
|
|
||||||
|
let geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(vs, 3));
|
||||||
|
geometry.setIndex(es);
|
||||||
|
geometries.push([geometry, lbm]);
|
||||||
|
|
||||||
|
last_mesh_id = obj.geometry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let gm of geometries) {
|
||||||
|
let [g, mat] = gm;
|
||||||
|
let object;
|
||||||
|
if (g.attributes.normal) {
|
||||||
|
object = new THREE.Mesh(g, mat);
|
||||||
|
} else {
|
||||||
|
object = new THREE.LineSegments(g, lbm);
|
||||||
|
}
|
||||||
|
let matrix = new THREE.Matrix4();
|
||||||
|
const m = obj.transformation.data().components.toJs();
|
||||||
|
matrix.set(
|
||||||
|
m[0][0], m[0][1], m[0][2], m[0][3],
|
||||||
|
m[1][0], m[1][1], m[1][2], m[1][3],
|
||||||
|
m[2][0], m[2][1], m[2][2], m[2][3],
|
||||||
|
m[3][0], m[3][1], m[3][2], m[3][3]
|
||||||
|
);
|
||||||
|
object.matrixAutoUpdate = false;
|
||||||
|
object.matrix = matrix;
|
||||||
|
|
||||||
|
scene.add(object);
|
||||||
|
|
||||||
|
objectMapping[object.uuid] = obj.guid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCamera() {
|
||||||
|
let boundingBox = new THREE.Box3();
|
||||||
|
boundingBox.setFromObject(scene);
|
||||||
|
let center = new THREE.Vector3();
|
||||||
|
boundingBox.getCenter(center);
|
||||||
|
controls.target = center;
|
||||||
|
|
||||||
|
let viewDistance = boundingBox.isEmpty() ? 100. : boundingBox.getSize(new THREE.Vector3()).length() * 2.;
|
||||||
|
camera.position.copy(center.clone().add(
|
||||||
|
new THREE.Vector3(0.25, 1.0, 0.5).normalize().multiplyScalar(viewDistance)
|
||||||
|
));
|
||||||
|
|
||||||
|
camera.near = viewDistance / 100;
|
||||||
|
camera.far = viewDistance * 100;
|
||||||
|
controls.update();
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
camera.updateMatrixWorld();
|
||||||
|
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
initCamera();
|
||||||
|
|
||||||
|
function loadScene() {
|
||||||
|
document.body.className = 'loading';
|
||||||
|
document.querySelector("#status2").innerHTML = "Generating geometry";
|
||||||
|
|
||||||
|
let ifc = modelObject.get_model();
|
||||||
|
let it = ifcopenshell_geom.iterator.callKwargs({
|
||||||
|
'settings': s,
|
||||||
|
'file_or_filename': ifc,
|
||||||
|
'exclude': ['IfcSpace', 'IfcOpeningElement'],
|
||||||
|
'geometry_library': 'hybrid-cgal-simple-opencascade'
|
||||||
|
});
|
||||||
|
|
||||||
|
let last_mesh_id = null;
|
||||||
|
|
||||||
|
if (it.initialize()) {
|
||||||
|
while (true) {
|
||||||
|
let obj = it.get();
|
||||||
|
|
||||||
|
// obj.type appears to be overwritten by pyodide, returning the typename of the C++ class?
|
||||||
|
let ty = ifc.by_id(obj.id).is_a()
|
||||||
|
generateMesh(last_mesh_id, obj);
|
||||||
|
|
||||||
|
if (!it.next()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.body.className = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
requestAnimationFrame(render);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', onMouseClick);
|
||||||
|
|
||||||
|
function getPoint(event) {
|
||||||
|
let raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
|
let mouse = new THREE.Vector2();
|
||||||
|
let rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
mouse.x = ((event.clientX - rect.left) / (rect.width)) * 2 - 1;
|
||||||
|
mouse.y = -((event.clientY - rect.top) / (rect.height)) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
|
||||||
|
if (mouseMode != 1) {
|
||||||
|
// Select, Add Window/Door
|
||||||
|
const nonLineObjects = [];
|
||||||
|
scene.traverse((obj) => {
|
||||||
|
if (obj.geometry && obj.geometry.attributes.normal) {
|
||||||
|
nonLineObjects.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let objs = raycaster.intersectObjects(nonLineObjects, false);
|
||||||
|
if (objs.length) {
|
||||||
|
let {
|
||||||
|
point,
|
||||||
|
object
|
||||||
|
} = objs[0];
|
||||||
|
return {
|
||||||
|
point,
|
||||||
|
object
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Polygonal wall
|
||||||
|
let plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); // flat ground plane
|
||||||
|
let point = new THREE.Vector3();
|
||||||
|
return raycaster.ray.intersectPlane(plane, point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addObjToScene(obj) {
|
||||||
|
let last_mesh_id = null;
|
||||||
|
|
||||||
|
generateMesh(last_mesh_id, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createShape(el) {
|
||||||
|
addObjToScene(ifcopenshell_geom.create_shape.callKwargs({
|
||||||
|
'settings': s,
|
||||||
|
'inst': el,
|
||||||
|
'geometry_library': 'hybrid-cgal-simple-opencascade'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseClick(event) {
|
||||||
|
let newPoint = getPoint(event);
|
||||||
|
if (mouseMode == 1) {
|
||||||
|
if (previousPoint != null) {
|
||||||
|
createShape(modelObject.create_2pt_wall([previousPoint.x, previousPoint.y], [newPoint.x, newPoint.y], 0, 3., 0.2, modelObject.storey));
|
||||||
|
}
|
||||||
|
previousPoint = newPoint;
|
||||||
|
}
|
||||||
|
if (mouseMode == 2 || mouseMode == 3) {
|
||||||
|
if (newPoint !== null) {
|
||||||
|
let {
|
||||||
|
point,
|
||||||
|
object
|
||||||
|
} = newPoint;
|
||||||
|
const guid = objectMapping[object.uuid];
|
||||||
|
const toRemove = [];
|
||||||
|
scene.traverse((obj) => {
|
||||||
|
if (objectMapping[obj.uuid] == guid) {
|
||||||
|
toRemove.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toRemove.forEach(obj => obj.removeFromParent());
|
||||||
|
createShape(modelObject.create_fill(mouseMode == 2 ? 'door' : 'window', [point.x, point.y, point.z], guid));
|
||||||
|
createShape(modelObject.get_element(guid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
# From: src\bonsai\bonsai\bim\module\model\prop.py
|
||||||
|
# Adapted to use dataclasses instead of bpy props
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BIMWindowProperties:
|
||||||
|
non_si_units_props = ("is_editing", "window_type")
|
||||||
|
window_types = (
|
||||||
|
("SINGLE_PANEL", "SINGLE_PANEL", ""),
|
||||||
|
("DOUBLE_PANEL_HORIZONTAL", "DOUBLE_PANEL_HORIZONTAL", ""),
|
||||||
|
("DOUBLE_PANEL_VERTICAL", "DOUBLE_PANEL_VERTICAL", ""),
|
||||||
|
("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_BOTTOM", ""),
|
||||||
|
("TRIPLE_PANEL_TOP", "TRIPLE_PANEL_TOP", ""),
|
||||||
|
("TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_LEFT", ""),
|
||||||
|
("TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_RIGHT", ""),
|
||||||
|
("TRIPLE_PANEL_HORIZONTAL", "TRIPLE_PANEL_HORIZONTAL", ""),
|
||||||
|
("TRIPLE_PANEL_VERTICAL", "TRIPLE_PANEL_VERTICAL", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# number of panels and default mullion/transom values
|
||||||
|
window_types_panels = {
|
||||||
|
"SINGLE_PANEL": (1, ((0, 0 ), (0, 0 ))),
|
||||||
|
"DOUBLE_PANEL_HORIZONTAL": (2, ((0, 0 ), (0.45, 0 ))),
|
||||||
|
"DOUBLE_PANEL_VERTICAL": (2, ((0.3, 0 ), (0, 0 ))),
|
||||||
|
"TRIPLE_PANEL_BOTTOM": (3, ((0.3, 0 ), (0.45, 0 ))),
|
||||||
|
"TRIPLE_PANEL_TOP": (3, ((0.3, 0 ), (0.45, 0 ))),
|
||||||
|
"TRIPLE_PANEL_LEFT": (3, ((0.3, 0 ), (0.45, 0 ))),
|
||||||
|
"TRIPLE_PANEL_RIGHT": (3, ((0.3, 0 ), (0.45, 0 ))),
|
||||||
|
"TRIPLE_PANEL_HORIZONTAL": (3, ((0, 0 ), (0.3, 0.6))),
|
||||||
|
"TRIPLE_PANEL_VERTICAL": (3, ((0.2, 0.4), (0, 0 ))),
|
||||||
|
}
|
||||||
|
|
||||||
|
is_editing: bool = False
|
||||||
|
window_type: str = "SINGLE_PANEL"
|
||||||
|
overall_height: float = 0.9
|
||||||
|
overall_width: float = 0.6
|
||||||
|
|
||||||
|
# lining properties
|
||||||
|
lining_depth: float = 0.050
|
||||||
|
lining_thickness: float = 0.050
|
||||||
|
lining_offset: float = 0.050
|
||||||
|
lining_to_panel_offset_x: float = 0.025
|
||||||
|
lining_to_panel_offset_y: float = 0.025
|
||||||
|
mullion_thickness: float = 0.050
|
||||||
|
first_mullion_offset: float = 0.3
|
||||||
|
second_mullion_offset: float = 0.45
|
||||||
|
transom_thickness: float = 0.050
|
||||||
|
first_transom_offset: float = 0.3
|
||||||
|
second_transom_offset: float = 0.6
|
||||||
|
|
||||||
|
# panel properties
|
||||||
|
frame_depth: list = field(default_factory = lambda: [0.035] * 3)
|
||||||
|
frame_thickness: list = field(default_factory = lambda: [0.035] * 3)
|
||||||
|
|
||||||
|
def to_dict(self, si_conversion=1.):
|
||||||
|
di = {
|
||||||
|
"partition_type": self.window_type,
|
||||||
|
"overall_height": self.overall_height / si_conversion,
|
||||||
|
"overall_width": self.overall_width / si_conversion,
|
||||||
|
"lining_properties": {
|
||||||
|
"LiningDepth": self.lining_depth / si_conversion,
|
||||||
|
"LiningThickness": self.lining_thickness / si_conversion,
|
||||||
|
"LiningOffset": self.lining_offset / si_conversion,
|
||||||
|
"LiningToPanelOffsetX": self.lining_to_panel_offset_x / si_conversion,
|
||||||
|
"LiningToPanelOffsetY": self.lining_to_panel_offset_y / si_conversion,
|
||||||
|
"MullionThickness": self.mullion_thickness / si_conversion,
|
||||||
|
"FirstMullionOffset": self.first_mullion_offset / si_conversion,
|
||||||
|
"SecondMullionOffset": self.second_mullion_offset / si_conversion,
|
||||||
|
"TransomThickness": self.transom_thickness / si_conversion,
|
||||||
|
"FirstTransomOffset": self.first_transom_offset / si_conversion,
|
||||||
|
"SecondTransomOffset": self.second_transom_offset / si_conversion,
|
||||||
|
},
|
||||||
|
"panel_properties": [],
|
||||||
|
}
|
||||||
|
number_of_panels, panels_data = self.window_types_panels[self.window_type]
|
||||||
|
for panel_i in range(number_of_panels):
|
||||||
|
panel_data = {
|
||||||
|
"FrameDepth": self.frame_depth[panel_i] / si_conversion,
|
||||||
|
"FrameThickness": self.frame_thickness[panel_i] / si_conversion,
|
||||||
|
}
|
||||||
|
di["panel_properties"].append(panel_data)
|
||||||
|
return di
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BIMDoorProperties:
|
||||||
|
non_si_units_props = ("is_editing", "door_type", "panel_width_ratio")
|
||||||
|
door_types = (
|
||||||
|
("SINGLE_SWING_LEFT", "SINGLE_SWING_LEFT", ""),
|
||||||
|
("SINGLE_SWING_RIGHT", "SINGLE_SWING_RIGHT", ""),
|
||||||
|
("DOUBLE_SWING_LEFT", "DOUBLE_SWING_LEFT", ""),
|
||||||
|
("DOUBLE_SWING_RIGHT", "DOUBLE_SWING_RIGHT", ""),
|
||||||
|
("DOUBLE_DOOR_SINGLE_SWING", "DOUBLE_DOOR_SINGLE_SWING", ""),
|
||||||
|
("SLIDING_TO_LEFT", "SLIDING_TO_LEFT", ""),
|
||||||
|
("SLIDING_TO_RIGHT", "SLIDING_TO_RIGHT", ""),
|
||||||
|
("DOUBLE_DOOR_SLIDING", "DOUBLE_DOOR_SLIDING", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
is_editing: bool = False
|
||||||
|
door_type: str = "SINGLE_SWING_LEFT"
|
||||||
|
overall_height: float = 2.0
|
||||||
|
overall_width: float = 0.9
|
||||||
|
|
||||||
|
# lining properties
|
||||||
|
lining_depth: float = 0.050
|
||||||
|
lining_thickness: float = 0.050
|
||||||
|
lining_offset: float = 0.0
|
||||||
|
lining_to_panel_offset_x: float = 0.025
|
||||||
|
lining_to_panel_offset_y: float = 0.025
|
||||||
|
transom_thickness: float = 0.000
|
||||||
|
transom_offset: float = 1.525
|
||||||
|
|
||||||
|
casing_thickness: float = 0.075
|
||||||
|
casing_depth: float = 0.005
|
||||||
|
|
||||||
|
threshold_thickness: float = 0.025
|
||||||
|
threshold_depth: float = 0.1
|
||||||
|
threshold_offset: float = 0.000
|
||||||
|
|
||||||
|
# panel properties
|
||||||
|
panel_depth: float = 0.035
|
||||||
|
panel_width_ratio: float = 1.0
|
||||||
|
frame_thickness: float = 0.035
|
||||||
|
frame_depth: float = 0.035
|
||||||
|
|
||||||
|
def to_dict(self, si_conversion=1.):
|
||||||
|
return {
|
||||||
|
"operation_type": self.door_type,
|
||||||
|
"overall_height": self.overall_height / si_conversion,
|
||||||
|
"overall_width": self.overall_width / si_conversion,
|
||||||
|
"lining_properties": {
|
||||||
|
"LiningDepth": self.lining_depth / si_conversion,
|
||||||
|
"LiningThickness": self.lining_thickness / si_conversion,
|
||||||
|
"LiningOffset": self.lining_offset / si_conversion,
|
||||||
|
"LiningToPanelOffsetX": self.lining_to_panel_offset_x / si_conversion,
|
||||||
|
"LiningToPanelOffsetY": self.lining_to_panel_offset_y / si_conversion,
|
||||||
|
"TransomThickness": self.transom_thickness / si_conversion,
|
||||||
|
"TransomOffset": self.transom_offset / si_conversion,
|
||||||
|
"CasingThickness": self.casing_thickness / si_conversion,
|
||||||
|
"CasingDepth": self.casing_depth / si_conversion,
|
||||||
|
"ThresholdThickness": self.threshold_thickness / si_conversion,
|
||||||
|
"ThresholdDepth": self.threshold_depth / si_conversion,
|
||||||
|
"ThresholdOffset": self.threshold_offset / si_conversion,
|
||||||
|
},
|
||||||
|
"panel_properties": {
|
||||||
|
"PanelDepth": self.panel_depth / si_conversion,
|
||||||
|
"PanelWidth": self.panel_width_ratio,
|
||||||
|
"FrameDepth": self.frame_depth / si_conversion,
|
||||||
|
"FrameThickness": self.frame_thickness / si_conversion,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
* {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
grid-template-rows: 80px 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"menu menu"
|
||||||
|
"toolbar main";
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
padding-top: 25vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body.loading .msg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1vw 5vw;
|
||||||
|
border: solid 1px #eee;
|
||||||
|
border-radius: 1vw;
|
||||||
|
}
|
||||||
|
form div {
|
||||||
|
margin-bottom: 3vw;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#status1 {
|
||||||
|
font-size: 150%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#branding {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
input[type='button'] {
|
||||||
|
padding: 8px 64px;
|
||||||
|
margin-top: 3vw;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
ul, ul li {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
ul li {
|
||||||
|
display: inline-block;
|
||||||
|
border: solid 1px #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eee;
|
||||||
|
font-size: 32px !important;
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
.tools li {
|
||||||
|
display: block;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
ul li:hover {
|
||||||
|
border-color: #aaa;
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
grid-area: menu;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
grid-area: toolbar;
|
||||||
|
}
|
||||||
|
.toolbar li.active {
|
||||||
|
border-color: #888;
|
||||||
|
background: #ccc;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
grid-area: main;
|
||||||
|
}
|
||||||
Submodule
+1
Submodule src/pyodide/demo-app/wheels added at 33b437e5fd
Reference in New Issue
Block a user