Add custom template path support for WebGL export (#21695)

* Add custom template path support for WebGL export
- Extract hardcoded template into separate file and store in Mod/BIM/Resources/templates
- Remove hardcoded template from code
- Add new section to Import-Export Preference page ("WebGL"). This introduces two new parameters
- Make export() return bool to indicate success or failure for controlled headless export
- Add new unit tests for WebGL export to ensure functionality
- Update CMakeLists.txt to include the new template resource

* Apply suggestions from code review

Changes to literals, suggested after DWG review

Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com>

* Removed unused import

* Removed unused import

---------

Co-authored-by: Max Wilfinger <6246609+maxwxyz@users.noreply.github.com>
Co-authored-by: Yorik van Havre <yorik.vanhavre@gmail.com>
This commit is contained in:
Ilya Baidakov
2025-07-15 01:25:57 +03:00
committed by GitHub
parent 905d73b00d
commit 702e16a353
11 changed files with 1252 additions and 649 deletions

View File

@@ -173,6 +173,10 @@ SET(bimcommands_SRCS
bimcommands/__init__.py
)
SET(BIM_templates
Resources/templates/webgl_export_template.html
)
SET(nativeifc_SRCS
nativeifc/ifc_commands.py
nativeifc/ifc_diff.py
@@ -223,6 +227,8 @@ SET(bimtests_SRCS
bimtests/TestArchReference.py
bimtests/TestArchSchedule.py
bimtests/TestArchTruss.py
bimtests/TestWebGLExport.py
bimtests/TestWebGLExportGui.py
)
SOURCE_GROUP("" FILES ${Arch_SRCS})
@@ -246,6 +252,7 @@ ADD_CUSTOM_TARGET(BIM ALL
${bimtests_SRCS}
${nativeifc_SRCS}
${BIMGuiIcon_SVG}
${BIM_templates}
)
ADD_CUSTOM_TARGET(ImporterPythonTestData ALL
@@ -266,6 +273,12 @@ fc_target_copy_resource(BIM
${Arch_presets}
)
fc_target_copy_resource(BIM
${CMAKE_SOURCE_DIR}/src/Mod/BIM
${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_DATADIR}/Mod/BIM
${BIM_templates}
)
fc_target_copy_resource(ImporterPythonTestData
${CMAKE_SOURCE_DIR}/src/Mod/BIM
${CMAKE_BINARY_DIR}/Mod/BIM
@@ -330,3 +343,9 @@ INSTALL(
"${CMAKE_INSTALL_DATADIR}/Mod/BIM/Resources/icons"
)
INSTALL(
FILES
${BIM_templates}
DESTINATION
"${CMAKE_INSTALL_DATADIR}/Mod/BIM/Resources/templates"
)

View File

@@ -707,6 +707,7 @@ FreeCADGui.addPreferencePage(":/ui/preferences-ifc.ui", t)
FreeCADGui.addPreferencePage(":/ui/preferences-ifc-export.ui", t)
FreeCADGui.addPreferencePage(":/ui/preferences-dae.ui", t)
FreeCADGui.addPreferencePage(":/ui/preferences-sh3d-import.ui", t)
FreeCADGui.addPreferencePage(":/ui/preferences-webgl.ui", t)
# Add unit tests
FreeCAD.__unit_test__ += ["TestArchGui"]

View File

@@ -271,6 +271,7 @@
<file>ui/preferences-ifc-export.ui</file>
<file>ui/preferences-ifc.ui</file>
<file>ui/preferences-sh3d-import.ui</file>
<file>ui/preferences-webgl.ui</file>
<file>ui/preferencesNativeIFC.ui</file>
</qresource>
</RCC>

View File

@@ -0,0 +1,652 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>$pagetitle</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<meta name="generator" content="FreeCAD $version">
<style>
* {
margin: 0;
padding: 0;
}
body {
background: #ffffff; /* Old browsers */
background: -moz-linear-gradient(top, #e3e9fc 0%, #ffffff 70%, #e2dab3 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(top, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* Chrome10-25, Safari5.1-6 */
background: linear-gradient(to bottom, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
width: 100vw;
height: 100vh;
}
canvas { display: block; }
#mainCanvas {
width: 100%;
height: 100%;
}
#arrowCanvas {
position: absolute;
left: 0px;
bottom: 0px;
width: 150px;
height: 150px;
z-index: 100;
}
select { width: 170px; }
</style>
</head>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@$threejs_version/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@$threejs_version/examples/jsm/"
}
}
</script>
<body>
<canvas id="mainCanvas"></canvas>
<canvas id="arrowCanvas"></canvas>
<script type="module">
// Direct from mrdoob: https://www.jsdelivr.com/package/npm/three
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
import { Line2 } from 'three/addons/lines/Line2.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
import { EdgeSplitModifier } from 'three/addons/modifiers/EdgeSplitModifier.js';
const data = $data;
// Z is up for FreeCAD
THREE.Object3D.DEFAULT_UP = new THREE.Vector3(0, 0, 1);
const defaultWireColor = new THREE.Color('rgb(0,0,0)');
const defaultWireLineWidth = 2; // in pixels
const raycasterObj = []; // list of obj that can mouseover highlight
const canvas = document.querySelector('#mainCanvas');
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas
}); // Clear bg so we can set it with css
renderer.setClearColor(0x000000, 0);
let renderRequested = false;
// HemisphereLight gives different colors of light from the top
// and bottom simulating reflected light from the 'ground' and
// 'sky'
scene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 0.4));
const dLight1 = new THREE.DirectionalLight(0xffffff, 0.4);
dLight1.position.set(5, -2, 3);
scene.add(dLight1);
const dLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dLight2.position.set(-5, 2, 3);
scene.add(dLight2);
if (data.compressed) {
const base = data.base;
const baseFloat = data.baseFloat;
function baseDecode(input) {
const baseCt = base.length;
const output = [];
const len = parseInt(input[0]); // num chars of each element
for (let i = 1; i < input.length; i += len) {
const str = input.substring(i, i + len).trim();
let val = 0;
for (let s = 0; s < str.length; s++) {
const ind = base.indexOf(str[s]);
val += ind * Math.pow(baseCt, s);
}
output.push(val);
}
return output;
}
function floatDecode(input) {
const baseCt = base.length;
const baseFloatCt = baseFloat.length;
let numString = '';
for (let i = 0; i < input.length; i += 4) {
const b90chunk = input.substring(i, i + 4).trim();
let quotient = 0;
for (let s = 0; s < b90chunk.length; s++) {
const ind = base.indexOf(b90chunk[s]);
quotient += ind * Math.pow(baseCt, s);
}
let buffer = '';
for (let s = 0; s < 7; s++) {
buffer = baseFloat[quotient % baseFloatCt] + buffer;
quotient = parseInt(quotient / baseFloatCt);
}
numString += buffer;
}
let trailingCommas = 0;
for (let s = 1; s < 7; s++) {
if (numString[numString.length - s] == baseFloat[0]) {
trailingCommas++;
}
}
numString = numString.substring(0, numString.length - trailingCommas);
return numString;
}
// Decode from base90 and distribute the floats
for (const obj of data.objects) {
obj.floats = JSON.parse('[' + floatDecode(obj.floats) + ']');
obj.verts = baseDecode(obj.verts).map(x => obj.floats[x]);
obj.facets = baseDecode(obj.facets);
obj.wires = obj.wires.map(w => baseDecode(w).map(x => obj.floats[x]));
obj.facesToFacets = obj.facesToFacets.map(x => baseDecode(x));
}
}
else {
for (const obj of data.objects) {
obj.verts = obj.verts.map(x => parseFloat(x));
obj.wires = obj.wires.map(w => w.map(x => parseFloat(x)))
obj.facesToFacets = obj.facesToFacets.map(w => w.map(x => parseFloat(x)));
}
}
// Get bounds for global clipping
const globalMaxMin = [{min: null, max: null},
{min: null, max: null},
{min: null, max: null}];
for (const obj of data.objects) {
for (let v = 0; v < obj.verts.length; v++) {
if (globalMaxMin[v % 3] === null
|| obj.verts[v] < globalMaxMin[v % 3].min) {
globalMaxMin[v % 3].min = obj.verts[v];
}
if (globalMaxMin[v % 3] === null
|| obj.verts[v] > globalMaxMin[v % 3].max) {
globalMaxMin[v % 3].max = obj.verts[v];
}
}
}
let bigrange = 0;
// add a little extra
for (const i of globalMaxMin) {
const range = i.max - i.min;
if (range > bigrange) {
bigrange = range;
}
i.min -= range * 0.01;
i.max += range * 0.01;
}
const camCenter = new THREE.Vector3(
0.5 * (globalMaxMin[0].max - globalMaxMin[0].min) + globalMaxMin[0].min,
0.5 * (globalMaxMin[1].max - globalMaxMin[1].min) + globalMaxMin[1].min,
0.5 * (globalMaxMin[2].max - globalMaxMin[2].min) + globalMaxMin[2].min );
const viewSize = 1.5 * bigrange; // make the view area a little bigger than the object
const aspectRatio = canvas.clientWidth / canvas.clientHeight;
const originalAspect = aspectRatio;
function initCam(camera) {
// XXX this needs to treat the perspective and orthographic
// cameras differently
camera.position.set(
data.camera.position_x,
data.camera.position_y,
data.camera.position_z);
camera.lookAt(camCenter);
camera.updateMatrixWorld();
}
let cameraType = data.camera.type;
const persCamera = new THREE.PerspectiveCamera(
50, aspectRatio, 1, 100000);
initCam(persCamera);
const orthCamera = new THREE.OrthographicCamera(
-aspectRatio * viewSize / 2, aspectRatio * viewSize / 2,
viewSize / 2, -viewSize / 2, -100000, 100000);
initCam(orthCamera);
function assignMesh(positions, color, opacity, faces) {
const baseGeometry = new THREE.BufferGeometry();
baseGeometry.setAttribute('position', new THREE.BufferAttribute(
positions, 3));
// EdgeSplitModifier is used to combine verts so that smoothing normals can be generated WITHOUT removing the hard edges of the design
// REF: https://threejs.org/examples/?q=edge#webgl_modifier_edgesplit - https://github.com/mrdoob/three.js/pull/20535
const edgeSplit = new EdgeSplitModifier();
const cutOffAngle = 20;
const geometry = edgeSplit.modify(
baseGeometry, cutOffAngle * Math.PI / 180);
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
const material = new THREE.MeshLambertMaterial({
color: color,
side: THREE.DoubleSide,
vertexColors: false,
opacity: opacity,
transparent: opacity != 1.0,
fog: false
});
const meshobj = new THREE.Mesh(geometry, material);
meshobj.name = meshobj.uuid;
faces.push(meshobj.uuid);
scene.add(meshobj);
raycasterObj.push(meshobj);
}
const objects = [];
for (const obj of data.objects) {
// Each face gets its own material because they each can
// have different colors
const faces = [];
if (obj.facesToFacets.length > 0) {
for (let f=0; f < obj.facesToFacets.length; f++) {
const facecolor = obj.faceColors.length > 0 ? obj.faceColors[f] : obj.color;
const positions = new Float32Array(obj.facesToFacets[f].length * 9);
for (let a=0; a < obj.facesToFacets[f].length; a++) {
for (let b=0; b < 3; b++) {
for (let c=0; c < 3; c++) {
positions[9 * a + 3 * b + c] = obj.verts[3 * obj.facets[3 * obj.facesToFacets[f][a] + b ] + c ];
}
}
}
assignMesh(positions, facecolor, obj.opacity, faces);
}
} else {
// No facesToFacets means that there was a tessellate()
// mismatch inside FreeCAD. Use all facets in object to
// create this mesh
const positions = new Float32Array(obj.facets.length * 3);
for (let a=0; a < obj.facets.length; a++) {
for (let b=0; b < 3; b++) {
positions[3 * a + b] = obj.verts[3 * obj.facets[a] + b];
}
}
assignMesh(positions, obj.color, obj.opacity, faces);
}
// Wires
// cannot have lines in WebGL that are wider than 1px due to browser limitations so Line2 workaround lib is used
// REF: https://threejs.org/examples/?q=fat#webgl_lines_fat - https://jsfiddle.net/brLk6aud/1/
// This material is shared by all wires in this object
const wirematerial = new LineMaterial( {
color: defaultWireColor,
linewidth: defaultWireLineWidth,
dashed: false, dashSize: 1, gapSize: 1, dashScale: 3
} );
wirematerial.resolution.set(
canvas.clientWidth * window.devicePixelRatio,
canvas.clientHeight * window.devicePixelRatio);
const wires = [];
for (const w of obj.wires) {
const wiregeometry = new LineGeometry();
wiregeometry.setPositions(w);
const wire = new Line2(wiregeometry, wirematerial);
wire.computeLineDistances();
wire.scale.set(1, 1, 1);
wire.name = wire.uuid;
scene.add(wire);
wires.push(wire.name);
}
objects.push({
data: obj,
faces: faces,
wires: wires,
wirematerial: wirematerial,
gui_link: null
});
}
// ---- GUI Init ----
const gui = new GUI({ width: 300, closeFolders: true });
const addFolder = GUI.prototype.addFolder;
GUI.prototype.addFolder = function(...args) {
return addFolder.call(this, ...args).close();
}
const guiparams = {
wiretype: 'Normal',
wirewidth: defaultWireLineWidth,
wirecolor: '#' + defaultWireColor.getHexString(),
clippingx: 100,
clippingy: 100,
clippingz: 100,
cameraType: cameraType,
navright: function() { navChange([1, 0, 0]); },
navtop: function() { navChange([0, 0, 1]); },
navfront: function() { navChange([0, -1, 0]); }
};
// ---- Wires ----
const wiretypes = { Normal: 'Normal', Dashed: 'Dashed', None: 'None' };
const wireFolder = gui.addFolder('Wire');
wireFolder.add(guiparams, 'wiretype', wiretypes).name('Wire Display').onChange(wireChange);
wireFolder.add(guiparams, 'wirewidth').min(1).max(5).step(1).name('Wire Width').onChange(wireChange);
wireFolder.addColor(guiparams, 'wirecolor').name('Wire Color').onChange(wireChange);
function wireChange() {
for (const obj of objects) {
const m = obj.wirematerial;
if (m.dashed) {
if (guiparams.wiretype != 'Dashed') {
m.dashed = false;
delete m.defines.USE_DASH;
}
} else {
if (guiparams.wiretype == 'Dashed') {
m.dashed = true;
// Dashed lines require this as of r122. delete if not dashed
m.defines.USE_DASH = ""; // https://discourse.threejs.org/t/dashed-line2-material/10825
}
}
if (guiparams.wiretype == 'None') {
m.visible = false;
} else {
if ((obj.faces.length == 0) | scene.getObjectByName(obj.faces[0]).material.visible){
m.visible = true;
}
}
m.linewidth = guiparams.wirewidth;
m.color = new THREE.Color(guiparams.wirecolor);
m.needsUpdate = true;
}
requestRender();
}
wireChange();
// ---- Clipping ----
const clippingFolder = gui.addFolder('Clipping');
clippingFolder.add(guiparams, 'clippingx').min(0).max(100).step(1).name('X-Axis Clipping').onChange(clippingChange);
clippingFolder.add(guiparams, 'clippingy').min(0).max(100).step(1).name('Y-Axis Clipping').onChange(clippingChange);
clippingFolder.add(guiparams, 'clippingz').min(0).max(100).step(1).name('Z-Axis Clipping').onChange(clippingChange);
const clipPlaneX = new THREE.Plane(new THREE.Vector3( -1, 0, 0 ), 0);
const clipPlaneY = new THREE.Plane(new THREE.Vector3( 0, -1, 0 ), 0);
const clipPlaneZ = new THREE.Plane(new THREE.Vector3( 0, 0, -1 ), 0);
function clippingChange() {
if (guiparams.clippingx < 100 || guiparams.clippingy < 100 || guiparams.clippingz < 100) {
if (renderer.clippingPlanes.length == 0) {
renderer.clippingPlanes.push(clipPlaneX, clipPlaneY, clipPlaneZ);
}
}
clipPlaneX.constant = (globalMaxMin[0].max - globalMaxMin[0].min) * guiparams.clippingx / 100.0 + globalMaxMin[0].min;
clipPlaneY.constant = (globalMaxMin[1].max - globalMaxMin[1].min) * guiparams.clippingy / 100.0 + globalMaxMin[1].min;
clipPlaneZ.constant = (globalMaxMin[2].max - globalMaxMin[2].min) * guiparams.clippingz / 100.0 + globalMaxMin[2].min;
requestRender();
}
// ---- Camera & Navigation ----
const camFolder = gui.addFolder('Camera');
const cameraTypes = { Perspective: 'Perspective', Orthographic: 'Orthographic' };
camFolder.add(guiparams, 'cameraType', cameraTypes).name('Camera type').onChange(cameraChange);
camFolder.add(guiparams, 'navright').name('View Right');
camFolder.add(guiparams, 'navtop').name('View Top');
camFolder.add(guiparams, 'navfront').name('View Front');
function navChange(v) {
const t = new THREE.Vector3();
new THREE.Box3().setFromObject(scene).getSize(t);
persControls.object.position.set(
v[0] * t.x * 2 + camCenter.x,
v[1] * t.y * 2 + camCenter.y,
v[2] * t.z * 2 + camCenter.z);
persControls.target = camCenter;
persControls.update();
orthControls.object.position.set(
v[0] * t.x + camCenter.x,
v[1] * t.y + camCenter.y,
v[2] * t.z + camCenter.z);
orthControls.target = camCenter;
orthControls.update();
// controls.update() implicitly calls requestRender()
}
function cameraChange(v) {
cameraType = v;
requestRender();
}
const guiObjects = gui.addFolder('Objects');
for (const obj of objects) {
// Ignore objects with no vertices
if (obj.data.verts.length > 0) {
const guiObjData = {
obj: obj, color: obj.data.color, opacity: obj.data.opacity, show: true };
const guiObject = guiObjects.addFolder(obj.data.name);
guiObject.addColor(guiObjData, 'color').name('Color').onChange(GUIObjectChange);
guiObject.add(guiObjData, 'opacity').min(0.0).max(1.0).step(0.05).name('Opacity').onChange(GUIObjectChange);
guiObject.add(guiObjData, 'show').onChange(GUIObjectChange).listen();
obj.gui_link = guiObjData
}
}
function GUIObjectChange(v) {
for (const f of this.object.obj.faces) {
const m = scene.getObjectByName(f).material;
if (this.property == 'color') {
m.color.setStyle(v);
}
if (this.property == 'opacity') {
m.opacity = v;
m.transparent = (v != 1.0);
}
if (this.property == 'show') {
m.visible = v
}
}
if (this.property == 'opacity') {
const m = this.object.obj.wirematerial;
m.opacity = v;
m.transparent = (v != 1.0);
}
if (this.property == 'show') {
const m = this.object.obj.wirematerial;
m.visible = v
}
requestRender();
}
// Make simple orientation arrows and box - REF: http://jsfiddle.net/b97zd1a3/16/
const arrowCanvas = document.querySelector('#arrowCanvas');
const arrowRenderer = new THREE.WebGLRenderer({
alpha: true,
canvas: arrowCanvas
}); // clear
arrowRenderer.setClearColor(0x000000, 0);
arrowRenderer.setSize(arrowCanvas.clientWidth * window.devicePixelRatio,
arrowCanvas.clientHeight * window.devicePixelRatio,
false);
const arrowScene = new THREE.Scene();
const arrowCamera = new THREE.PerspectiveCamera(
50, arrowCanvas.clientWidth / arrowCanvas.clientHeight, 1, 500 );
arrowCamera.up = persCamera.up; // important!
const arrowPos = new THREE.Vector3(0, 0, 0);
arrowScene.add(new THREE.ArrowHelper(
new THREE.Vector3(1, 0, 0), arrowPos, 60, 0x7F2020, 20, 10));
arrowScene.add(new THREE.ArrowHelper(
new THREE.Vector3(0, 1, 0), arrowPos, 60, 0x207F20, 20, 10));
arrowScene.add(new THREE.ArrowHelper(
new THREE.Vector3(0, 0, 1), arrowPos, 60, 0x20207F, 20, 10));
arrowScene.add(new THREE.Mesh(
new THREE.BoxGeometry(40, 40, 40),
new THREE.MeshLambertMaterial({ color: 0xaaaaaa })
));
arrowScene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 1.2));
// Controls
const persControls = new OrbitControls(persCamera, renderer.domElement);
persControls.target = camCenter; // rotate around center of parts
// persControls.enablePan = false;
// persControls.enableDamping = true;
persControls.update();
const orthControls = new OrbitControls(orthCamera, renderer.domElement);
orthControls.target = camCenter; // rotate around center of parts
// orthControls.enablePan = false;
// orthControls.enableDamping = true;
orthControls.update();
function render() {
renderRequested = false;
persControls.update();
if (cameraType == 'Perspective') {
arrowCamera.position.copy(persCamera.position);
arrowCamera.position.sub(persControls.target);
}
orthControls.update();
if (cameraType == 'Orthographic') {
arrowCamera.position.copy(orthCamera.position);
arrowCamera.position.sub(orthControls.target);
}
arrowCamera.lookAt(arrowScene.position);
arrowCamera.position.setLength(200);
if (cameraType == 'Perspective') {
renderer.render(scene, persCamera);
}
if (cameraType == 'Orthographic') {
renderer.render(scene, orthCamera);
}
arrowRenderer.render(arrowScene, arrowCamera);
};
function requestRender() {
if (!renderRequested) {
renderRequested = true;
requestAnimationFrame(render);
}
}
persControls.addEventListener('change', requestRender);
orthControls.addEventListener('change', requestRender);
renderer.domElement.addEventListener('mousemove', onMouseMove);
renderer.domElement.addEventListener('dblclick', onMouseDblClick);
window.addEventListener('resize', onMainCanvasResize, false);
onMainCanvasResize();
requestRender();
function onMainCanvasResize() {
const pixelRatio = window.devicePixelRatio;
const width = canvas.clientWidth * pixelRatio | 0;
const height = canvas.clientHeight * pixelRatio | 0;
const needResize = canvas.width !== width || canvas.height !== height;
const aspect = canvas.clientWidth / canvas.clientHeight;
if (needResize) {
renderer.setSize(width, height, false);
// See https://stackoverflow.com/questions/39373113/three-js-resize-window-not-scaling-properly
const change = originalAspect / aspect;
const newSize = viewSize * change;
orthCamera.left = -aspect * newSize / 2;
orthCamera.right = aspect * newSize / 2;
orthCamera.top = newSize / 2;
orthCamera.bottom = -newSize / 2;
orthCamera.updateProjectionMatrix();
persCamera.aspect = canvas.clientWidth / canvas.clientHeight;
persCamera.updateProjectionMatrix();
}
for (const obj of objects) {
obj.wirematerial.resolution.set(width, height);
}
requestRender();
}
// Use mouse double click to toggle the gui for the selected object
function onMouseDblClick(e){
let c = false;
if (cameraType == 'Orthographic') {
c = orthCamera;
}
if (cameraType == 'Perspective') {
c = persCamera;
}
if (!c) {
return;
}
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(
(e.clientX / canvas.clientWidth) * 2 - 1,
-(e.clientY / canvas.clientHeight) * 2 + 1),
c);
const intersects = raycaster.intersectObjects(raycasterObj);
for (const i of intersects) {
const m = i.object;
if (!m.material.visible){continue};
for (const obj of objects) {
for (const face_uuid of obj.faces) {
if (face_uuid == m.uuid) {
obj.gui_link.show = false
obj.wirematerial.visible = false
for (const face of obj.faces) {
scene.getObjectByName(face).material.visible = false
}
requestRender();
return
}
}
}
}
}
function onMouseMove(e) {
let c = false;
if (cameraType == 'Orthographic') {
c = orthCamera;
}
if (cameraType == 'Perspective') {
c = persCamera;
}
if (!c) {
return;
}
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(
(e.clientX / canvas.clientWidth) * 2 - 1,
-(e.clientY / canvas.clientHeight) * 2 + 1),
c);
const intersects = raycaster.intersectObjects(raycasterObj);
let chosen = '';
for (const i of intersects) {
const m = i.object.material;
if ((m.opacity > 0) & m.visible) {
if (m.emissive.getHex() == 0x000000) {
m.emissive.setHex( 0x777777 );
m.needsUpdate = true;
requestRender();
}
chosen = i.object.name;
break;
}
}
for (const r of raycasterObj) {
if (r.name == chosen) {
continue;
}
if (r.material.emissive.getHex() != 0x000000) {
r.material.emissive.setHex(0x000000);
r.material.needsUpdate = true;
requestRender();
}
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Gui::Dialog::DlgSettingsArch</class>
<widget class="QWidget" name="Gui::Dialog::DlgSettingsArch">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>555</width>
<height>729</height>
</rect>
</property>
<property name="windowTitle">
<string>WebGL</string>
</property>
<layout class="QVBoxLayout">
<property name="spacing">
<number>6</number>
</property>
<property name="margin">
<number>9</number>
</property>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Export Options</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="Gui::PrefCheckBox" name="checkBox_useCustomWebGLTemplate">
<property name="toolTip">
<string>A custom WebGL HTML template is used for export. Otherwise, the default template will be used.
The default template is located at:
&lt;FreeCAD installation directory&gt;/Resources/Mod/BIM/templates/webgl_export_template.html </string>
</property>
<property name="text">
<string>Use custom export template</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>useCustomWebGLExportTemplate</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Mod/BIM</cstring>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_4">
<item>
<widget class="QLabel" name="label_4">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Path to template</string>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefFileChooser" name="gui::preffilechooser_2">
<property name="enabled">
<bool>false</bool>
</property>
<property name="toolTip">
<string>The path to the custom WebGL HTML template</string>
</property>
<property name="prefEntry" stdset="0">
<cstring>WebGLTemplateCustomPath</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Mod/BIM</cstring>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<layoutdefault spacing="6" margin="11"/>
<customwidgets>
<customwidget>
<class>Gui::FileChooser</class>
<extends>QWidget</extends>
<header>Gui/FileDialog.h</header>
</customwidget>
<customwidget>
<class>Gui::PrefFileChooser</class>
<extends>Gui::FileChooser</extends>
<header>Gui/PrefWidgets.h</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>checkBox_useCustomWebGLTemplate</sender>
<signal>toggled(bool)</signal>
<receiver>label_4</receiver>
<slot>setEnabled(bool)</slot>
</connection>
<connection>
<sender>checkBox_useCustomWebGLTemplate</sender>
<signal>toggled(bool)</signal>
<receiver>gui::preffilechooser_2</receiver>
<slot>setEnabled(bool)</slot>
</connection>
</connections>
</ui>

View File

@@ -47,4 +47,4 @@ from bimtests.TestArchReference import TestArchReference
from bimtests.TestArchSchedule import TestArchSchedule
from bimtests.TestArchTruss import TestArchTruss
from bimtests.TestArchComponent import TestArchComponent
from bimtests.TestWebGLExport import TestWebGLExport

View File

@@ -42,6 +42,8 @@ from draftutils.messages import _msg
if App.GuiUp:
import FreeCADGui
from bimtests.TestWebGLExportGui import TestWebGLExportGui
class ArchTest(unittest.TestCase):
def setUp(self):

View File

@@ -0,0 +1,140 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 baidakovil <baidakovil@icloud.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD 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 *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Unit tests for WebGL export functionality. Gui tests are in `TestWebGLExportGui.py`.
"""
import os
import tempfile
import unittest
from unittest.mock import patch, mock_open
from BIM.importers import importWebGL
from .TestArchBase import TestArchBase
class TestWebGLExport(TestArchBase):
def setUp(self):
"""Using TestArchBase setUp to initialize the document for convenience,
but also create a temporary directory for tests."""
super().setUp()
self.test_dir = tempfile.mkdtemp()
self.test_template_content = """
<!DOCTYPE html>
<html>
<head><title>$pagetitle</title></head>
<body>WebGL Content: $data</body>
</html>
"""
def tearDown(self):
import shutil
shutil.rmtree(self.test_dir, ignore_errors=True)
super().tearDown()
def test_actual_default_template_readable_and_valid(self):
"""Test that the actual default template can be read and contains
required placeholders"""
operation = "Testing actual default template reading and validation"
self.printTestMessage(operation)
# Test with real configuration - no mocks
with patch(
"BIM.importers.importWebGL.params.get_param", return_value=False
): # Disable custom template
result = importWebGL.getHTMLTemplate()
if result is not None: # Only test if template exists
self.assertIsInstance(result, str)
# Check for basic HTML structure
self.assertIn("<html", result.lower())
self.assertIn("$pagetitle", result.lower())
self.assertIn("$data", result.lower())
def test_custom_template_with_real_file(self):
"""Test custom template functionality with real temporary file"""
operation = "Testing custom template with real file"
self.printTestMessage(operation)
# Create real temporary template file
custom_path = os.path.join(self.test_dir, "custom_template.html")
with open(custom_path, "w", encoding="utf-8") as f:
f.write(self.test_template_content)
with patch(
"BIM.importers.importWebGL.params.get_param"
) as mock_params:
mock_params.side_effect = lambda param, path=None: {
"useCustomWebGLExportTemplate": True,
"WebGLTemplateCustomPath": custom_path,
}.get(param, False)
result = importWebGL.getHTMLTemplate()
self.assertIsNotNone(result)
self.assertEqual(
result.strip(), self.test_template_content.strip()
)
def test_default_template_logic_when_custom_disabled(self):
"""Test code path when custom template is disabled"""
operation = "Testing default template logic when custom is disabled"
self.printTestMessage(operation)
with patch(
"BIM.importers.importWebGL.params.get_param", return_value=False
):
with patch("os.path.isfile", return_value=True):
with patch(
"builtins.open",
mock_open(read_data=self.test_template_content),
):
result = importWebGL.getHTMLTemplate()
self.assertIsNotNone(result)
if result is not None:
self.assertIn("WebGL Content", result)
def test_custom_template_not_found_headless_mode(self):
"""Test behavior when custom template not found in headless mode"""
operation = "Testing custom template not found in headless mode"
self.printTestMessage(operation)
with patch(
"BIM.importers.importWebGL.params.get_param"
) as mock_params:
mock_params.side_effect = lambda param, path=None: {
"useCustomWebGLExportTemplate": True,
"WebGLTemplateCustomPath": "/nonexistent/template.html",
}.get(param, False)
with patch(
"BIM.importers.importWebGL.FreeCADGui", None
): # Simulate headless mode
result = importWebGL.getHTMLTemplate()
self.assertIsNone(result)
if __name__ == "__main__":
# Allow running tests directly
unittest.main(verbosity=2)

View File

@@ -0,0 +1,181 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2025 baidakovil <baidakovil@icloud.com> *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD 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 *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""
Unit tests for Gui WebGL export functionality. Tests both the template handling and
the main export function. Non-gui tests are in `TestWebGLExport.py`.
"""
import os
import tempfile
import unittest
from unittest.mock import patch, MagicMock, mock_open
from BIM.importers import importWebGL
from .TestArchBase import TestArchBase
class TestWebGLExportGui(TestArchBase):
def setUp(self):
"""Using TestArchBase setUp to initialize the document for convenience,
but also create a temporary directory for tests."""
super().setUp()
self.test_dir = tempfile.mkdtemp()
self.test_template_content = """
<!DOCTYPE html>
<html>
<head><title>$pagetitle</title></head>
<body>WebGL Content: $data</body>
</html>
"""
def tearDown(self):
import shutil
shutil.rmtree(self.test_dir, ignore_errors=True)
super().tearDown()
@patch("BIM.importers.importWebGL.FreeCADGui")
def test_custom_template_not_found_gui_user_accepts_fallback(
self, mock_gui
):
"""Test GUI dialog when custom template not found -
user accepts fallback"""
operation = "Testing GUI custom template not found - user accepts fallback"
self.printTestMessage(operation)
from PySide import QtWidgets
mock_gui.getMainWindow.return_value = MagicMock()
with patch(
"BIM.importers.importWebGL.params.get_param"
) as mock_params:
mock_params.side_effect = lambda param, path=None: {
"useCustomWebGLExportTemplate": True,
"WebGLTemplateCustomPath": "/nonexistent/template.html",
}.get(param, False)
with patch(
"PySide.QtWidgets.QMessageBox.question",
return_value=QtWidgets.QMessageBox.Yes,
):
with patch("os.path.isfile", return_value=True):
with patch(
"builtins.open",
mock_open(read_data=self.test_template_content),
):
result = importWebGL.getHTMLTemplate()
self.assertIsNotNone(result)
@patch("BIM.importers.importWebGL.FreeCADGui")
def test_custom_template_not_found_gui_user_rejects_fallback(
self, mock_gui
):
"""Test GUI dialog when custom template not found -
user rejects fallback"""
operation = "Testing GUI custom template not found - user rejects fallback"
self.printTestMessage(operation)
from PySide import QtWidgets
mock_gui.getMainWindow.return_value = MagicMock()
with patch(
"BIM.importers.importWebGL.params.get_param"
) as mock_params:
mock_params.side_effect = lambda param, path=None: {
"useCustomWebGLExportTemplate": True,
"WebGLTemplateCustomPath": "/nonexistent/template.html",
}.get(param, False)
with patch(
"PySide.QtWidgets.QMessageBox.question",
return_value=QtWidgets.QMessageBox.No,
):
result = importWebGL.getHTMLTemplate()
self.assertIsNone(result)
def test_export_returns_false_when_no_template(self):
"""Test that export function returns False when
no template is available"""
operation = "Testing export returns False when no template available"
self.printTestMessage(operation)
with patch(
"BIM.importers.importWebGL.getHTMLTemplate", return_value=None
):
with patch("BIM.importers.importWebGL.FreeCADGui") as mock_gui:
# Mock the GUI components that might be accessed
mock_active_doc = MagicMock()
mock_active_doc.ActiveView = MagicMock()
mock_gui.ActiveDocument = mock_active_doc
result = importWebGL.export(
[], os.path.join(self.test_dir, "test.html")
)
self.assertFalse(result)
def test_export_returns_true_when_template_available(self):
"""Test that export function returns True when template is available"""
operation = "Testing export returns True when template available"
self.printTestMessage(operation)
mock_template = """<html><body>
$pagetitle $version $data $threejs_version
</body></html>"""
with patch(
"BIM.importers.importWebGL.getHTMLTemplate",
return_value=mock_template,
):
with patch(
"BIM.importers.importWebGL.FreeCAD.ActiveDocument"
) as mock_doc:
mock_doc.Label = "Test Document"
with patch("BIM.importers.importWebGL.FreeCADGui") as mock_gui:
# Mock the GUI components that might be accessed
mock_active_doc = MagicMock()
mock_active_doc.ActiveView = MagicMock()
mock_gui.ActiveDocument = mock_active_doc
# Mock the functions that populate data to return JSON-serializable values
with patch(
"BIM.importers.importWebGL.populate_camera"
) as mock_populate_camera:
with patch(
"BIM.importers.importWebGL.Draft.get_group_contents",
return_value=[],
):
mock_populate_camera.return_value = (
None # Modifies data dict in place
)
result = importWebGL.export(
[], os.path.join(self.test_dir, "test.html")
)
self.assertTrue(result)
if __name__ == "__main__":
# Allow running tests directly
unittest.main(verbosity=2)

View File

@@ -4,6 +4,7 @@
# * *
# * Copyright (c) 2013 Yorik van Havre <yorik@uncreated.net> *
# * Copyright (c) 2020 Travis Apple <travisapple@gmail.com> *
# * Copyright (c) 2025 baidakovil <baidakovil@icloud.com> *
# * *
# * This file is part of FreeCAD. *
# * *
@@ -44,13 +45,16 @@
#
# This module provides tools to export HTML files containing the
# exported objects in WebGL format and a simple three.js-based viewer.
# Tests are provided in src/Mod/BIM/bimtests/TestWebGLExport.py.
# The template is provided in src/Mod/BIM/Resources/templates/webgl_export_template.html.
"""FreeCAD WebGL Exporter"""
import json
import os
import textwrap
from builtins import open as pyopen
from typing import NotRequired, TypedDict
from typing_extensions import NotRequired, TypedDict
import numpy as np
@@ -59,9 +63,11 @@ import Draft
import Mesh
import OfflineRenderingUtils
import Part
from draftutils import params
if FreeCAD.GuiUp:
import FreeCADGui
from PySide import QtWidgets
from draftutils.translate import translate
else:
FreeCADGui = None
@@ -77,666 +83,137 @@ threejs_version = "0.172.0"
def getHTMLTemplate():
return textwrap.dedent("""\
<!DOCTYPE html>
<html lang="en">
<head>
<title>$pagetitle</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<meta name="generator" content="FreeCAD $version">
<style>
* {
margin: 0;
padding: 0;
}
body {
background: #ffffff; /* Old browsers */
background: -moz-linear-gradient(top, #e3e9fc 0%, #ffffff 70%, #e2dab3 100%); /* FF3.6-15 */
background: -webkit-linear-gradient(top, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* Chrome10-25, Safari5.1-6 */
background: linear-gradient(to bottom, #e3e9fc 0%,#ffffff 70%,#e2dab3 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
width: 100vw;
height: 100vh;
}
canvas { display: block; }
#mainCanvas {
width: 100%;
height: 100%;
}
#arrowCanvas {
position: absolute;
left: 0px;
bottom: 0px;
width: 150px;
height: 150px;
z-index: 100;
}
select { width: 170px; }
</style>
</head>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@$threejs_version/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@$threejs_version/examples/jsm/"
}
}
</script>
<body>
<canvas id="mainCanvas"></canvas>
<canvas id="arrowCanvas"></canvas>
<script type="module">
// Direct from mrdoob: https://www.jsdelivr.com/package/npm/three
import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
import { Line2 } from 'three/addons/lines/Line2.js';
import { LineMaterial } from 'three/addons/lines/LineMaterial.js';
import { LineGeometry } from 'three/addons/lines/LineGeometry.js';
import { EdgeSplitModifier } from 'three/addons/modifiers/EdgeSplitModifier.js';
"""Returns the HTML template from external file.
The custom template path can be set in the Preferences.
Returns None if no valid template is found.
"""
const data = $data;
def try_read_template(path, description):
"""Helper function to safely read a template file."""
if not os.path.isfile(path):
FreeCAD.Console.PrintWarning(
f"{description.capitalize()} file '{path}' does not "
"exist or is not a file.\n"
)
return None
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Failed to read {description} file '{path}' "
f"due to: {str(e)}\n"
)
return None
// Z is up for FreeCAD
THREE.Object3D.DEFAULT_UP = new THREE.Vector3(0, 0, 1);
using_custom_template = params.get_param(
"useCustomWebGLExportTemplate", path="Mod/BIM"
)
const defaultWireColor = new THREE.Color('rgb(0,0,0)');
const defaultWireLineWidth = 2; // in pixels
# Try to use custom template if enabled
if using_custom_template:
custom_path = params.get_param(
"WebGLTemplateCustomPath", path="Mod/BIM"
)
custom_content = try_read_template(
custom_path, "custom WebGL template"
)
if custom_content:
FreeCAD.Console.PrintMessage(
f"Using custom template file '{custom_path}'.\n"
)
return custom_content
else:
# Custom template failed - ask user or auto-fallback
if not FreeCADGui:
# In non-GUI mode, cancel export when custom template fails
FreeCAD.Console.PrintError(
f"Export cancelled: Custom template '{custom_path}' "
"not available.\n"
)
return None
const raycasterObj = []; // list of obj that can mouseover highlight
# In GUI mode, ask the user
message = translate(
"BIM",
"Custom WebGL template file '{}' could not be read.\n\n"
"Do you want to proceed using the default template?",
).format(custom_path)
const canvas = document.querySelector('#mainCanvas');
reply = QtWidgets.QMessageBox.question(
FreeCADGui.getMainWindow(),
translate("BIM", "WebGL Template Not Found"),
message,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.Yes,
)
const scene = new THREE.Scene();
if reply != QtWidgets.QMessageBox.Yes:
# User chose not to proceed - return None to indicate failure
FreeCAD.Console.PrintError(
f"Export cancelled: Custom template '{custom_path}' "
"not available.\n"
)
return None
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: canvas
}); // Clear bg so we can set it with css
renderer.setClearColor(0x000000, 0);
# Try to use default template
default_template_path = os.path.join(
FreeCAD.getResourceDir(),
"Mod",
"BIM",
"Resources",
"templates",
"webgl_export_template.html",
)
default_content = try_read_template(
default_template_path, "default WebGL template"
)
if default_content:
FreeCAD.Console.PrintMessage(
f"Using default template file '{default_template_path}'.\n"
)
return default_content
let renderRequested = false;
# No template available - export cannot proceed
if FreeCADGui:
# In GUI mode, show Qt error dialog
message = translate(
"BIM",
"The default WebGL export template is not available at path:"
" {}\n\nPlease check your FreeCAD installation or provide a "
"custom template under menu Preferences -> Import-Export -> WebGL.",
).format(default_template_path)
// HemisphereLight gives different colors of light from the top
// and bottom simulating reflected light from the 'ground' and
// 'sky'
scene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 0.4));
# Use getMainWindow() as parent following FreeCAD patterns
parent = FreeCADGui.getMainWindow()
title = translate("BIM", "WebGL Export Template Error")
const dLight1 = new THREE.DirectionalLight(0xffffff, 0.4);
dLight1.position.set(5, -2, 3);
scene.add(dLight1);
const dLight2 = new THREE.DirectionalLight(0xffffff, 0.4);
dLight2.position.set(-5, 2, 3);
scene.add(dLight2);
QtWidgets.QMessageBox.critical(parent, title, message)
else:
# In headless mode, print to console
FreeCAD.Console.PrintError(
"Default WebGL export template not available at"
f"path: {default_template_path}\n"
)
if (data.compressed) {
const base = data.base;
const baseFloat = data.baseFloat;
function baseDecode(input) {
const baseCt = base.length;
const output = [];
const len = parseInt(input[0]); // num chars of each element
for (let i = 1; i < input.length; i += len) {
const str = input.substring(i, i + len).trim();
let val = 0;
for (let s = 0; s < str.length; s++) {
const ind = base.indexOf(str[s]);
val += ind * Math.pow(baseCt, s);
}
output.push(val);
}
return output;
}
function floatDecode(input) {
const baseCt = base.length;
const baseFloatCt = baseFloat.length;
let numString = '';
for (let i = 0; i < input.length; i += 4) {
const b90chunk = input.substring(i, i + 4).trim();
let quotient = 0;
for (let s = 0; s < b90chunk.length; s++) {
const ind = base.indexOf(b90chunk[s]);
quotient += ind * Math.pow(baseCt, s);
}
let buffer = '';
for (let s = 0; s < 7; s++) {
buffer = baseFloat[quotient % baseFloatCt] + buffer;
quotient = parseInt(quotient / baseFloatCt);
}
numString += buffer;
}
let trailingCommas = 0;
for (let s = 1; s < 7; s++) {
if (numString[numString.length - s] == baseFloat[0]) {
trailingCommas++;
}
}
numString = numString.substring(0, numString.length - trailingCommas);
return numString;
}
// Decode from base90 and distribute the floats
for (const obj of data.objects) {
obj.floats = JSON.parse('[' + floatDecode(obj.floats) + ']');
obj.verts = baseDecode(obj.verts).map(x => obj.floats[x]);
obj.facets = baseDecode(obj.facets);
obj.wires = obj.wires.map(w => baseDecode(w).map(x => obj.floats[x]));
obj.facesToFacets = obj.facesToFacets.map(x => baseDecode(x));
}
}
else {
for (const obj of data.objects) {
obj.verts = obj.verts.map(x => parseFloat(x));
obj.wires = obj.wires.map(w => w.map(x => parseFloat(x)))
obj.facesToFacets = obj.facesToFacets.map(w => w.map(x => parseFloat(x)));
}
}
// Get bounds for global clipping
const globalMaxMin = [{min: null, max: null},
{min: null, max: null},
{min: null, max: null}];
for (const obj of data.objects) {
for (let v = 0; v < obj.verts.length; v++) {
if (globalMaxMin[v % 3] === null
|| obj.verts[v] < globalMaxMin[v % 3].min) {
globalMaxMin[v % 3].min = obj.verts[v];
}
if (globalMaxMin[v % 3] === null
|| obj.verts[v] > globalMaxMin[v % 3].max) {
globalMaxMin[v % 3].max = obj.verts[v];
}
}
}
let bigrange = 0;
// add a little extra
for (const i of globalMaxMin) {
const range = i.max - i.min;
if (range > bigrange) {
bigrange = range;
}
i.min -= range * 0.01;
i.max += range * 0.01;
}
const camCenter = new THREE.Vector3(
0.5 * (globalMaxMin[0].max - globalMaxMin[0].min) + globalMaxMin[0].min,
0.5 * (globalMaxMin[1].max - globalMaxMin[1].min) + globalMaxMin[1].min,
0.5 * (globalMaxMin[2].max - globalMaxMin[2].min) + globalMaxMin[2].min );
const viewSize = 1.5 * bigrange; // make the view area a little bigger than the object
const aspectRatio = canvas.clientWidth / canvas.clientHeight;
const originalAspect = aspectRatio;
function initCam(camera) {
// XXX this needs to treat the perspective and orthographic
// cameras differently
camera.position.set(
data.camera.position_x,
data.camera.position_y,
data.camera.position_z);
camera.lookAt(camCenter);
camera.updateMatrixWorld();
}
let cameraType = data.camera.type;
const persCamera = new THREE.PerspectiveCamera(
50, aspectRatio, 1, 100000);
initCam(persCamera);
const orthCamera = new THREE.OrthographicCamera(
-aspectRatio * viewSize / 2, aspectRatio * viewSize / 2,
viewSize / 2, -viewSize / 2, -100000, 100000);
initCam(orthCamera);
function assignMesh(positions, color, opacity, faces) {
const baseGeometry = new THREE.BufferGeometry();
baseGeometry.setAttribute('position', new THREE.BufferAttribute(
positions, 3));
// EdgeSplitModifier is used to combine verts so that smoothing normals can be generated WITHOUT removing the hard edges of the design
// REF: https://threejs.org/examples/?q=edge#webgl_modifier_edgesplit - https://github.com/mrdoob/three.js/pull/20535
const edgeSplit = new EdgeSplitModifier();
const cutOffAngle = 20;
const geometry = edgeSplit.modify(
baseGeometry, cutOffAngle * Math.PI / 180);
geometry.computeVertexNormals();
geometry.computeBoundingSphere();
const material = new THREE.MeshLambertMaterial({
color: color,
side: THREE.DoubleSide,
vertexColors: false,
opacity: opacity,
transparent: opacity != 1.0,
fog: false
});
const meshobj = new THREE.Mesh(geometry, material);
meshobj.name = meshobj.uuid;
faces.push(meshobj.uuid);
scene.add(meshobj);
raycasterObj.push(meshobj);
}
const objects = [];
for (const obj of data.objects) {
// Each face gets its own material because they each can
// have different colors
const faces = [];
if (obj.facesToFacets.length > 0) {
for (let f=0; f < obj.facesToFacets.length; f++) {
const facecolor = obj.faceColors.length > 0 ? obj.faceColors[f] : obj.color;
const positions = new Float32Array(obj.facesToFacets[f].length * 9);
for (let a=0; a < obj.facesToFacets[f].length; a++) {
for (let b=0; b < 3; b++) {
for (let c=0; c < 3; c++) {
positions[9 * a + 3 * b + c] = obj.verts[3 * obj.facets[3 * obj.facesToFacets[f][a] + b ] + c ];
}
}
}
assignMesh(positions, facecolor, obj.opacity, faces);
}
} else {
// No facesToFacets means that there was a tessellate()
// mismatch inside FreeCAD. Use all facets in object to
// create this mesh
const positions = new Float32Array(obj.facets.length * 3);
for (let a=0; a < obj.facets.length; a++) {
for (let b=0; b < 3; b++) {
positions[3 * a + b] = obj.verts[3 * obj.facets[a] + b];
}
}
assignMesh(positions, obj.color, obj.opacity, faces);
}
// Wires
// cannot have lines in WebGL that are wider than 1px due to browser limitations so Line2 workaround lib is used
// REF: https://threejs.org/examples/?q=fat#webgl_lines_fat - https://jsfiddle.net/brLk6aud/1/
// This material is shared by all wires in this object
const wirematerial = new LineMaterial( {
color: defaultWireColor,
linewidth: defaultWireLineWidth,
dashed: false, dashSize: 1, gapSize: 1, dashScale: 3
} );
wirematerial.resolution.set(
canvas.clientWidth * window.devicePixelRatio,
canvas.clientHeight * window.devicePixelRatio);
const wires = [];
for (const w of obj.wires) {
const wiregeometry = new LineGeometry();
wiregeometry.setPositions(w);
const wire = new Line2(wiregeometry, wirematerial);
wire.computeLineDistances();
wire.scale.set(1, 1, 1);
wire.name = wire.uuid;
scene.add(wire);
wires.push(wire.name);
}
objects.push({
data: obj,
faces: faces,
wires: wires,
wirematerial: wirematerial,
gui_link: null
});
}
// ---- GUI Init ----
const gui = new GUI({ width: 300, closeFolders: true });
const addFolder = GUI.prototype.addFolder;
GUI.prototype.addFolder = function(...args) {
return addFolder.call(this, ...args).close();
}
const guiparams = {
wiretype: 'Normal',
wirewidth: defaultWireLineWidth,
wirecolor: '#' + defaultWireColor.getHexString(),
clippingx: 100,
clippingy: 100,
clippingz: 100,
cameraType: cameraType,
navright: function() { navChange([1, 0, 0]); },
navtop: function() { navChange([0, 0, 1]); },
navfront: function() { navChange([0, -1, 0]); }
};
// ---- Wires ----
const wiretypes = { Normal: 'Normal', Dashed: 'Dashed', None: 'None' };
const wireFolder = gui.addFolder('Wire');
wireFolder.add(guiparams, 'wiretype', wiretypes).name('Wire Display').onChange(wireChange);
wireFolder.add(guiparams, 'wirewidth').min(1).max(5).step(1).name('Wire Width').onChange(wireChange);
wireFolder.addColor(guiparams, 'wirecolor').name('Wire Color').onChange(wireChange);
function wireChange() {
for (const obj of objects) {
const m = obj.wirematerial;
if (m.dashed) {
if (guiparams.wiretype != 'Dashed') {
m.dashed = false;
delete m.defines.USE_DASH;
}
} else {
if (guiparams.wiretype == 'Dashed') {
m.dashed = true;
// Dashed lines require this as of r122. delete if not dashed
m.defines.USE_DASH = ""; // https://discourse.threejs.org/t/dashed-line2-material/10825
}
}
if (guiparams.wiretype == 'None') {
m.visible = false;
} else {
if ((obj.faces.length == 0) | scene.getObjectByName(obj.faces[0]).material.visible){
m.visible = true;
}
}
m.linewidth = guiparams.wirewidth;
m.color = new THREE.Color(guiparams.wirecolor);
m.needsUpdate = true;
}
requestRender();
}
wireChange();
// ---- Clipping ----
const clippingFolder = gui.addFolder('Clipping');
clippingFolder.add(guiparams, 'clippingx').min(0).max(100).step(1).name('X-Axis Clipping').onChange(clippingChange);
clippingFolder.add(guiparams, 'clippingy').min(0).max(100).step(1).name('Y-Axis Clipping').onChange(clippingChange);
clippingFolder.add(guiparams, 'clippingz').min(0).max(100).step(1).name('Z-Axis Clipping').onChange(clippingChange);
const clipPlaneX = new THREE.Plane(new THREE.Vector3( -1, 0, 0 ), 0);
const clipPlaneY = new THREE.Plane(new THREE.Vector3( 0, -1, 0 ), 0);
const clipPlaneZ = new THREE.Plane(new THREE.Vector3( 0, 0, -1 ), 0);
function clippingChange() {
if (guiparams.clippingx < 100 || guiparams.clippingy < 100 || guiparams.clippingz < 100) {
if (renderer.clippingPlanes.length == 0) {
renderer.clippingPlanes.push(clipPlaneX, clipPlaneY, clipPlaneZ);
}
}
clipPlaneX.constant = (globalMaxMin[0].max - globalMaxMin[0].min) * guiparams.clippingx / 100.0 + globalMaxMin[0].min;
clipPlaneY.constant = (globalMaxMin[1].max - globalMaxMin[1].min) * guiparams.clippingy / 100.0 + globalMaxMin[1].min;
clipPlaneZ.constant = (globalMaxMin[2].max - globalMaxMin[2].min) * guiparams.clippingz / 100.0 + globalMaxMin[2].min;
requestRender();
}
// ---- Camera & Navigation ----
const camFolder = gui.addFolder('Camera');
const cameraTypes = { Perspective: 'Perspective', Orthographic: 'Orthographic' };
camFolder.add(guiparams, 'cameraType', cameraTypes).name('Camera type').onChange(cameraChange);
camFolder.add(guiparams, 'navright').name('View Right');
camFolder.add(guiparams, 'navtop').name('View Top');
camFolder.add(guiparams, 'navfront').name('View Front');
function navChange(v) {
const t = new THREE.Vector3();
new THREE.Box3().setFromObject(scene).getSize(t);
persControls.object.position.set(
v[0] * t.x * 2 + camCenter.x,
v[1] * t.y * 2 + camCenter.y,
v[2] * t.z * 2 + camCenter.z);
persControls.target = camCenter;
persControls.update();
orthControls.object.position.set(
v[0] * t.x + camCenter.x,
v[1] * t.y + camCenter.y,
v[2] * t.z + camCenter.z);
orthControls.target = camCenter;
orthControls.update();
// controls.update() implicitly calls requestRender()
}
function cameraChange(v) {
cameraType = v;
requestRender();
}
const guiObjects = gui.addFolder('Objects');
for (const obj of objects) {
// Ignore objects with no vertices
if (obj.data.verts.length > 0) {
const guiObjData = {
obj: obj, color: obj.data.color, opacity: obj.data.opacity, show: true };
const guiObject = guiObjects.addFolder(obj.data.name);
guiObject.addColor(guiObjData, 'color').name('Color').onChange(GUIObjectChange);
guiObject.add(guiObjData, 'opacity').min(0.0).max(1.0).step(0.05).name('Opacity').onChange(GUIObjectChange);
guiObject.add(guiObjData, 'show').onChange(GUIObjectChange).listen();
obj.gui_link = guiObjData
}
}
function GUIObjectChange(v) {
for (const f of this.object.obj.faces) {
const m = scene.getObjectByName(f).material;
if (this.property == 'color') {
m.color.setStyle(v);
}
if (this.property == 'opacity') {
m.opacity = v;
m.transparent = (v != 1.0);
}
if (this.property == 'show') {
m.visible = v
}
}
if (this.property == 'opacity') {
const m = this.object.obj.wirematerial;
m.opacity = v;
m.transparent = (v != 1.0);
}
if (this.property == 'show') {
const m = this.object.obj.wirematerial;
m.visible = v
}
requestRender();
}
// Make simple orientation arrows and box - REF: http://jsfiddle.net/b97zd1a3/16/
const arrowCanvas = document.querySelector('#arrowCanvas');
const arrowRenderer = new THREE.WebGLRenderer({
alpha: true,
canvas: arrowCanvas
}); // clear
arrowRenderer.setClearColor(0x000000, 0);
arrowRenderer.setSize(arrowCanvas.clientWidth * window.devicePixelRatio,
arrowCanvas.clientHeight * window.devicePixelRatio,
false);
const arrowScene = new THREE.Scene();
const arrowCamera = new THREE.PerspectiveCamera(
50, arrowCanvas.clientWidth / arrowCanvas.clientHeight, 1, 500 );
arrowCamera.up = persCamera.up; // important!
const arrowPos = new THREE.Vector3(0, 0, 0);
arrowScene.add(new THREE.ArrowHelper(
new THREE.Vector3(1, 0, 0), arrowPos, 60, 0x7F2020, 20, 10));
arrowScene.add(new THREE.ArrowHelper(
new THREE.Vector3(0, 1, 0), arrowPos, 60, 0x207F20, 20, 10));
arrowScene.add(new THREE.ArrowHelper(
new THREE.Vector3(0, 0, 1), arrowPos, 60, 0x20207F, 20, 10));
arrowScene.add(new THREE.Mesh(
new THREE.BoxGeometry(40, 40, 40),
new THREE.MeshLambertMaterial({ color: 0xaaaaaa })
));
arrowScene.add(new THREE.HemisphereLight(0xC7E8FF, 0xFFE3B3, 1.2));
// Controls
const persControls = new OrbitControls(persCamera, renderer.domElement);
persControls.target = camCenter; // rotate around center of parts
// persControls.enablePan = false;
// persControls.enableDamping = true;
persControls.update();
const orthControls = new OrbitControls(orthCamera, renderer.domElement);
orthControls.target = camCenter; // rotate around center of parts
// orthControls.enablePan = false;
// orthControls.enableDamping = true;
orthControls.update();
function render() {
renderRequested = false;
persControls.update();
if (cameraType == 'Perspective') {
arrowCamera.position.copy(persCamera.position);
arrowCamera.position.sub(persControls.target);
}
orthControls.update();
if (cameraType == 'Orthographic') {
arrowCamera.position.copy(orthCamera.position);
arrowCamera.position.sub(orthControls.target);
}
arrowCamera.lookAt(arrowScene.position);
arrowCamera.position.setLength(200);
if (cameraType == 'Perspective') {
renderer.render(scene, persCamera);
}
if (cameraType == 'Orthographic') {
renderer.render(scene, orthCamera);
}
arrowRenderer.render(arrowScene, arrowCamera);
};
function requestRender() {
if (!renderRequested) {
renderRequested = true;
requestAnimationFrame(render);
}
}
persControls.addEventListener('change', requestRender);
orthControls.addEventListener('change', requestRender);
renderer.domElement.addEventListener('mousemove', onMouseMove);
renderer.domElement.addEventListener('dblclick', onMouseDblClick);
window.addEventListener('resize', onMainCanvasResize, false);
onMainCanvasResize();
requestRender();
function onMainCanvasResize() {
const pixelRatio = window.devicePixelRatio;
const width = canvas.clientWidth * pixelRatio | 0;
const height = canvas.clientHeight * pixelRatio | 0;
const needResize = canvas.width !== width || canvas.height !== height;
const aspect = canvas.clientWidth / canvas.clientHeight;
if (needResize) {
renderer.setSize(width, height, false);
// See https://stackoverflow.com/questions/39373113/three-js-resize-window-not-scaling-properly
const change = originalAspect / aspect;
const newSize = viewSize * change;
orthCamera.left = -aspect * newSize / 2;
orthCamera.right = aspect * newSize / 2;
orthCamera.top = newSize / 2;
orthCamera.bottom = -newSize / 2;
orthCamera.updateProjectionMatrix();
persCamera.aspect = canvas.clientWidth / canvas.clientHeight;
persCamera.updateProjectionMatrix();
}
for (const obj of objects) {
obj.wirematerial.resolution.set(width, height);
}
requestRender();
}
// Use mouse double click to toggle the gui for the selected object
function onMouseDblClick(e){
let c = false;
if (cameraType == 'Orthographic') {
c = orthCamera;
}
if (cameraType == 'Perspective') {
c = persCamera;
}
if (!c) {
return;
}
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(
(e.clientX / canvas.clientWidth) * 2 - 1,
-(e.clientY / canvas.clientHeight) * 2 + 1),
c);
const intersects = raycaster.intersectObjects(raycasterObj);
for (const i of intersects) {
const m = i.object;
if (!m.material.visible){continue};
for (const obj of objects) {
for (const face_uuid of obj.faces) {
if (face_uuid == m.uuid) {
obj.gui_link.show = false
obj.wirematerial.visible = false
for (const face of obj.faces) {
scene.getObjectByName(face).material.visible = false
}
requestRender();
return
}
}
}
}
}
function onMouseMove(e) {
let c = false;
if (cameraType == 'Orthographic') {
c = orthCamera;
}
if (cameraType == 'Perspective') {
c = persCamera;
}
if (!c) {
return;
}
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(
(e.clientX / canvas.clientWidth) * 2 - 1,
-(e.clientY / canvas.clientHeight) * 2 + 1),
c);
const intersects = raycaster.intersectObjects(raycasterObj);
let chosen = '';
for (const i of intersects) {
const m = i.object.material;
if ((m.opacity > 0) & m.visible) {
if (m.emissive.getHex() == 0x000000) {
m.emissive.setHex( 0x777777 );
m.needsUpdate = true;
requestRender();
}
chosen = i.object.name;
break;
}
}
for (const r of raycasterObj) {
if (r.name == chosen) {
continue;
}
if (r.material.emissive.getHex() != 0x000000) {
r.material.emissive.setHex(0x000000);
r.material.needsUpdate = true;
requestRender();
}
}
}
</script>
</body>
</html>
""")
return None
def export(
exportList, filename: str, colors: dict[str, str] | None = None, camera: str | None = None
):
"""Exports objects to an html file"""
) -> bool:
"""Exports objects to an html file.
Returns:
bool: True if export was successful, False if not (particulary,
False if no template was available).
"""
# Check template availability first, before any processing
html = getHTMLTemplate()
if html is None:
# No template available - export failed
return False
global disableCompression, base, baseFloat
@@ -894,7 +371,6 @@ def export(
data["objects"].append(objdata)
html = getHTMLTemplate()
html = html.replace("$pagetitle", FreeCAD.ActiveDocument.Label)
version = FreeCAD.Version()
html = html.replace("$version", f"{version[0]}.{version[1]}.{version[2]}")
@@ -910,6 +386,7 @@ def export(
with pyopen(filename, "w", encoding="utf-8") as outfile:
outfile.write(html)
FreeCAD.Console.PrintMessage(translate("Arch", "Successfully written") + f" {filename}\n")
return True
def get_view_properties(obj, label: str, colors: dict[str, str] | None) -> tuple[str, float]:

View File

@@ -643,7 +643,8 @@ def _get_param_dictionary():
":/ui/preferences-dae.ui",
":/ui/preferences-ifc.ui",
":/ui/preferences-ifc-export.ui",
":/ui/preferences-sh3d-import.ui",):
":/ui/preferences-sh3d-import.ui",
":/ui/preferences-webgl.ui",):
# https://stackoverflow.com/questions/14750997/load-txt-file-from-resources-in-python
fd = QtCore.QFile(fnm)