530 lines
21 KiB
Python
530 lines
21 KiB
Python
# SPDX-License-Identifier: LGPL-2.1-or-later
|
|
|
|
# ***************************************************************************
|
|
# * *
|
|
# * 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. *
|
|
# * *
|
|
# * 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/>. *
|
|
# * *
|
|
# ***************************************************************************
|
|
|
|
# REFS:
|
|
# https://github.com/mrdoob/three.js/blob/master/examples/webgl_interactive_buffergeometry.html
|
|
# https://threejs.org/examples/#webgl_buffergeometry_lines
|
|
# https://forum.freecad.org/viewtopic.php?t=51245
|
|
# https://forum.freecad.org/viewtopic.php?t=29487
|
|
# https://threejs.org/examples/#webgl_raycast_sprite
|
|
#
|
|
# Params for export()
|
|
# 'colors' is of the form: {'Body': [1,0,0], 'Body001': [1,1,0], 'Body002': [1,0,1] }
|
|
# 'camera' is of the form: "PerspectiveCamera {\n viewportMapping ADJUST_CAMERA\n position 30.242626 -51.772324 85.63475\n orientation -0.4146691 0.088459305 -0.90566254 4.7065201\nnearDistance 53.126431\n farDistance 123.09125\n aspectRatio 1\n focalDistance 104.53851\n heightAngle 0.78539819\n\n}"
|
|
# The 'camera' string for the active document may be generated from: import OfflineRenderingUtils; OfflineRenderingUtils.getCamera(FreeCAD.ActiveDocument.FileName);
|
|
#
|
|
# Development reload oneliner:
|
|
# def re(): from importlib import reload;import importWebGL;reload(importWebGL);o=FreeCAD.getDocument("YourDocName");importWebGL.export([o.getObject("YourBodyName")],u"C:/path/to/your/file.htm");
|
|
|
|
## @package importWebGL
|
|
# \ingroup ARCH
|
|
# \brief FreeCAD WebGL Exporter
|
|
#
|
|
# 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_extensions import NotRequired, TypedDict
|
|
|
|
import numpy as np
|
|
|
|
import FreeCAD
|
|
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
|
|
|
|
def translate(ctxt, txt):
|
|
return txt
|
|
|
|
|
|
disableCompression = False # Compress object data before sending to JS
|
|
base = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!#$%&()*+-:;/=>?@[]^_,.{|}~`" # safe str chars for js in all cases
|
|
baseFloat = ",.-0123456789"
|
|
threejs_version = "0.172.0"
|
|
|
|
|
|
def getHTMLTemplate():
|
|
"""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.
|
|
"""
|
|
|
|
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
|
|
|
|
using_custom_template = params.get_param(
|
|
"useCustomWebGLExportTemplate", path="Mod/BIM"
|
|
)
|
|
|
|
# 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
|
|
|
|
# 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)
|
|
|
|
reply = QtWidgets.QMessageBox.question(
|
|
FreeCADGui.getMainWindow(),
|
|
translate("BIM", "WebGL Template Not Found"),
|
|
message,
|
|
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
|
|
QtWidgets.QMessageBox.Yes,
|
|
)
|
|
|
|
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
|
|
|
|
# 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
|
|
|
|
# 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)
|
|
|
|
# Use getMainWindow() as parent following FreeCAD patterns
|
|
parent = FreeCADGui.getMainWindow()
|
|
title = translate("BIM", "WebGL Export Template Error")
|
|
|
|
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"
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def export(
|
|
exportList, filename: str, colors: dict[str, str] | None = None, camera: str | None = None
|
|
) -> bool:
|
|
"""Exports objects to an html file.
|
|
|
|
Returns:
|
|
bool: True if export was successful, False if not (particularly,
|
|
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
|
|
|
|
data = {"camera": {}, "file": {}, "objects": []}
|
|
|
|
populate_camera(data["camera"], camera)
|
|
|
|
# Take the objects out of groups
|
|
objectslist = Draft.get_group_contents(exportList, walls=True, addgroups=False)
|
|
# objectslist = Arch.pruneIncluded(objectslist)
|
|
|
|
for obj in objectslist:
|
|
# Pull all obj data before we dig down the links
|
|
label = obj.Label
|
|
color, opacity = get_view_properties(obj, label, colors)
|
|
|
|
validObject = False
|
|
if obj.isDerivedFrom("Mesh::Feature"):
|
|
mesh = obj.Mesh
|
|
validObject = True
|
|
if obj.isDerivedFrom("Part::Feature"):
|
|
objShape = obj.Shape
|
|
validObject = True
|
|
if obj.isDerivedFrom("App::Link"):
|
|
linkPlacement = obj.LinkPlacement
|
|
while True: # drill down to get to the actual obj
|
|
if obj.isDerivedFrom("App::Link"):
|
|
if obj.ViewObject.OverrideMaterial:
|
|
color = Draft.getrgb(
|
|
obj.ViewObject.ShapeMaterial.DiffuseColor, testbw=False
|
|
)
|
|
obj = obj.LinkedObject
|
|
if hasattr(obj, "__len__"):
|
|
FreeCAD.Console.PrintMessage(f"{label}: Sub-Links are Unsupported.\n")
|
|
break
|
|
elif obj.isDerivedFrom("Part::Feature"):
|
|
objShape = obj.Shape.copy(False)
|
|
objShape.Placement = linkPlacement
|
|
validObject = True
|
|
break
|
|
elif obj.isDerivedFrom("Mesh::Feature"):
|
|
mesh = obj.Mesh.copy()
|
|
mesh.Placement = linkPlacement
|
|
validObject = True
|
|
break
|
|
|
|
if not validObject:
|
|
continue
|
|
|
|
objdata = {
|
|
"name": label,
|
|
"color": color,
|
|
"opacity": opacity,
|
|
"verts": "",
|
|
"facets": "",
|
|
"wires": [],
|
|
"faceColors": [],
|
|
"facesToFacets": [],
|
|
"floats": [],
|
|
}
|
|
|
|
if obj.isDerivedFrom("Part::Feature"):
|
|
deviation = 0.5
|
|
if FreeCADGui and hasattr(obj.ViewObject, "Deviation"):
|
|
deviation = obj.ViewObject.Deviation
|
|
|
|
# obj.ViewObject.DiffuseColor is length=1 when all faces are the same color, length=len(faces) for when they're not
|
|
if len(obj.ViewObject.DiffuseColor) == len(objShape.Faces):
|
|
for fc in obj.ViewObject.DiffuseColor:
|
|
objdata["faceColors"].append(Draft.getrgb(fc, testbw=False))
|
|
|
|
# get verts and facets for ENTIRE object
|
|
shapeData = objShape.tessellate(deviation)
|
|
mesh = Mesh.Mesh(shapeData)
|
|
|
|
if len(objShape.Faces) > 1:
|
|
# Map each Facet created by tessellate() to a Face so that it can be colored correctly using faceColors
|
|
# This is done by matching the results of a tessellate() on EACH FACE to the overall tessellate stored in shapeData
|
|
# if there is any error in matching these two then we display the whole object as one face and forgo the face colors
|
|
for f in objShape.Faces:
|
|
faceData = f.tessellate(deviation)
|
|
found = True
|
|
# face verts. List of type Vector()
|
|
for fv in range(len(faceData[0])):
|
|
found = False
|
|
for sv in range(len(shapeData[0])): # shape verts
|
|
# do not use isEqual() here
|
|
if faceData[0][fv] == shapeData[0][sv]:
|
|
# replace with the index of shapeData[0]
|
|
faceData[0][fv] = sv
|
|
found = True
|
|
break
|
|
if not found:
|
|
break
|
|
if not found:
|
|
FreeCAD.Console.PrintMessage("Facet to Face Mismatch.\n")
|
|
objdata["facesToFacets"] = []
|
|
break
|
|
|
|
# map each of the face facets to the shape facets and make a list of shape facet indices that belong to this face
|
|
facetList = []
|
|
for ff in faceData[1]: # face facets
|
|
found = False
|
|
for sf in range(len(shapeData[1])): # shape facets
|
|
if (
|
|
faceData[0][ff[0]] in shapeData[1][sf]
|
|
and faceData[0][ff[1]] in shapeData[1][sf]
|
|
and faceData[0][ff[2]] in shapeData[1][sf]
|
|
):
|
|
facetList.append(sf)
|
|
found = True
|
|
break
|
|
if not found:
|
|
break
|
|
if not found:
|
|
FreeCAD.Console.PrintMessage("Facet List Mismatch.\n")
|
|
objdata["facesToFacets"] = []
|
|
break
|
|
|
|
if not disableCompression:
|
|
facetList = baseEncode(facetList)
|
|
|
|
objdata["facesToFacets"].append(facetList)
|
|
|
|
wires = [] # Add wires
|
|
for f in objShape.Faces:
|
|
for w in f.Wires:
|
|
wo = Part.Wire(Part.__sortEdges__(w.Edges))
|
|
# use strings to avoid 0.00001 written as 1e-05
|
|
wire = []
|
|
for v in wo.discretize(QuasiDeflection=0.005):
|
|
wire.extend([f"{v.x:.5f}", f"{v.y:.5f}", f"{v.z:.5f}"])
|
|
wires.append(wire)
|
|
|
|
if not disableCompression:
|
|
wires, objdata["floats"] = compress_wires(wires, objdata["floats"])
|
|
objdata["wires"] = wires
|
|
|
|
vIndex = {}
|
|
verts = []
|
|
for p in mesh.Points:
|
|
vIndex[p.Index] = p.Index
|
|
verts.extend([f"{p.Vector.x:.5f}", f"{p.Vector.y:.5f}", f"{p.Vector.z:.5f}"])
|
|
|
|
facets = [vIndex[i] for f in mesh.Facets for i in f.PointIndices]
|
|
|
|
if not disableCompression:
|
|
verts, objdata["floats"] = compress_verts(verts, objdata["floats"])
|
|
objdata["floats"] = compress_floats(objdata["floats"])
|
|
facets = baseEncode(facets)
|
|
verts = baseEncode(verts)
|
|
|
|
objdata["facets"] = facets
|
|
objdata["verts"] = verts
|
|
|
|
data["objects"].append(objdata)
|
|
|
|
html = html.replace("$pagetitle", FreeCAD.ActiveDocument.Label)
|
|
version = FreeCAD.Version()
|
|
html = html.replace("$version", f"{version[0]}.{version[1]}.{version[2]}")
|
|
|
|
# Remove data compression in JS
|
|
data["compressed"] = not disableCompression
|
|
data["base"] = base
|
|
data["baseFloat"] = baseFloat
|
|
|
|
html = html.replace("$data", json.dumps(data, separators=(",", ":"))) # Shape Data
|
|
html = html.replace("$threejs_version", threejs_version)
|
|
|
|
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]:
|
|
"""Get the color and opacity of the object"""
|
|
color = "#cccccc"
|
|
opacity = 1.0
|
|
if FreeCADGui and hasattr(obj.ViewObject, "ShapeColor"):
|
|
color = Draft.getrgb(obj.ViewObject.ShapeColor, testbw=False)
|
|
opacity = int((100 - obj.ViewObject.Transparency) / 5) / 20 # 0>>1 with step of 0.05
|
|
elif colors:
|
|
if label in colors:
|
|
color = Draft.getrgb(colors[label], testbw=False)
|
|
return color, opacity
|
|
|
|
|
|
class CameraDict(TypedDict):
|
|
"""Dictionary for camera contents"""
|
|
|
|
type: NotRequired[str]
|
|
focalDistance: NotRequired[str]
|
|
position_x: NotRequired[str]
|
|
position_y: NotRequired[str]
|
|
position_z: NotRequired[str]
|
|
|
|
|
|
def populate_camera(data: CameraDict, camera: str | None):
|
|
if not FreeCADGui and not camera:
|
|
camera = OfflineRenderingUtils.getCamera(FreeCAD.ActiveDocument.FileName)
|
|
|
|
if camera:
|
|
# REF: src/Mod/BIM/OfflineRenderingUtils.py
|
|
camnode = OfflineRenderingUtils.getCoinCamera(camera)
|
|
cameraPosition = camnode.position.getValue().getValue()
|
|
data["type"] = "Orthographic"
|
|
if "PerspectiveCamera" in camera:
|
|
data["type"] = "Perspective"
|
|
data["focalDistance"] = camnode.focalDistance.getValue()
|
|
data["position_x"] = cameraPosition[0]
|
|
data["position_y"] = cameraPosition[1]
|
|
data["position_z"] = cameraPosition[2]
|
|
else:
|
|
v = FreeCADGui.ActiveDocument.ActiveView
|
|
data["type"] = v.getCameraType()
|
|
data["focalDistance"] = v.getCameraNode().focalDistance.getValue()
|
|
data["position_x"] = v.viewPosition().Base.x
|
|
data["position_y"] = v.viewPosition().Base.y
|
|
data["position_z"] = v.viewPosition().Base.z
|
|
|
|
|
|
def compress_floats(floats: list[str]) -> str:
|
|
"""Compress floats to base 90
|
|
|
|
Use ratio of 7x base13 to 4x base90 because 13^7 ~ 90^4
|
|
"""
|
|
fullstr = json.dumps(floats, separators=(",", ":"))
|
|
fullstr = fullstr.replace("[", "").replace("]", "").replace('"', "")
|
|
floatStr = ""
|
|
baseFloatCt = len(baseFloat)
|
|
baseCt = len(base)
|
|
for fs in range(0, len(fullstr), 7): # chunks of 7 chars, skip the first one
|
|
str7 = fullstr[fs : (fs + 7)]
|
|
quotient = 0
|
|
for s in range(len(str7)):
|
|
quotient += baseFloat.find(str7[s]) * pow(baseFloatCt, (6 - s))
|
|
for v in range(4):
|
|
floatStr += base[quotient % baseCt]
|
|
quotient = int(quotient / baseCt)
|
|
return floatStr
|
|
|
|
|
|
def compress_wires(wires: list[list[str]], floats: list[str]) -> tuple[list[list[str]], list[str]]:
|
|
"""
|
|
Create floats list to compress wires being written into the JS
|
|
"""
|
|
lengths = []
|
|
for w in wires:
|
|
lengths.append(len(w))
|
|
floats.extend(w)
|
|
|
|
float_arr, all_wires = np.unique(floats, return_inverse=True)
|
|
wire_arrays = np.array_split(all_wires, np.cumsum(lengths[:-1]))
|
|
return [baseEncode(w.tolist()) for w in wire_arrays], float_arr.tolist()
|
|
|
|
|
|
def compress_verts(verts: list[str], floats: list[str]) -> tuple[list[int], list[str]]:
|
|
"""
|
|
Create floats list to compress verts and wires being written into the JS
|
|
"""
|
|
floats_v, ind, verts_v = np.unique(verts, return_index=True, return_inverse=True)
|
|
|
|
# Reorder as np.unique orders the resulting array (needed for facet matching)
|
|
floats_v = floats_v[ind.argsort()]
|
|
reindex = dict(zip(ind.argsort(), np.arange(ind.size)))
|
|
verts_v = np.vectorize(lambda entry: reindex[entry])(verts_v)
|
|
|
|
# Get repeated indexes already existing from previous steps
|
|
v_in_w = np.nonzero(np.isin(floats_v, floats))[0]
|
|
w_in_v = np.nonzero(np.isin(floats, floats_v))[0]
|
|
v_in_w2 = np.where(~np.isin(floats_v, floats))
|
|
|
|
# Order values the same
|
|
v_in_w = v_in_w[floats_v[v_in_w].argsort()]
|
|
w_in_v = w_in_v[np.array(floats)[w_in_v].argsort()]
|
|
|
|
# Replace repeated indexes that exist in floats
|
|
new_index = len(floats)
|
|
verts_v += new_index
|
|
for vw, wv in zip(v_in_w + new_index, w_in_v):
|
|
verts_v[verts_v == vw] = wv
|
|
|
|
# Remove indexes of repeated entries in floats_v
|
|
for vw in (v_in_w + new_index)[v_in_w.argsort()][::-1]:
|
|
verts_v[verts_v > vw] -= 1
|
|
|
|
return verts_v.tolist(), np.concatenate([floats, floats_v[v_in_w2]]).tolist()
|
|
|
|
|
|
def baseEncode(arr: list[int]) -> str:
|
|
"""Compresses an array of ints into a base90 string"""
|
|
|
|
global base
|
|
if len(arr) == 0:
|
|
return ""
|
|
|
|
longest = 0
|
|
output = []
|
|
baseCt = len(base)
|
|
for v in range(len(arr)):
|
|
buffer = ""
|
|
quotient = arr[v]
|
|
while True:
|
|
buffer += base[quotient % baseCt]
|
|
quotient = int(quotient / baseCt)
|
|
if quotient == 0:
|
|
break
|
|
output.append(buffer)
|
|
if len(buffer) > longest:
|
|
longest = len(buffer)
|
|
output = [("{:>" + str(longest) + "}").format(x) for x in output] # pad each element
|
|
return str(longest) + ("").join(output)
|